合并编辑器生成结果原子提交改动
统一图片、视频与音频生成结果的事务提交和幂等重放 补齐改图任务权威来源快照与历史载荷迁移 同步 SpacetimeDB 表、procedure、客户端绑定和架构文档 保留 worker lease 校验、失败退款与紧凑结果契约
This commit is contained in:
@@ -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。
|
||||
@@ -6737,3 +6737,18 @@
|
||||
- 业务隔离:共享组件不等于共享规则。SFX 继续使用 Vidu `audio1.0`、2–10 秒、默认 5 秒、现有 Prompt 回退、1500 字限制、价格和提交链路;BGM 继续使用 canonical Prompt、200 字生成限制、30 个预设、AI 补全 / 简化、单层撤销、提交锁和 Suno。BGM 按 dialog ID 写回,SFX 继续走现有 `setGenerateDialog`,两条路径不得互换。
|
||||
- 非目标:本次只规划视图归并,不实现 SFX V2 的 ElevenLabs、中译英、自动时长、30 秒、Loop、一键优化或预设,不修改任何后端、External v1、Schema、计费或需求原文,也不新建配置驱动的 composer 框架。
|
||||
- 实施状态:已恢复共享音频 composer,独立完整 BGM composer 及其测试文件已删除,原覆盖完整迁入总 composer。Prompt / 预设 / controller / 总 composer `121/121`、surface 与 submission workflow `72/72` 通过,typecheck、变更文件 ESLint、Prettier、编码检查和差异检查通过;没有修改后端、契约或需求原文,也没有实现 SFX V2 独有功能。
|
||||
|
||||
## 2026-08-06 编辑器生成结果使用 durable receipt 与统一原子提交
|
||||
|
||||
- 背景:图片、改图、去背景、图集 / UI 多产物、角色动作、视频、音效和背景音乐在 OSS 结果可用后,仍分段 confirm object、创建 project resource / account asset、保存 canvas 和 complete job。任一中间失败都会留下部分业务事实;只把 `external_generation_job` 当 operation journal 又无法覆盖无 job 的 inline,也无法独立证明某批 resource/asset/canvas 已作为一笔提交完成。
|
||||
- 决策:新增私有 `editor_generation_operation` durable commit receipt,queue 与 inline 共用。`persist_editor_generation_result_and_return` 在一次 `try_with_tx` 内提交可选 asset object、全部 resource / asset / binding、可选 canvas V2 CAS、queue job 终态和 receipt。job 仍是队列、lease、计费和通知真相,receipt 只是提交凭证,不复制大快照或形成平行 read model。worker 成功走统一 procedure 后不再单独 complete job。
|
||||
- 身份与重放:queue 以 job ID 为 operation ID,inline 以稳定 request ID 为 operation ID;Provider task ID 只做审计。operation fingerprint 绑定规范请求,commit SHA-256 对完整提交输入的稳定 BSATN 编码做 domain-separated 哈希,另外绑定逐 slot 候选、布局与 job completion,不使用 Rust `Debug` 文本充当持久协议。receipt 存在且所有权威事实一致时才返回 `AlreadyApplied`;不重复事件、不刷新时间、不推进 canvas revision。receipt 缺失但稳定 resource/asset/binding 已存在必须失败关闭;事务前已单独确认的 object 只在全部字段精确相等时允许复用。
|
||||
- 并发、时间与 OSS 边界:canvas 冲突只刷新 project 重算布局,不重跑 Provider / OSS;`completed_at_micros` 必须为正数,候选原时间字段与它一起绑定 commit SHA-256,重放不重新取时;job 终态与事件使用 SpacetimeDB `ctx.timestamp`。OSS `PUT / HEAD` 仍在数据库事务外,事务失败可以留下无引用 object,不声称跨 OSS exactly-once。
|
||||
- queue 结果与 CAS 重试补充:普通画布 queue 只持久化 source/warning 元数据,Editor Agent 和 External API 分别只写入各自裁剪后的结果,最终 JSON 不得超过 512 KiB。消费者身份必须在 worker 从完整 claimed job 构造调用上下文时固化,不能从已裁剪的 summary 兼容快照反推。CAS 冲突最多刷新布局一次,只允许 revision/layers 和 layout `updated_at_micros` 随最新 project 变化,避免回拨并发用户更新时间;items、job payload 与 `completed_at_micros` 保持不变。每个 prepared commit 的传输未知结果最多原样重放两次,不重跑 Provider / OSS。
|
||||
- receipt 只保存 queue result 的 SHA-256,不复制最多 512 KiB 的 payload;重放时从已完成 job 回读权威 payload 并核对摘要。事务边界即使没有 asset_object candidate,也必须统一核对 resource/asset/binding 的 object ID/key/owner,并要求 canvas layout 与全部 project resource 属于同一 project。
|
||||
- 事务内还要先查同 `operation_id` 的 `external_generation_job`:存在则首次/重放都强制完整 completion guard,不存在才允许 inline。resource/asset 的尺寸、媒体引用、task、kind 与生成元数据按 item 交叉验证,音频 binding 使用 operation 限定 tuple 和显式 kind 映射。省略 candidate 的已登记 object 在 receipt 重放时仍回读 owner/key/task/kind/媒体身份。
|
||||
- queue 跨记录绑定继续失败关闭:job `source_entity_id` 必须就是结果唯一 project,所有 `source_resource_id` 必须已存在且属于同 owner / project。Provider 已成功但原子持久化确定失败时,当前 worker/lease 验证、当前计费 attempt 退款和 job 失败终态由同一 SpacetimeDB 事务结算;不在 api-server 先独立退款。
|
||||
- compact result 裁剪不得丢失消费 DTO 必填字段或正式素材定位信息:角色动作/视频保留 `ok`,音效/BGM 保留 `prompt`,External 角色动作与视频还保留稳定 `assetId`,不复制大型生成 payload。account asset 的 `source_resource_id` 与 project resource 一样验证候选/已登记来源的 owner,并在有项目上下文时验证 project。
|
||||
- External v1 的二次 allowlist 裁剪同样保留 `prompt / actualPrompt`,契约验收以 `serialize_atomic_editor_generation_job_result` 最终 JSON 为准,不只测上游 builder。图标/UI 正常与 source-only fallback 同时保留 `ok / prompt / actualPrompt`,fallback 的尺寸/model/价格也从本次生成上下文显式携带,不依赖可选 project resource。Editor Agent 图片生成/修改 DTO 允许 compact payload 不携带 `provider`。inline 八类 provider 生成的已成功 billing guard 延迟到 owner handler 完成 durable receipt 提交才 disarm;procedure 发出前的明确失败/取消退款,发出后回包前的传输不确定或取消保留扣款。
|
||||
- 影响范围:所有现役编辑器生成类型、`spacetime-module` / `spacetime-client` 结果提交契约、queue worker 终态写回、schema / migration / generated bindings 与对应故障注入测试。完美像素保留现有专用原子 procedure;手动图集拆分保留现有批量事务,其 canvas completion 并入批量事务另行收口。
|
||||
- 关联:`docs/technical/【后端架构】编辑器生成结果原子提交与幂等重放方案-2026-08-06.md`、Issue #134。
|
||||
|
||||
@@ -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 两条近似提示。
|
||||
@@ -4348,3 +4356,24 @@
|
||||
- 原因:冲突两侧代表不同组件架构,逐行保留看似有用的 JSX 会把一个架构中的局部条件拼进另一个架构。import 排序、格式检查和只覆盖单一 mode 的测试都不能证明这种组合成立。
|
||||
- 处理:先确定权威组件边界,再按完整调用链解决冲突。图片画布音频入口当前决策是恢复一个共享 `ImageCanvasAudioGenerationComposerView`,由组件内 `isSoundEffect` 分流;BGM/SFX 的 validator、写回、锁和提交契约仍分别保持。不要只补一个常量后继续维持已经废弃的双 composer 边界。
|
||||
- 验证:同时渲染 `audio-sound-effect` 与 `audio-background-music`,覆盖两个 mode 的正向控件和互斥负向断言、dialog / mode 切换、BGM 稳定 ID 与 controller 缺失的失败关闭,并运行 `ImageCanvasGenerationComposerView.test.tsx` 与 typecheck。
|
||||
|
||||
## 生成结果的稳定 ID 和 job 终态都不能代替 durable receipt(2026-08-06)
|
||||
|
||||
- 现象:Provider / OSS 已成功,但项目资源、账号素材、binding、画布和 job 只完成一部分;不确定结果重放时,有时又复制一批素材或重复推进 canvas revision。inline 路径在进程重启后尤其无法判断前一次提交是否整笔完成。
|
||||
- 原因:把“请求已入队”、“某个稳定 ID 已存在”或“job 已 completed”误当成整批业务记录已原子提交的证据。request fingerprint 只证明用户请求,不绑定最终 slot、派生记录、画布候选和 compact result;仅比较资源 ID 也无法发现内容漂移。
|
||||
- 处理:用 `editor_generation_operation` 记录 durable receipt,分开 request fingerprint 与整笔 commit SHA-256。首次调用在同一 SpacetimeDB 事务中校验 lease 并写 object/resource/asset/binding/canvas/job/receipt;重放先查 receipt,再读回逐 slot 权威事实精确比较。receipt 缺失但 resource/asset/binding 已存在时失败关闭,不得补写 receipt;事务前已确认的 asset object 只能在 ID、bucket/key、owner、策略、媒体、来源和实体字段全部相等时复用。
|
||||
- 时间与并发:`completed_at_micros` 必须为正数,object/resource/asset/binding/canvas 候选原时间字段与它一起纳入 commit SHA-256,不能在每次重放时重新取时;job 终态和完成事件只用 SpacetimeDB `ctx.timestamp`。canvas CAS 冲突后只刷新 project 并重算布局,不重跑 Provider / OSS。OSS 尚未进入该事务,无引用 object 仍是需另行清理的边界,不要宣称跨 OSS exactly-once。
|
||||
- queue completion 不能把 inline 完整响应无条件同时复制到 `result` 和 `editor-agent-tool-call-result`。图集/UI 最多 64 个切片会重复携带 resource/asset/prompt/generationInputs,容易超过 job payload 512 KiB 上限并让整个原子提交回滚。必须先按普通 UI、Editor Agent、External API 的消费方契约裁剪,再把最终 JSON 交给统一 procedure。
|
||||
- 消费方身份不能在提交前重新读取 summary 兼容快照来判断:该快照按设计清空 dedupe key 并删除 generationInputs,Editor Agent / External API 会因此被误判成普通 UI。应在 worker 持有完整 claimed job 时把安全的 consumer kind 与 source identity 固化到调用上下文。
|
||||
- procedure future 超时或连接断开不能直接映射为业务失败,远端事务可能已经提交。必须有界重放同一 prepared commit;明确 CAS 后才刷新 layout,且刷新 layout 应使用新时间,不能把项目 `updated_at` 回拨。receipt 不复制 queue payload,只存摘要并从 job 权威行回读;跨记录 object/project 一致性必须在事务内验证,不能依赖当前 builder 通常会携带完整 candidate。
|
||||
- job 的 owner/kind/fingerprint/lease 都正确仍不够:`source_entity_id` 还必须绑定结果项目,来源资源必须另查存在性与 owner/project 归属;否则同 owner 的 job 可以误写别的项目,或伪造跨用户/跨项目血缘。
|
||||
- Provider 成功时计费 guard 已解除,后续原子持久化失败不会自动退款。但也不能在 api-server 先独立退款再尝试 fail job:过期 worker、fail 断线或原子提交已成功但回包丢失时,会变成「结果成功且已退款」。正确边界是在同一 SpacetimeDB 事务内先 fencing 当前 lease,再同步写退款账本和失败终态;不得期待 `max_attempts = 1` 的编辑器任务再走租约耗尽路径补退。
|
||||
- compact result 只能删除大 payload,不能删除消费方 DTO 必填字段或定位正式结果的稳定引用。角色动作/视频缺 `ok`、音效/BGM 缺 `prompt` 都会让 Editor Agent 把已完成 job 判成不可重试的回填失败;External 角色动作/视频如果创建了账号素材,completed 结果还必须保留 `assetId`。
|
||||
- `project_resource.source_resource_id` 校验不会自动覆盖 `editor_asset.source_resource_id`;asset-only 结果可以没有项目资源候选,必须另查来源是本事务候选或已登记资源且属于同 owner;若本次结果有 project,还必须同 project。
|
||||
- inline 模式不会走 queue `fail_job`,若计费 wrapper 在 Provider 成功时立即 disarm,后续的上传/原子持久化明确失败会扣费无结果。应在全部 inline owner handler 外统一延迟已成功 billing guard 到 durable commit;明确失败退款,但传输未知结果不退,否则远端已成功时又会变成「结果 + 退款」。
|
||||
- 消费契约不能只测上游 builder:External v1 在 durable job 入库前还有一层 allowlist compactor,必须对最终 JSON 断言 `ok / prompt / actualPrompt` 及稳定 resource/asset 引用。
|
||||
- 计费 guard 的取消补偿必须区分 procedure dispatch 边界:`Build / PoolAcquire / ConnectBuild / ConnectHandshake` 等未发出阶段可确定退款;dispatch 后回包前的 future 取消与断连必须视为结果未知并保留扣款,等 durable receipt 对账。只在 error 返回后再标记 unknown 会留下取消窗口;必须在真正调用 procedure 前同步设置 task-local 标记,并在 `Procedure` 结果或确定未发出的失败后清除。
|
||||
- compact DTO 的可选字段必须用最终 consumer payload 回归:Editor Agent 图片生成/修改的 `provider` 会被脱敏删除,必须是可选字段;图标/UI 正常与 source-only fallback 则必须保留 `ok / prompt / actualPrompt`。fallback 不得从可选 project resource 反推必填字段,否则无 `projectId` 任务会持久 `prompt/model=null`、尺寸为零且图标/UI 丢失 `priceMudPoints`。
|
||||
- receipt 存在不等于引用 object 仍然可信:省略 candidate 的已登记 object 在重放时也要回读 owner/key/task/kind/媒体身份。同时先查同 operation ID job,存在 job 却漏传 completion 必须整笔回滚,否则会得到 receipt 成功而 job 仍 running 的永久分裂。resource/asset/binding 也不得仅核对 object ID/key,必须按 operation 合法 tuple 交叉验证业务元数据。
|
||||
- 验证:故障注入覆盖 resource 后 asset/binding 失败、canvas CAS 冲突、过期 lease、同 operation 异 fingerprint / 异 commit、receipt 缺失的部分既有记录、精确既有 object 复用与 object 内容漂移;成功重放必须证明记录数、时间、binding/job 事件数和 canvas revision 全部不变。
|
||||
- 关联:`docs/technical/【后端架构】编辑器生成结果原子提交与幂等重放方案-2026-08-06.md`、Issue #134。
|
||||
|
||||
@@ -29,7 +29,8 @@ VectorEngine `gpt-image-2`、音频、LLM 等外部生成不能由面向外部
|
||||
- `claim_external_generation_jobs_and_return`:worker 按 `worker_id`、`limit` 和 lease 时长抢占 `pending` 或 lease 过期的 `running` 任务,返回本次 claim 的 `lease_token`。
|
||||
- `renew_external_generation_job_lease_and_return`:worker 长任务执行期间按 `worker_id + lease_token` 续租,防止外部生成超过单次 lease 后被重复领取。
|
||||
- `update_external_generation_job_phase_and_return`:worker 按 `job_id + worker_id + lease_token` 把当前执行阶段更新为 `generating` 或 `processing`,并同步现有摘要投影;不新增阶段任务或阶段表。procedure 用结构化结果区分 `LeaseFencingRejected` 与 `OtherRejected`,调用方不解析错误文案;`LeaseFencingRejected` 立即终止,`OtherRejected` 以及 SDK 的 `Procedure` / `Runtime` 错误不重试,只有 `Build` / `ConnectDropped` / `Timeout` 在同一个 job attempt 内重试 `1` 次。该重试只重新上报 phase,不把任务写回 `pending`,也不重新调用 provider;编辑器 job 入队固定 `max_attempts=1`,第二次传输失败后任务进入 `failed`,不会回到 `pending` 或从 provider 生成起点重跑。
|
||||
- `complete_external_generation_job_and_return`:worker 成功后按 `worker_id + lease_token` 写入 `result_payload_json`,任务进入 `completed`。
|
||||
- `complete_external_generation_job_and_return`:只保留给不携带编辑器正式 object/resource/asset/canvas 业务写回的兼容路径。现役编辑器生成成功时不得单独调用它。
|
||||
- `persist_editor_generation_result_and_return`:编辑器 queue / inline 共用的结果提交口。单一事务写入可选 asset object、全部 project resource / account asset / asset binding、可选 canvas V2 CAS、queue job 完成与 durable receipt;返回 `Applied / AlreadyApplied` 和权威快照。
|
||||
- `fail_external_generation_job_and_return`:worker 失败后按 `worker_id + lease_token` 回写错误,并按 `max_attempts` 决定回到 `pending` 重试或进入 `failed`。
|
||||
- `list_external_generation_job_summaries_and_return`:按当前账号从轻量摘要投影读取正式生成任务列表,返回 pending / running / 未确认终态数量、任务价格、执行阶段和完成提示确认状态。
|
||||
- `acknowledge_external_generation_job_summaries_and_return`:按当前账号确认已终态任务的完成 / 失败提示,写入摘要投影的 `notification_acknowledged_at` 并追加审计事件。
|
||||
@@ -87,6 +88,8 @@ BFF 只做鉴权、授权裁剪、字段脱敏和契约映射;worker 调度、
|
||||
|
||||
新增私有审计表 `external_generation_job_event`,记录 `enqueued/claimed/lease_renewed/completed/failed/acknowledged` 等事件。事件表只追加状态转换事实,不作为当前状态源;排障时先看 `external_generation_job` 当前状态,再按 `job_id` 追 `external_generation_job_event` 时间线。
|
||||
|
||||
另新增私有 `editor_generation_operation` durable commit receipt。它不与 `external_generation_job` 争抢任务状态:job 仍负责队列、lease、计费和通知,receipt 只固化某个 owner/kind/operation 的 request fingerprint、整笔 commit SHA-256、可选 project 以及 queue 的 job/worker/lease/result 绑定。inline 虽没有 job,也必须写 receipt;否则 API 进程重启后无法安全区分“完整提交”与“稳定 ID 巧合/历史部分记录”。
|
||||
|
||||
索引:
|
||||
|
||||
- `by_external_generation_job_status_available(status, available_at)`
|
||||
@@ -205,9 +208,11 @@ controller 配置:
|
||||
- `editor_video_generation`:画布视频生成和视频素材快速编辑。
|
||||
- `editor_sound_effect_generation` / `editor_background_music_generation`:画布音效与背景音乐。
|
||||
|
||||
画板结果的业务真相仍是 `editor_project_resource`、账号级 `editor_asset` 和 `editor_canvas.layers_json`。请求携带 `projectId + canvasCompletion` 时,worker 成功后读取当前项目 layout,用最新 generation dialog placeholder 或无 dialog 完成占位写入结果图层,并保存项目快照;前端轮询单 job 到 completed 后重新读取项目快照,不从队列 payload 或本地临时响应重建正式图层。生成器已被删除时,worker 只保留生成出的资源 / 素材记录,不把结果重新塞回画布。
|
||||
画板结果的业务真相仍是 `asset_object`、`editor_project_resource`、账号级 `editor_asset`、可选 `asset_entity_binding` 和对应的 legacy / structured canvas 表;`editor_generation_operation` 只是提交回执,不替代这些 read model。worker 在 Provider 与 OSS 完成后只做 prepare:使用 owner + operation kind + job ID + stable slot 派生 resource/asset ID,构造可选 object/binding、候选 layout 和 compact job result,然后一次调用 `persist_editor_generation_result_and_return`。该 procedure 必须在当前事务快照校验 owner、job kind、由 `request_payload_json` 重算的 fingerprint 与未过期 lease,最后与业务记录一起完成 job 和 receipt。任一验证、binding 或 canvas CAS 失败都回滚全部数据库事实;worker 不得随后再调用 `complete_external_generation_job_and_return`。前端轮询单 job 到 completed 后重新读取项目快照,不从队列 payload、receipt 或本地临时响应重建正式图层。
|
||||
|
||||
角色形象、图标 spritesheet 和 UI 素材提取在 provider 原图已经持久化后,如果透明背景处理最终失败,只用原图完成 `canvasCompletion`,不创建或回填透明处理图,图标和 UI 也不继续拆分,任务保持 `completed`。这个 source-only 降级只包住透明背景处理的最终失败;phase 上报、provider 原图持久化、透明处理图持久化或画布写回失败仍按任务错误传播。
|
||||
结果重放必须保持同一 operation fingerprint 和同一 prepared commit:receipt 存在时核对 commit SHA-256、project/job/worker/lease/result 绑定与逐 slot 权威记录,完全一致才返回 `AlreadyApplied`,不重复 job/binding 事件或 canvas revision。receipt 缺失但任一稳定 resource/asset/binding 已存在、同 operation 异指纹/异内容、已过期 lease 都失败关闭;事务前已确认 object 只在候选全字段精确一致时复用。canvas CAS 冲突时只刷新 project 重算 layout,不再次调用 Provider 或上传 OSS。`completed_at_micros` 必须为正数,候选原时间字段与它一起绑定到 commit SHA-256,重放不得重新取时;job 终态与事件使用 SpacetimeDB `ctx.timestamp`。OSS `PUT / HEAD` 仍位于事务外,可留下无引用 object,不声称跨 OSS exactly-once。
|
||||
|
||||
角色形象、图标 spritesheet 和 UI 素材提取在 provider 原图已可用且 OSS 上传已验证后,如果透明背景处理最终失败,最终 prepared commit 只保留原图并用它完成 `canvasCompletion`,不创建或回填透明处理图,图标和 UI 也不继续拆分,任务保持 `completed`。这个 source-only 降级只包住透明背景处理的最终失败;phase 上报、原图候选构造、透明处理图候选构造或统一原子提交失败仍按任务错误传播。
|
||||
|
||||
透明背景处理正常成功时,角色形象、图标 spritesheet 和 UI 素材提取的画布都同时放透明主结果与 provider 原图:透明主结果保持生成器 `generatedLayerId` 主锚点,provider 原图作为第二个图层放在其右侧;图标和 UI 实际拆分出的业务素材从 provider 原图右侧继续排列。
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
# 编辑器生成结果原子提交与幂等重放方案
|
||||
|
||||
日期:`2026-08-06`
|
||||
|
||||
## 目标
|
||||
|
||||
修复 Issue #134:现役图片、图片修改、背景移除、图标图集、UI 素材提取、角色动作、视频、音效和背景音乐生成,在 OSS 结果已经可用后,必须把正式 `asset_object`、`editor_project_resource`、`editor_asset`、可选画布完成和队列终态作为同一个可重放提交处理,禁止继续按多个独立 SpacetimeDB procedure 分段写入。
|
||||
|
||||
本方案只承诺数据库内原子性。OSS `PUT / HEAD` 仍位于 SpacetimeDB 事务外;事务失败可能留下尚未登记或尚未引用的对象,后续按 operation 前缀做异步清理,不把它描述成跨 OSS 的 exactly-once。
|
||||
|
||||
## 权威操作身份
|
||||
|
||||
- 默认 queue 模式:`external_generation_job.job_id` 是唯一 operation ID。External v1 的 `Idempotency-Key`、主站稳定 `x-request-id` 和 Editor Agent 确定性任务 ID 都先收敛为该 job ID。
|
||||
- inline 兼容模式:使用 `RequestContext.request_id` 作为 operation ID,并对规范请求计算 SHA-256 fingerprint;同 ID 异 fingerprint 必须返回幂等冲突。inline 也必须写 durable receipt,不能只靠进程内 prepared result 或稳定记录 ID 猜测是否已提交。
|
||||
- Provider `taskId` 只保留为生成审计字段,不参与正式记录唯一性。
|
||||
- 每个 operation 的产物以稳定 `slot` 区分,例如 `provider-source`、`primary`、`processed`、`slice-0000`、`animation-preview`、`animation-final`。记录 ID 按 `owner + operation kind + operation ID + slot + record kind` 做 domain-separated SHA-256 派生,产物顺序变化不能改变既有 slot 的 ID。
|
||||
|
||||
## 统一 procedure
|
||||
|
||||
在 `spacetime-module` 增加 `persist_editor_generation_result_and_return`。procedure 只允许 editor generation runtime service identity 调用,并在一个 `try_with_tx` 内完成全部动作。
|
||||
|
||||
输入的编码级形状:
|
||||
|
||||
```rust
|
||||
EditorGenerationResultPersistItemInput {
|
||||
slot: String,
|
||||
asset_object: Option<AssetObjectUpsertInput>,
|
||||
project_resource: Option<EditorProjectResourceCreateInput>,
|
||||
asset: Option<EditorAssetCreateInput>,
|
||||
binding: Option<AssetEntityBindingInput>,
|
||||
}
|
||||
|
||||
EditorGenerationResultPersistInput {
|
||||
owner_user_id: String,
|
||||
operation_kind: String,
|
||||
operation_id: String,
|
||||
operation_fingerprint: String,
|
||||
items: Vec<EditorGenerationResultPersistItemInput>,
|
||||
canvas_layout: Option<EditorProjectLayoutSaveV2Input>,
|
||||
job_completion: Option<ExternalGenerationJobCompleteInput>,
|
||||
completed_at_micros: i64,
|
||||
}
|
||||
```
|
||||
|
||||
输出返回 `Applied / AlreadyApplied`、逐 slot 的 object/resource/asset/binding 快照、可选 project 快照和可选 job 快照。
|
||||
|
||||
### Durable receipt
|
||||
|
||||
新增私有表 `editor_generation_operation`,它是 queue 和 inline 共用的 durable commit receipt,不是第二套业务状态或任务队列。主键 `operation_key` 由 owner 和 operation ID 做 domain-separated SHA-256 派生,因此同 owner 不得跨 operation kind 复用同一 operation ID;表内固化 `owner_user_id / operation_kind / operation_id / operation_fingerprint / commit_sha256 / project_id / job_id / job_worker_id / job_lease_token / job_result_payload_sha256 / completed_at`。
|
||||
|
||||
- `operation_fingerprint` 绑定用户请求;`commit_sha256` 对完整 `EditorGenerationResultPersistInput` 的稳定 BSATN 编码做 domain-separated SHA-256,另外绑定本次准备提交的 slot、object/resource/asset/binding、画布候选与 job completion。不得使用 Rust `Debug` 文本充当持久协议,两类指纹也不得混为一个。
|
||||
- queue 路径必须把 receipt 与原 `job_id + worker_id + lease_token + result_payload_json` 全量绑定;receipt 只保存 payload SHA-256,不复制正文。首次提交仍必须验证当前有效 lease,完成后重放以 receipt 为提交凭证,并回读已完成 job 核对业务身份、权威 compact result 及其 SHA-256。
|
||||
- receipt 只保存幂等校验所需的有界元数据与摘要,不复制 project/canvas 大快照,不代替 resource、asset、binding 和 job 的权威表。
|
||||
|
||||
### 首次提交顺序
|
||||
|
||||
1. 校验调用身份、operation 字段、fingerprint、item 数量上限和 slot 唯一性。统一提交最多接受 66 个 item,用于容纳最多 64 个图集切片以及 provider 原图和透明整图。
|
||||
2. queue 输入必须完整携带 `job_id + worker_id + lease_token + result_payload_json`;inline 输入必须全部省略,禁止半套 guard。
|
||||
3. queue 路径在同一事务快照内校验 job owner、kind、request fingerprint、running 状态和有效 lease;过期 worker 不得写业务结果。`source_entity_id` 必须精确等于本次唯一结果 `project_id`,不得用同 owner 的 job 向其他项目提交。
|
||||
4. 对每个 item 校验稳定 object/resource/asset ID、owner、project、folder、object key、source resource、task 审计字段和媒体字段的交叉一致性。project resource 和 account asset 的 `source_resource_id` 均必须单独验证:来源资源必须是本次同事务候选或已登记资源,属于同 owner,且在结果具有项目上下文时属于同 project;不接受 asset-only 分支绕过血缘校验。
|
||||
5. `asset_object` 存在于输入时在同一事务内做精确 upsert;省略时,resource/asset/binding 引用的 object 必须已登记且属于同 owner。事务前 OSS `HEAD` 成功不等于 object 已正式登记。
|
||||
6. 创建全部 project resource、account asset 和可选 `asset_entity_binding`。binding 必须指向同 slot 的 object 与对应 resource/asset 实体,且 owner、asset kind、entity kind/id 和稳定 binding ID 完全一致。不得接受普通 media reuse 返回另一个随机 resource ID;稳定 ID 已被占用且内容不一致时失败关闭。
|
||||
7. 有 `canvas_layout` 时调用既有 V2 layout 持久化函数,以 `expected_revision` 做 CAS,并继续执行 legacy / structured 大小、资源引用和媒体族门禁。
|
||||
8. queue 路径最后调用事务内 job complete,写入调用方预先按现有规则构造的 compact result payload;再写入 durable receipt。任一步失败时 object/resource/asset/binding/canvas/job/receipt 全部回滚。
|
||||
|
||||
`completed_at_micros` 必须为正数,首次提交把它固化为 receipt `completed_at`。object/resource/asset/binding/canvas 候选各自现有的时间字段连同 `completed_at_micros` 一起进入 commit SHA-256;同一 prepared commit 的未知结果重放必须复用原时间,不得重新取时。明确的 canvas CAS 表示该事务已回滚,刷新 project 后形成新的 layout candidate,使用刷新时的 `updated_at_micros`,避免把并发用户刚写入的项目时间回拨。job `completed_at/updated_at` 与 job event 时间仍由 SpacetimeDB 事务时间 `ctx.timestamp` 产生,不信任调用方时钟。成功重放返回原快照,不刷新 receipt、业务记录、事件或 canvas revision。
|
||||
|
||||
### 重放
|
||||
|
||||
- receipt 存在时,只允许相同 owner/kind/ID、operation fingerprint、commit SHA-256 与原 project/job 绑定的完整重放;queue 额外核对原 worker/lease/result payload。逐 slot 权威 object/resource/asset/binding 和可选 project/job 仍必须可读且与候选一致,不得只看 receipt 就伪造快照。
|
||||
- receipt 存在且全部事实一致时返回 `AlreadyApplied`,不得新增记录、重复 binding changed / job completed 事件、刷新时间或推进 canvas revision。
|
||||
- receipt 缺失但任一稳定 asset object/resource/asset/binding、画布结果或已完成 job 已存在,属于可疑的部分写入,必须失败关闭;不得临时补 receipt 后声称幂等。现役生成 prepare 阶段只做 OSS PUT/HEAD,不得在统一 procedure 前单独登记稳定 asset object。
|
||||
- procedure 调用结果未知时,调用方最多自动重放同一 prepared commit 两次,不重新调用 Provider 或重新上传 OSS;明确的业务错误和 CAS 冲突不进入传输重放。
|
||||
- `operation_id` 在 `external_generation_job` 中已存在时,首次提交和 receipt 重放都必须携带与它一致的完整 job completion guard;只有事务内确认不存在同 ID job 时才允许 inline。
|
||||
- 同一 item 的 resource/asset 尺寸、媒体引用、task、asset kind 与生成元数据必须一致;binding 必须匹配 operation 明确允许的 entity/slot/kind/profile tuple。音频使用 `sound-effect -> editor_sound_effect`、`background-music -> editor_background_music` 显式映射,不使用粗暴的全字段硬等。
|
||||
- item 省略 `asset_object` candidate 而复用已登记对象时,首次提交与 `AlreadyApplied` 重放都要回读 canonical object,重新验证存在性、owner、object key、task、kind 和音频媒体类型。
|
||||
|
||||
## api-server 接入
|
||||
|
||||
- 通用持久化改为 `prepare -> build canvas candidate -> atomic commit`。prepare 阶段只生成稳定 ID、上传/验证对象和构造候选 DTO,不创建 resource/asset。
|
||||
- api-server 继续复用现有画布 completion / replacement 逻辑计算候选 `layers_json` 和 `expected_revision`;统一 procedure 在最终事务内重新执行既有 layout 校验和 CAS。
|
||||
- CAS 冲突只刷新当前 project、重新计算 layout 并重试 prepared commit;相同 operation、slot、对象和记录候选保持不变,禁止重跑 Provider。
|
||||
- 重新计算 layout 时只允许 revision、layers 与 layout `updated_at_micros` 随最新 project 变化;业务 items、job result payload 和 `completed_at_micros` 保持不变。首次 CAS 事务已明确回滚,因此刷新后的 layout 是新的 prepared commit;该 commit 若结果未知,只能原样重放自身。调用方最多自动刷新一次,第二次冲突直接返回。
|
||||
- queue completion 不持久化 inline handler 的完整响应:普通画布任务只保留 source/warning 元数据,Editor Agent 只写入裁剪后的 `editor-agent-tool-call-result`,External API 只写入裁剪后的 `result`。图集/UI 切片不得在 queue payload 中重复携带完整 resource/asset/prompt/generationInputs,最终 JSON 必须在 512 KiB 持久化上限内。
|
||||
- compact result 必须先满足原消费 DTO 的必填字段:角色动作/视频保留 `ok`,图标/UI 正常与 source-only fallback 保留 `ok / prompt / actualPrompt`,音效/BGM 保留 `prompt`;Editor Agent 与 External v1 的二次 allowlist 裁剪都不得再删除 `prompt / actualPrompt`,最终持久 payload 必须能反序列化为对应 response contract。Editor Agent 图片生成/修改 DTO 的 `provider` 为可选审计字段,compact payload 可删除它而不影响终态回填。External API 的角色动作与视频结果还必须保留本次已创建账号素材的稳定 `assetId`;裁剪可移除大 payload,但不得让 completed 结果无法定位正式素材。
|
||||
- queue 消费者身份在 worker 从完整 claimed job 构造调用上下文时固化;原子提交不得再从 summary 兼容快照反推,因为该快照会清空 dedupe key 并裁剪 request payload。
|
||||
- queue 的 compact result 当前不保存 `project`,因此可在事务前由稳定候选 resource/asset 和生成响应元数据构造;HTTP 成功响应中的 project 使用 procedure 返回的权威快照。
|
||||
- worker 在统一 procedure 已完成 job 后不得再次调用 `complete_external_generation_job`。只有 `Applied / AlreadyApplied` 才能作为成功终态。
|
||||
- Provider 已成功且计费 attempt 已扣款后,若原子提交确定失败并要把 job 置为终态 `failed`,必须由同一 SpacetimeDB 事务先验证当前 worker/lease,再结算当前 attempt 退款并写失败终态。不得在 api-server 先独立退款,否则过期 worker 或已成功但回包丢失的提交可能同时得到正式结果与退款。
|
||||
- inline 模式没有 job 失败事务补退,计费成功边界必须延迟到 durable result commit 完成。Provider/上传成功后的明确持久化失败退还已扣泥点;`Build / PoolAcquire / ConnectBuild / ConnectHandshake` 等 procedure 未发出阶段的失败或取消仍通过 deferred guard 退款。procedure dispatch 后到明确回包前必须标记结果未知;连续传输不确定或此窗口内 HTTP future 被取消时保留扣款,避免远端已成功时变成「正式结果 + 退款」。`Procedure` 回包是确定结果,成功或明确失败后必须清除未知标记。
|
||||
|
||||
## 多产物与现有特例
|
||||
|
||||
- 图片的 provider source、透明/规整结果和 source-only fallback 必须在最终选择明确后一次提交;fallback 只提交实际保留的结果集合。fallback compact result 的尺寸、`prompt / actualPrompt`、model 和图标/UI `priceMudPoints` 必须来自本次已冻结生成上下文,不得从可选 project resource 反推;不带 `projectId` 时仍必须产生完整消费契约。
|
||||
- 图标图集和 UI 提取使用稳定 slot 提交 provider source、透明整图和成功切片;切片失败时按既有 warning 语义只提交可信整图集合。
|
||||
- 角色动作一次提交预览视频与最终序列素材;逐帧 `asset_object` 可作为 item upsert 或已登记对象被最终序列引用,正式 project resource / account asset 与 canvas 不得分段提交。
|
||||
- 视频、音效和背景音乐使用单个 primary item。
|
||||
- 完美像素保留现有专用 operation/fingerprint/procedure;手动图集拆分不调用 Provider,不属于本次九类生成 job 的原子提交范围,继续使用现有批量 procedure 与画布完成链路。
|
||||
|
||||
## Schema 与兼容性
|
||||
|
||||
- 新增私有 `editor_generation_operation` durable receipt 表;它与 `external_generation_job` 分工,前者证明一笔业务结果原子提交,后者仍是 queue 执行、lease、计费和通知真相。新表必须纳入 `migration.rs` 导入/导出、schema 检查和本文档表目录。
|
||||
- 新增 Spacetime procedure/type ABI 后必须重新生成 `spacetime-client` bindings,并同步 facade mapper。
|
||||
- HTTP 路由、请求/响应 DTO、header、状态码和 External v1 异步语义保持不变,因此不修改 OpenAPI;必须复跑 External v1 契约测试证明没有漂移。
|
||||
- inline 兼容模式没有 durable job,但必须具有同样的 durable receipt、稳定 ID、fingerprint 和单事务重放;这仍不授权浏览器自动重试已可能发出的生成 POST,调用方应先走结果对账。
|
||||
|
||||
## 验收
|
||||
|
||||
- 资源创建后资产或 binding 校验失败:事务结束后 object/resource/asset/binding/canvas/job/receipt 均无部分写入。
|
||||
- 资源/资产创建后 canvas revision 冲突:全部业务记录回滚;使用同 operation 和 prepared result 刷新布局后可成功。
|
||||
- 成功后相同 operation 重放:返回原 object/resource/asset/binding/project/job,receipt 只有一行,记录数、时间、完成事件数和 canvas revision 不变。
|
||||
- 同 operation 异 request fingerprint、异 commit SHA-256、异 project/job 绑定、除精确可复用 object 外的部分既有记录、缺失 receipt 和过期 lease:失败关闭且零新增写入。
|
||||
- queue job 的 `source_entity_id` 与结果项目不同、或 `source_resource_id` 不属于同 owner / project:失败关闭且零新增写入。
|
||||
- Provider 成功后原子持久化确定失败:有效 lease、当前计费 attempt 退款与 job `failed` 在同一事务内成功或回滚;已 completed 或过期 lease 失败关闭且不退款。External 角色动作/视频成功结果保留稳定素材引用。
|
||||
- legacy 与 structured canvas、dialog 已删除、无 project/asset folder、单产物、多产物、64 切片和角色动作序列均覆盖。
|
||||
- 图片、修改、背景移除、图集、UI 提取、角色动作、视频、音效、背景音乐的生产路径不得再出现 `create resource -> create asset -> save canvas -> complete job` 分段组合。
|
||||
@@ -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 schema;Runtime 只接受恰好一次同名 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 动作关联。
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -59,15 +59,18 @@ layer 只表达“某个资源怎样放在画布上”。`src / prompt / actualP
|
||||
|
||||
### 3.5 worker 原子完成
|
||||
|
||||
worker 完成生成任务时,本次先用读取时 revision 调用 CAS 保存;发生并发变更时拒绝覆盖并让任务保留可诊断失败,不再静默覆盖用户布局。最终收口仍是受 `job_id + worker_id + lease_token` 栅栏保护的后端 procedure 在同一事务内:
|
||||
worker 完成生成任务时,`api-server` 先把 Provider / OSS 结果准备为稳定 operation/slot 候选,再调用 `persist_editor_generation_result_and_return`。procedure 受 editor generation runtime service identity 保护,queue 路径还必须在同一快照校验 `job_id + worker_id + lease_token`、owner、job kind 和由 job 规范请求重算的 SHA-256 fingerprint;inline 三个 job guard 全空,不接受半套栅栏。同一 `try_with_tx` 内:
|
||||
|
||||
1. 校验 job、owner、project、canvas、dialog 和租约;
|
||||
2. 幂等创建或确认 `editor_project_resource`;
|
||||
3. 创建 / 替换结果 layer,并删除或更新占位 layer;
|
||||
4. 把 dialog 更新为终态并关联 `generated_layer_id`;
|
||||
5. 递增 canvas revision,最后才允许完成 external job。
|
||||
1. 校验 operation 身份、request fingerprint、slot 唯一性、稳定 ID 和全部 owner/project/folder/source/task/媒体交叉关系;
|
||||
2. 精确 upsert 可选 `asset_object`,或验证省略的 object 已登记且归属同 owner;
|
||||
3. 创建全部 `editor_project_resource`、`editor_asset` 和可选 `asset_entity_binding`;
|
||||
4. 对候选布局重新执行 legacy / structured 验证,以 `expected_revision` CAS 写入 layer / dialog 完成态并且只递增一次 canvas revision;
|
||||
5. queue 路径写入 compact result 并完成 external job;
|
||||
6. 写入 `editor_generation_operation` durable receipt,固化 operation fingerprint、整笔 commit SHA-256、project/job/worker/lease/result 绑定和首次完成时间。
|
||||
|
||||
重复 completion 必须返回同一资源、layer 和 dialog 终态,不得重复插入,也不能因 dialog 暂时缺失而返回 `changed=false` 后仍把任务标记完成。任一步失败时整笔业务写回回滚,任务保留可诊断的失败或可重试状态。
|
||||
任一步失败时 object/resource/asset/binding/canvas/job/receipt 全部回滚。CAS 冲突时调用方只刷新当前 project 并重算 layout 候选,原 operation、slot、对象和业务记录候选不变,不重跑 Provider 或 OSS。完整重放只在 receipt 存在,且 request fingerprint、commit SHA-256、project/job 绑定与全部权威记录一致时返回 `AlreadyApplied`;不重复事件、不刷新时间、不推进 revision。receipt 缺失但稳定业务记录已存在、同 operation 内容漂移或不完整重放都必须失败关闭。
|
||||
|
||||
`completed_at_micros` 必须为正数并固化到 receipt;object/resource/asset/binding/canvas 候选的原时间字段也纳入 commit SHA-256,重放复用原 prepared commit,不重新取时。job 完成时间和完成事件使用事务 `ctx.timestamp`,不信任 worker 时钟。OSS `PUT / HEAD` 仍在 SpacetimeDB 事务外,因此事务失败可以留下未登记或未引用 object,不将本契约表述为跨 OSS exactly-once。
|
||||
|
||||
### 3.6 免费同步栅格派生完成
|
||||
|
||||
@@ -123,7 +126,7 @@ SpacetimeDB 必须先于依赖新 procedure / bindings 的 API 发布;前端
|
||||
- release 存量抽样中的缺资源 `local-*` 角色动作序列可无损 round-trip,并被识别为已持久化终态而非资源登记 pending;同形状但空帧、相对路径、HTTP / 签名 URL、`data:` / `blob:` 引用必须拒绝。已有资源的 `sourceResourceId == resourceId` 历史自引用应按资源表真相安全剥离,其他来源 ID 或资源字段冲突仍必须拒绝。
|
||||
- release 全量审计暴露的普通缺资源行必须先通过定向 repair dry-run;图片只能复用同工程唯一资源,音频只能从已登记 private asset_object 恢复。修复后同一 plan 全部命中 `already_repaired`,再重跑全量 backfill dry-run,要求所有 canvas 均通过。
|
||||
- structured 模式下 typed 列而非扩展 JSON 决定几何、层级、分组、显示 / 锁定、资源引用、`asset_kind_override` 和 dialog 状态;标签展示和类型能力判断统一按 `override ?? resource default`。修改当前图层标签与清除覆盖都保持 `resource_id` 和资源行数量不变;复制共享同一资源并复制 override,随后各副本可独立修改 override。两个客户端基于同一 revision 写入时只允许一个成功,冲突方重载后端最新快照,不换上新 revision 原样重放旧整包。细粒度 batch mutation 是取消 2 MiB 兼容入口的后续项,不冒充为本次已完成。
|
||||
- worker completion 当前以读取时 revision 做 CAS,冲突时拒绝覆盖;V2 保存和保存后快照在同一 procedure 结果内返回,避免“已提交但后续 GET 失败”的不确定结果。lease-fenced 资源 / layer / dialog / job 单事务 completion 仍是后续收口项。
|
||||
- worker completion 已使用 durable receipt 与统一原子提交;V2 布局 CAS、object/resource/asset/binding、job 终态和 receipt 在同一 procedure 结果内返回。故障注入必须证明资产校验失败与 canvas revision 冲突均为零部分写入,成功后重放不新增记录、事件或 revision。
|
||||
- structured 快照刷新后,上传参考图、生成结果、占位与 dialog 状态均可恢复;资源存在但布局写入失败时不会伪装为保存成功。
|
||||
- 完美像素处理失败 / 超时时 OSS、resource、asset 和 layer 均无新增;成功时只有一个最终 PNG、至多一个 project resource 和一个账号素材。处理中占位删除已先持久化时,完成请求不复活 dialog 或结果 layer;回包时本地占位已删除则不应用完成快照,已成功创建的资源 / 素材仍可读取;传输结果未知时客户端不自动重放 unsafe POST。
|
||||
- 回滚重组结果经 schema 校验、canonical hash / 资源引用核对且不超过 2 MiB;超限或不一致时明确拒绝且 structured 快照仍可读取。
|
||||
|
||||
@@ -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`;不得把它们压成单一错误字符串。这些字段只提供流程决策与诊断事实,是否重试、如何展示或持久化仍由业务调用方决定。
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
Generated
+1
@@ -5272,6 +5272,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use std::future::Future;
|
||||
use std::{
|
||||
cell::{Cell, RefCell},
|
||||
future::Future,
|
||||
};
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use serde_json::json;
|
||||
@@ -35,6 +38,78 @@ struct AssetOperationBillingPlan {
|
||||
|
||||
tokio::task_local! {
|
||||
static CURRENT_EXTERNAL_GENERATION_BILLING_CONTEXT: ExternalGenerationBillingContext;
|
||||
static DEFERRED_ASSET_OPERATION_REFUNDS: RefCell<Vec<AssetOperationRefundOnDrop>>;
|
||||
static EDITOR_GENERATION_COMMIT_DISPATCHED: Cell<bool>;
|
||||
static EDITOR_GENERATION_COMMIT_OUTCOME_UNKNOWN: Cell<bool>;
|
||||
}
|
||||
|
||||
pub(crate) async fn with_editor_generation_durable_billing_boundary<T, E, Fut>(
|
||||
future: Fut,
|
||||
) -> Result<T, E>
|
||||
where
|
||||
Fut: Future<Output = Result<T, E>>,
|
||||
{
|
||||
EDITOR_GENERATION_COMMIT_OUTCOME_UNKNOWN
|
||||
.scope(Cell::new(false), async {
|
||||
EDITOR_GENERATION_COMMIT_DISPATCHED
|
||||
.scope(Cell::new(false), async {
|
||||
DEFERRED_ASSET_OPERATION_REFUNDS
|
||||
.scope(RefCell::new(Vec::new()), async {
|
||||
let result = future.await;
|
||||
let outcome_unknown =
|
||||
EDITOR_GENERATION_COMMIT_OUTCOME_UNKNOWN.with(Cell::get);
|
||||
let refunds = DEFERRED_ASSET_OPERATION_REFUNDS.with(RefCell::take);
|
||||
match &result {
|
||||
Ok(_) => {
|
||||
for mut refund in refunds {
|
||||
refund.disarm();
|
||||
}
|
||||
}
|
||||
Err(_) if outcome_unknown => {
|
||||
// 远端提交可能已经成功;不能把正式结果与扣费同时保留后再退款。
|
||||
for mut refund in refunds {
|
||||
refund.disarm();
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
for mut refund in refunds {
|
||||
refund.refund_now().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
})
|
||||
.await
|
||||
})
|
||||
.await
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) fn begin_editor_generation_commit_attempt() {
|
||||
let _ = EDITOR_GENERATION_COMMIT_DISPATCHED.try_with(|dispatched| dispatched.set(false));
|
||||
}
|
||||
|
||||
pub(crate) fn mark_editor_generation_commit_dispatched() {
|
||||
let _ = EDITOR_GENERATION_COMMIT_DISPATCHED.try_with(|dispatched| dispatched.set(true));
|
||||
let _ = EDITOR_GENERATION_COMMIT_OUTCOME_UNKNOWN.try_with(|unknown| unknown.set(true));
|
||||
}
|
||||
|
||||
pub(crate) fn editor_generation_commit_attempt_was_dispatched() -> bool {
|
||||
EDITOR_GENERATION_COMMIT_DISPATCHED
|
||||
.try_with(Cell::get)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub(crate) fn mark_editor_generation_commit_outcome_confirmed() {
|
||||
let _ = EDITOR_GENERATION_COMMIT_DISPATCHED.try_with(|dispatched| dispatched.set(false));
|
||||
let _ = EDITOR_GENERATION_COMMIT_OUTCOME_UNKNOWN.try_with(|unknown| unknown.set(false));
|
||||
}
|
||||
|
||||
fn editor_generation_commit_outcome_is_unknown() -> bool {
|
||||
EDITOR_GENERATION_COMMIT_OUTCOME_UNKNOWN
|
||||
.try_with(Cell::get)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -145,7 +220,20 @@ where
|
||||
);
|
||||
match operation.await {
|
||||
Ok(value) => {
|
||||
refund_on_drop.disarm();
|
||||
let mut deferred_refund = Some(refund_on_drop);
|
||||
let deferred = DEFERRED_ASSET_OPERATION_REFUNDS
|
||||
.try_with(|refunds| {
|
||||
refunds
|
||||
.borrow_mut()
|
||||
.push(deferred_refund.take().expect("deferred refund guard"));
|
||||
})
|
||||
.is_ok();
|
||||
if !deferred {
|
||||
deferred_refund
|
||||
.as_mut()
|
||||
.expect("immediate refund guard")
|
||||
.disarm();
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
Err(error) => {
|
||||
@@ -225,6 +313,16 @@ impl Drop for AssetOperationRefundOnDrop {
|
||||
if !self.active {
|
||||
return;
|
||||
}
|
||||
if editor_generation_commit_outcome_is_unknown() {
|
||||
tracing::warn!(
|
||||
owner_user_id = self.owner_user_id,
|
||||
asset_kind = self.asset_kind,
|
||||
asset_id = self.asset_id,
|
||||
"编辑器生成提交结果未知,取消请求时保留扣费并等待 durable receipt 对账"
|
||||
);
|
||||
self.active = false;
|
||||
return;
|
||||
}
|
||||
let state = self.state.clone();
|
||||
let owner_user_id = self.owner_user_id.clone();
|
||||
let asset_kind = self.asset_kind.clone();
|
||||
@@ -826,4 +924,62 @@ mod tests {
|
||||
module_runtime::PROFILE_INVITE_CODE_METADATA_DEFAULT_JSON
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_provider_inline_route_defers_billing_until_durable_completion() {
|
||||
for (source, expected_calls) in [
|
||||
(include_str!("editor_project.rs"), 4),
|
||||
(include_str!("character_animation_assets.rs"), 2),
|
||||
(
|
||||
include_str!("vector_engine_audio_generation/generation.rs"),
|
||||
2,
|
||||
),
|
||||
] {
|
||||
assert_eq!(
|
||||
source
|
||||
.matches("with_editor_generation_durable_billing_boundary(")
|
||||
.count(),
|
||||
expected_calls,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn atomic_commit_dispatch_marks_unknown_before_the_cancellable_result_wait() {
|
||||
let editor_source = include_str!("editor_project.rs");
|
||||
let editor_body = editor_source
|
||||
.split_once("async fn persist_editor_generation_prepared_commit(")
|
||||
.and_then(|(_, tail)| {
|
||||
tail.split_once("async fn call_with_editor_generation_unknown_result_replay")
|
||||
.map(|(body, _)| body)
|
||||
})
|
||||
.expect("prepared commit helper");
|
||||
assert!(editor_body.contains("persist_editor_generation_result_with_dispatch"));
|
||||
assert!(editor_body.contains("mark_editor_generation_commit_dispatched"));
|
||||
|
||||
let client_source = include_str!("../../spacetime-client/src/editor_project.rs");
|
||||
let client_body = client_source
|
||||
.split_once("pub async fn persist_editor_generation_result_with_dispatch")
|
||||
.and_then(|(_, tail)| {
|
||||
tail.split_once("pub async fn create_editor_project")
|
||||
.map(|(body, _)| body)
|
||||
})
|
||||
.expect("dispatch-aware client procedure");
|
||||
let mark = client_body.find("on_dispatch();").expect("dispatch marker");
|
||||
let procedure = client_body
|
||||
.find(".persist_editor_generation_result_and_return_then(")
|
||||
.expect("procedure dispatch");
|
||||
|
||||
assert!(mark < procedure);
|
||||
|
||||
let billing_source = include_str!("asset_billing.rs");
|
||||
let drop_body = billing_source
|
||||
.split_once("impl Drop for AssetOperationRefundOnDrop")
|
||||
.and_then(|(_, tail)| {
|
||||
tail.split_once("pub(crate) fn should_refund_asset_operation_error")
|
||||
.map(|(body, _)| body)
|
||||
})
|
||||
.expect("refund guard drop");
|
||||
assert!(drop_body.contains("editor_generation_commit_outcome_is_unknown()"));
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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());
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -973,6 +973,7 @@ fn editor_generation_caller(
|
||||
audit_subject_user_id: Some(principal.owner_user_id().to_string()),
|
||||
audit_project_id: normalize_optional_string(project_id),
|
||||
phase_reporter: None,
|
||||
operation: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -41,7 +41,8 @@ use crate::{
|
||||
},
|
||||
editor_project::{
|
||||
EDITOR_GENERATION_MULTIPLE_WARNINGS_CODE, EDITOR_IMAGE_EDIT_QUEUE_PAYLOAD_VERSION,
|
||||
EditorBackgroundRemovalRequest, EditorGenerationCaller, EditorGenerationPhaseReporter,
|
||||
EditorBackgroundRemovalRequest, EditorGenerationCaller, EditorGenerationOperationContext,
|
||||
EditorGenerationPhaseReporter, EditorGenerationQueueResultContext,
|
||||
EditorIconSpritesheetGenerationRequest, EditorImageEditQueuePayload,
|
||||
EditorImageEditRequest, EditorImageEditResolvedSource, EditorImageGenerationRequest,
|
||||
EditorUiDesignAssetExtractionRequest, edit_editor_image_for_owner_with_source_snapshot,
|
||||
@@ -777,15 +778,7 @@ async fn process_external_generation_job_once(
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
complete_editor_generation_job_with_response(
|
||||
&state,
|
||||
&worker_id,
|
||||
&job,
|
||||
&response.0,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Ok(_) => Ok(()),
|
||||
Err(error) => {
|
||||
let message = error.body_text();
|
||||
fail_job(&state, &worker_id, &job, message.clone()).await?;
|
||||
@@ -811,9 +804,7 @@ async fn process_external_generation_job_once(
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
complete_editor_generation_job(&state, &worker_id, &job, result.0).await
|
||||
}
|
||||
Ok(_) => Ok(()),
|
||||
Err(error) => {
|
||||
let message = error.body_text();
|
||||
fail_job(&state, &worker_id, &job, message.clone()).await?;
|
||||
@@ -842,9 +833,7 @@ async fn process_external_generation_job_once(
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
complete_editor_generation_job(&state, &worker_id, &job, result.0).await
|
||||
}
|
||||
Ok(_) => Ok(()),
|
||||
Err(error) => {
|
||||
let message = error.body_text();
|
||||
fail_job(&state, &worker_id, &job, message.clone()).await?;
|
||||
@@ -872,15 +861,7 @@ async fn process_external_generation_job_once(
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
complete_editor_generation_job_with_response(
|
||||
&state,
|
||||
&worker_id,
|
||||
&job,
|
||||
&response.0,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Ok(_) => Ok(()),
|
||||
Err(error) => {
|
||||
let message = error.body_text();
|
||||
fail_job(&state, &worker_id, &job, message.clone()).await?;
|
||||
@@ -908,15 +889,7 @@ async fn process_external_generation_job_once(
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
complete_editor_generation_job_with_response(
|
||||
&state,
|
||||
&worker_id,
|
||||
&job,
|
||||
&response.0,
|
||||
)
|
||||
.await
|
||||
}
|
||||
Ok(_) => Ok(()),
|
||||
Err(error) => {
|
||||
let message = error.body_text();
|
||||
fail_job(&state, &worker_id, &job, message.clone()).await?;
|
||||
@@ -940,15 +913,12 @@ async fn process_external_generation_job_once(
|
||||
match generate_editor_character_animation_for_owner(
|
||||
state.clone(),
|
||||
request_context,
|
||||
job.owner_user_id.clone(),
|
||||
editor_generation_worker_caller(&worker_id, &job)?,
|
||||
Ok(Json(payload)),
|
||||
Some(editor_generation_phase_reporter(&worker_id, &job)?),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
complete_editor_generation_job(&state, &worker_id, &job, result.0).await
|
||||
}
|
||||
Ok(_) => Ok(()),
|
||||
Err(response) => {
|
||||
let message = response_error_message(response).await;
|
||||
fail_job(&state, &worker_id, &job, message.clone()).await?;
|
||||
@@ -972,14 +942,12 @@ async fn process_external_generation_job_once(
|
||||
match generate_editor_video_for_owner(
|
||||
state.clone(),
|
||||
request_context,
|
||||
job.owner_user_id.clone(),
|
||||
editor_generation_worker_caller(&worker_id, &job)?,
|
||||
Ok(Json(payload)),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
complete_editor_generation_job(&state, &worker_id, &job, result.0).await
|
||||
}
|
||||
Ok(_) => Ok(()),
|
||||
Err(response) => {
|
||||
let message = response_error_message(response).await;
|
||||
fail_job(&state, &worker_id, &job, message.clone()).await?;
|
||||
@@ -1003,14 +971,12 @@ async fn process_external_generation_job_once(
|
||||
match generate_editor_sound_effect_for_owner(
|
||||
state.clone(),
|
||||
request_context,
|
||||
job.owner_user_id.clone(),
|
||||
editor_generation_worker_caller(&worker_id, &job)?,
|
||||
Ok(Json(payload)),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
complete_editor_generation_job(&state, &worker_id, &job, result.0).await
|
||||
}
|
||||
Ok(_) => Ok(()),
|
||||
Err(response) => {
|
||||
let message = response_error_message(response).await;
|
||||
fail_job(&state, &worker_id, &job, message.clone()).await?;
|
||||
@@ -1034,14 +1000,12 @@ async fn process_external_generation_job_once(
|
||||
match generate_editor_background_music_for_owner(
|
||||
state.clone(),
|
||||
request_context,
|
||||
job.owner_user_id.clone(),
|
||||
editor_generation_worker_caller(&worker_id, &job)?,
|
||||
Ok(Json(payload)),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
complete_editor_generation_job(&state, &worker_id, &job, result.0).await
|
||||
}
|
||||
Ok(_) => Ok(()),
|
||||
Err(response) => {
|
||||
let message = response_error_message(response).await;
|
||||
fail_job(&state, &worker_id, &job, message.clone()).await?;
|
||||
@@ -1222,6 +1186,18 @@ fn editor_generation_worker_caller(
|
||||
audit_subject_user_id: Some(job.owner_user_id.clone()),
|
||||
audit_project_id: Some(job.source_entity_id.clone()),
|
||||
phase_reporter: Some(editor_generation_phase_reporter(worker_id, job)?),
|
||||
operation: Some(EditorGenerationOperationContext {
|
||||
operation_kind: job.job_kind.clone(),
|
||||
operation_id: job.job_id.clone(),
|
||||
operation_fingerprint:
|
||||
shared_contracts::editor_generation::editor_generation_request_fingerprint(
|
||||
job.job_kind.as_str(),
|
||||
job.request_payload_json.as_str(),
|
||||
),
|
||||
worker_id: Some(worker_id.to_string()),
|
||||
lease_token: Some(require_job_lease_token(job)?),
|
||||
queue_result_context: Some(EditorGenerationQueueResultContext::from_job(job)),
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1661,6 +1637,7 @@ async fn fail_job(
|
||||
error_message,
|
||||
retry_after_micros: now_micros.saturating_add(60_000_000),
|
||||
failed_at_micros: now_micros,
|
||||
// 退款由 SpacetimeDB 在验证 lease 后与失败终态同事务结算。
|
||||
refund_ledger_id: None,
|
||||
})
|
||||
.await
|
||||
@@ -2341,7 +2318,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn editor_image_job_completion_keeps_inline_response_warning() {
|
||||
fn editor_image_job_completion_is_committed_by_atomic_persistence() {
|
||||
let source = include_str!("external_generation_worker.rs");
|
||||
let start = source
|
||||
.find("EDITOR_IMAGE_GENERATION_JOB_KIND => {")
|
||||
@@ -2352,19 +2329,10 @@ mod tests {
|
||||
.expect("editor image worker branch end marker should exist");
|
||||
let branch = &branch_tail[..end];
|
||||
|
||||
for snippet in [
|
||||
"Ok(response)",
|
||||
"complete_editor_generation_job_with_response",
|
||||
"&response.0",
|
||||
] {
|
||||
assert!(
|
||||
branch.contains(snippet),
|
||||
"editor image completion should preserve response warning via {snippet}"
|
||||
);
|
||||
}
|
||||
assert!(branch.contains("Ok(_) => Ok(())"));
|
||||
assert!(
|
||||
!branch.contains("Ok(_) => complete_editor_generation_job"),
|
||||
"editor image completion must not discard the inline fallback warning"
|
||||
!branch.contains("complete_editor_generation_job"),
|
||||
"editor image completion must be part of the atomic result persistence"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2661,6 +2629,21 @@ mod tests {
|
||||
assert!(message.contains("90 秒"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_worker_failure_delegates_refund_to_the_fenced_failure_transaction() {
|
||||
let source = include_str!("external_generation_worker.rs");
|
||||
let body = source
|
||||
.split_once("async fn fail_job(")
|
||||
.and_then(|(_, tail)| {
|
||||
tail.split_once("async fn renew_job_lease(")
|
||||
.map(|(body, _)| body)
|
||||
})
|
||||
.expect("fail_job helper");
|
||||
assert!(!body.contains("settle_current_external_generation_attempt_refund("));
|
||||
assert!(body.contains(".fail_external_generation_job("));
|
||||
assert!(body.contains("refund_ledger_id: None"));
|
||||
}
|
||||
|
||||
fn external_generation_job_record_fixture(
|
||||
lease_token: Option<&str>,
|
||||
) -> ExternalGenerationJobRecord {
|
||||
|
||||
@@ -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_editor_agent_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);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
use axum::http::StatusCode;
|
||||
use module_assets::{
|
||||
AssetObjectAccessPolicy, build_asset_entity_binding_input, build_asset_object_upsert_input,
|
||||
generate_asset_binding_id, generate_asset_object_id,
|
||||
AssetEntityBindingInput, AssetObjectAccessPolicy, AssetObjectUpsertInput,
|
||||
build_asset_entity_binding_input, build_asset_object_upsert_input,
|
||||
};
|
||||
use platform_audio::{DownloadedAudio, GeneratedAudioPersistInput, GeneratedAudioPersistTarget};
|
||||
use serde_json::json;
|
||||
@@ -10,7 +10,7 @@ use crate::{http_error::AppError, platform_errors::map_oss_error, state::AppStat
|
||||
|
||||
use super::{
|
||||
clock::current_utc_micros,
|
||||
errors::{map_asset_field_error, map_spacetime_error},
|
||||
errors::map_asset_field_error,
|
||||
types::{AudioAssetBindingTarget, AudioAssetSlot},
|
||||
};
|
||||
|
||||
@@ -21,16 +21,45 @@ pub(super) struct PersistedAudioAsset {
|
||||
pub(super) audio_src: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(super) struct PreparedAudioAsset {
|
||||
pub(super) asset_object: AssetObjectUpsertInput,
|
||||
pub(super) binding: AssetEntityBindingInput,
|
||||
pub(super) object_key: String,
|
||||
pub(super) audio_src: String,
|
||||
}
|
||||
|
||||
pub(super) async fn persist_generated_audio_asset(
|
||||
_state: &AppState,
|
||||
_http_client: &reqwest::Client,
|
||||
_owner_user_id: &str,
|
||||
_task_id: &str,
|
||||
_slot: AudioAssetSlot,
|
||||
_task_kind: platform_audio::AudioTaskKind,
|
||||
_target: AudioAssetBindingTarget,
|
||||
_audio: DownloadedAudio,
|
||||
) -> Result<PersistedAudioAsset, AppError> {
|
||||
Err(
|
||||
AppError::from_status(StatusCode::INTERNAL_SERVER_ERROR).with_details(json!({
|
||||
"provider": "editor-generation-operation",
|
||||
"message": "旧音频分步持久化路径已禁用;编辑器生成必须使用原子结果提交。",
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) async fn prepare_generated_audio_asset(
|
||||
state: &AppState,
|
||||
http_client: &reqwest::Client,
|
||||
owner_user_id: &str,
|
||||
task_id: &str,
|
||||
_slot: AudioAssetSlot,
|
||||
provider_task_id: &str,
|
||||
storage_operation_id: &str,
|
||||
task_kind: platform_audio::AudioTaskKind,
|
||||
target: AudioAssetBindingTarget,
|
||||
audio: DownloadedAudio,
|
||||
) -> Result<PersistedAudioAsset, AppError> {
|
||||
asset_object_id: String,
|
||||
asset_binding_id: String,
|
||||
) -> Result<PreparedAudioAsset, AppError> {
|
||||
let oss_client = state.oss_client().ok_or_else(|| {
|
||||
AppError::from_status(StatusCode::SERVICE_UNAVAILABLE).with_details(json!({
|
||||
"provider": "aliyun-oss",
|
||||
@@ -42,7 +71,7 @@ pub(super) async fn persist_generated_audio_asset(
|
||||
let put_request =
|
||||
platform_audio::prepare_generated_audio_put_request(GeneratedAudioPersistInput {
|
||||
owner_user_id: owner_user_id.to_string(),
|
||||
task_id: task_id.to_string(),
|
||||
task_id: storage_operation_id.to_string(),
|
||||
task_kind,
|
||||
target: GeneratedAudioPersistTarget {
|
||||
entity_kind: target.entity_kind.clone(),
|
||||
@@ -69,49 +98,38 @@ pub(super) async fn persist_generated_audio_asset(
|
||||
.await
|
||||
.map_err(|error| map_oss_error(error, "aliyun-oss"))?;
|
||||
let now_micros = current_utc_micros();
|
||||
let asset_object = state
|
||||
.spacetime_client()
|
||||
.confirm_asset_object(
|
||||
build_asset_object_upsert_input(
|
||||
generate_asset_object_id(now_micros),
|
||||
head.bucket,
|
||||
head.object_key,
|
||||
AssetObjectAccessPolicy::Private,
|
||||
head.content_type.or(Some(audio_mime_type)),
|
||||
head.content_length,
|
||||
head.etag,
|
||||
target.asset_kind.clone(),
|
||||
Some(task_id.to_string()),
|
||||
Some(owner_user_id.to_string()),
|
||||
target.profile_id.clone(),
|
||||
Some(target.entity_id.clone()),
|
||||
now_micros,
|
||||
)
|
||||
.map_err(map_asset_field_error)?,
|
||||
)
|
||||
.await
|
||||
.map_err(map_spacetime_error)?;
|
||||
state
|
||||
.spacetime_client()
|
||||
.bind_asset_object_to_entity(
|
||||
build_asset_entity_binding_input(
|
||||
generate_asset_binding_id(now_micros),
|
||||
asset_object.asset_object_id.clone(),
|
||||
target.entity_kind,
|
||||
target.entity_id,
|
||||
target.slot,
|
||||
target.asset_kind,
|
||||
Some(owner_user_id.to_string()),
|
||||
target.profile_id,
|
||||
now_micros,
|
||||
)
|
||||
.map_err(map_asset_field_error)?,
|
||||
)
|
||||
.await
|
||||
.map_err(map_spacetime_error)?;
|
||||
let asset_object = build_asset_object_upsert_input(
|
||||
asset_object_id.clone(),
|
||||
head.bucket,
|
||||
head.object_key,
|
||||
AssetObjectAccessPolicy::Private,
|
||||
head.content_type.or(Some(audio_mime_type)),
|
||||
head.content_length,
|
||||
head.etag,
|
||||
target.asset_kind.clone(),
|
||||
Some(provider_task_id.to_string()),
|
||||
Some(owner_user_id.to_string()),
|
||||
target.profile_id.clone(),
|
||||
Some(target.entity_id.clone()),
|
||||
now_micros,
|
||||
)
|
||||
.map_err(map_asset_field_error)?;
|
||||
let binding = build_asset_entity_binding_input(
|
||||
asset_binding_id,
|
||||
asset_object_id,
|
||||
target.entity_kind,
|
||||
target.entity_id,
|
||||
target.slot,
|
||||
target.asset_kind,
|
||||
Some(owner_user_id.to_string()),
|
||||
target.profile_id,
|
||||
now_micros,
|
||||
)
|
||||
.map_err(map_asset_field_error)?;
|
||||
|
||||
Ok(PersistedAudioAsset {
|
||||
asset_object_id: asset_object.asset_object_id,
|
||||
Ok(PreparedAudioAsset {
|
||||
asset_object,
|
||||
binding,
|
||||
object_key: put_result.object_key,
|
||||
audio_src: put_result.legacy_public_path,
|
||||
})
|
||||
|
||||
@@ -144,7 +144,8 @@ pub struct EditorImageEditResult {
|
||||
pub prompt: String,
|
||||
pub actual_prompt: Option<String>,
|
||||
pub model: String,
|
||||
pub provider: String,
|
||||
#[serde(default)]
|
||||
pub provider: Option<String>,
|
||||
pub task_id: String,
|
||||
pub resource: Option<Value>,
|
||||
pub asset: Option<Value>,
|
||||
@@ -193,3 +194,30 @@ impl EditImageTool {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::EditorImageEditResult;
|
||||
|
||||
#[test]
|
||||
fn compact_edit_result_deserializes_without_provider() {
|
||||
let result: EditorImageEditResult = serde_json::from_value(serde_json::json!({
|
||||
"imageSrc": "/api/assets/object/edited.png",
|
||||
"objectKey": "generated/edited.png",
|
||||
"assetObjectId": "asset-object-2",
|
||||
"width": 512,
|
||||
"height": 512,
|
||||
"sourceType": "generated",
|
||||
"prompt": "修改图片",
|
||||
"actualPrompt": "修改后的图片",
|
||||
"model": "gpt-image-2",
|
||||
"taskId": "task-2",
|
||||
"resource": null,
|
||||
"asset": null,
|
||||
"project": null
|
||||
}))
|
||||
.expect("compact Agent edit result should not require provider");
|
||||
|
||||
assert_eq!(result.provider, None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,7 +160,8 @@ pub struct EditorImageGenerationResult {
|
||||
pub prompt: String,
|
||||
pub actual_prompt: Option<String>,
|
||||
pub model: String,
|
||||
pub provider: String,
|
||||
#[serde(default)]
|
||||
pub provider: Option<String>,
|
||||
pub task_id: String,
|
||||
pub resource: Option<Value>,
|
||||
pub asset: Option<Value>,
|
||||
@@ -205,3 +206,30 @@ impl GenerateImageTool {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::EditorImageGenerationResult;
|
||||
|
||||
#[test]
|
||||
fn compact_generation_result_deserializes_without_provider() {
|
||||
let result: EditorImageGenerationResult = serde_json::from_value(serde_json::json!({
|
||||
"imageSrc": "/api/assets/object/generated.png",
|
||||
"objectKey": "generated/image.png",
|
||||
"assetObjectId": "asset-object-1",
|
||||
"width": 512,
|
||||
"height": 512,
|
||||
"sourceType": "generated",
|
||||
"prompt": "生成一张图片",
|
||||
"actualPrompt": "生成一张清晰图片",
|
||||
"model": "gpt-image-2",
|
||||
"taskId": "task-1",
|
||||
"resource": null,
|
||||
"asset": null,
|
||||
"project": null
|
||||
}))
|
||||
.expect("compact Agent generation result should not require provider");
|
||||
|
||||
assert_eq!(result.provider, None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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. 设计文档
|
||||
|
||||
|
||||
@@ -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!(
|
||||
"{}/{}",
|
||||
@@ -2252,31 +2282,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,
|
||||
@@ -4017,6 +4057,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("系统", "用户");
|
||||
@@ -4025,6 +4092,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("系统", "用户")
|
||||
@@ -4865,6 +5001,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());
|
||||
}
|
||||
|
||||
@@ -4973,6 +5111,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
|
||||
@@ -4989,6 +5128,7 @@ mod tests {
|
||||
]),
|
||||
])
|
||||
.with_openai_chat()
|
||||
.with_max_output_tokens(256)
|
||||
.with_response_reasoning_effort(LlmResponseReasoningEffort::Low),
|
||||
)
|
||||
.await
|
||||
@@ -5007,6 +5147,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"],
|
||||
|
||||
@@ -12,3 +12,4 @@ oss-contracts = []
|
||||
[dependencies]
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user