Files
PolyGateway/research-wiki/designs/2026-10-01-issue29-openai-fidelity-design.md
T
iomgaa 9185eab8c9 feat: preserve governed OpenAI tool messages in 1.4.0
Add faithful single-choice chat results, tool stream activity, error-frame retries, and isolated protocol caching.

Reject empty cached completions with the same value predicate as transport, add regression coverage, and adopt Apache-2.0.
2026-10-01 14:32:19 -04:00

17 KiB
Raw Blame History

type, node_id, title, date
type node_id title date
design design:2026-10-01-issue29-openai-fidelity-design OpenAI消息保真设计 2026-10-01

#29 OpenAI 消息保真设计

状态:方向已获人类批准;计划独立审查 P1/P2 已修订并实施,首轮独立代码审查发现的缓存空产物及测试双向依赖已修复,等待父会话最终复核;1.4.0 发布准备,尚未发布。 基线:0e29deac1a72bfee348ed2f8061ea2c33be217e8 / 1.3.8;日期:2026-10-01。 需求:issue #29。 权威约束:ARCHITECTURE §2.3、§5、§6、§7.1–7.8;实现前按本文修订对应架构段落。

1. 批准记录与范围

用户已明确同意:现有 chat() 增加显式 OpenAI 保真模式,LLMResponse 增加完整协议结果,复用治理链,修复工具聚合、活性、error 帧和缓存隔离;首版单 choice、不执行工具、不增加 Agent Loop。用户随后确认内部移植代码均为其原创且本人 owner,并明确授权 PolyGateway 使用 Apache-2.0;dissect3 也是其项目。以上不再重复请示。

Codex 不可用,人类已授权原生 subagent 替代独立审查。具体 API 拼写与防御约束由本设计落实,经独立审查后实施;若审查要求改变已批准边界,才重新讨论。设计阶段已结束;实现验证记录见计划末尾,独立代码审查/发布前禁止宣称 issue 已解决。

“保真”是受支持 OpenAI Chat Completions 消息字段和值保真,不是 HTTP 字节级透明代理,不覆盖任意供应商增量方言。model、stream、stream_options 仍由库控制;没有逐块回调、工具执行、同步客户端、美元预算或失败用量取证新功能。

2. 源码核验与方案比较

已核实接缝 现状与修复目标
transports/openai_compat.py::_sse_delta 只读首 choice;content 早返回丢同帧 reasoning;忽略 tool_calls/error
_complete_stream / _complete_once 纯文本判空;raw 只存 usage;需新增协议快照与 tool-only 成功语义
client.py::chat / _build_payload messages 已直传,overlay 已合并,无需白名单重写
thinking.py::validate_thinking_raw raw-only 已支持;受管与 raw 冲突是既定保护,不得绕过
middleware/retry.py::_build_response 唯一响应构造出口,新增字段在此搬运,不新增治理循环
middleware/cache.py 已有完整消息/overlay 身份,但嵌套新类型需重建、模式需隔离
方案 优势 代价/结论
A:chat 显式模式 + LLMResponse 内嵌协议结果 生命周期、取消、缓存、遥测和对冲单一实现;兼容旧投影 需定义组合约束和缓存身份;选定
B:新增 chat_completion 入口,复用私有核心 接口像 OpenAI 重复公共签名、输入校验和终态封装,收益不足,否决
C:独立 SDK 客户端或代理自聚合 局部改动直观 无法自动解决响应上浮/缓存/线路错误,复制治理,否决

3. 公共契约

3.1 模式与类型

在 types.py 定义并从包根导出 ResponseMode 和 OpenAIChatCompletion:

ResponseMode = Literal["text", "openai"]

@dataclass(frozen=True)
class OpenAIChatCompletion:
    """单 choice 的协议消息快照;嵌套 JSON 并非深度不可变。"""
    message: dict[str, Any]
    finish_reason: str | None
    response_id: str | None
    usage: dict[str, Any] | None

GatewayClient.chat 末尾追加 keyword-only response_mode: ResponseMode = "text";ChatRequest 末尾同字段同默认。TransportResult、LLMResponse 末尾追加 completion: OpenAIChatCompletion | None = None,已有字段不改名、不改位置。保真模式成功时 completion 必非 None;文本模式不承诺提供 completion。

Transport.complete 追加必填 keyword-only response_mode: ResponseMode,依照现有端口纪律不使用默认值掩盖漏传。默认 transport 实现和测试 transport 一并适配;不新增 SDK 适配器(当前文件树尚无该实现)。自定义 transport 须实现新参数与返回契约,不支持模式时抛 RequestRejectedError,不静默回退。

completion 是独立于上游容器的深拷贝 JSON 快照;frozen 只保证字段不能重绑,不伪称深度不可变。调用方可用 copy.deepcopy(response.completion.message) 构造下一轮;其修改不得污染别次调用、缓存条目或另一路对冲。不上 MappingProxyType,避免与既有 dataclasses.asdict/JSON 序列化冲突。

3.2 请求与组合校验

条件 规则
未知模式 公共入口 ValueError,尚未创建逻辑调用上下文
openai + structured 非 None 公共入口 ValueError;不注入 schema、不自动重问;overlay 的 response_format 仍可直传
openai + n 仅允许缺省或严格整数 1(不接受 bool);调用 overlay 在入口校验,选中源 extra_body 及最终 payload 在 HTTP 前校验,不接受被 overlay 遮蔽的非法源级 n
text + n 保留旧行为,本次不扩大旧文本多 choice 语义;明确只投影第一 choice
消息 保留 assistant/tool/reasoning_content/扩展字段,JSON 值不改写;保真入口校验可序列化并深拷贝,避免并发/重试期间调用方改动
overlay 复用既有保护键、源级→请求级优先级;保真调用对快照作深拷贝,不原地改调用方容器

源级 n 错误属选源后的请求拒绝,抛 RequestRejectedError,finally 结算规则不变。保真响应必须恰好一个 choice;SSE choice.index 必须为整数 0,usage-only choices=[] 合法;非流式一个 choice 可省略 index,存在时必须为 0。上游返回多 choice 是协议失败 TransientError,不能静默截取。

3.3 推理所有权不随返回模式变化

复用 effective_effort、resolve_thinking、validate_thinking_raw,不改能力表与档位映射。raw-only 示例前提:源级 enable_thinking=None、reasoning_effort=None,请求级 reasoning_effort=None,然后 overlay 传 {"reasoning_effort": "max"}。chat(reasoning_effort=None) 是继承源,不是撤销源意图;raw 与任何有效受管意图(含 NONE)并存仍拒绝。raw-only 的 applied_effort 仍 None,不编造受管档位。

3.4 兼容投影与上游身份

完整协议 message 不过 _finalize_text,保留 content 的缺省/null/空串区别、空白、think 标签;非流式保留整个 message 的 JSON 扩展字段。LLMResponse.content/thinking 仍是旧 _finalize_text 投影,content 为 null/缺省时投影空串;只用于旧消费面和现有遥测,代理回传以 completion 为准。

response_id 是上游 id,允许未上报为 None,绝不拿 call_id 补齐;finish_reason 原样保存,缺失为 None,不从工具存在与否猜测;usage 保存原对象快照(最后一个非 null usage),不因 token 归一或 salvage 修改原文。字段存在但类型不符是协议失败。length 原样返回,不修复工具参数;下游决定是否执行。

4. 协议聚合与完成规则

新增内部模块 transports/_openai_completion.py,只处理 chat 单 choice 协议校验、每尝试聚合与快照;不做 I/O、重试、计时、provider 推理或缓存。现有 openai_compat 保留 HTTP、看门狗、投影、usage 归一化与领域上下文。

4.1 SSE 聚合状态

每次 complete 独立创建聚合器,不放 transport 实例共享状态。逐 JSON 帧先校验对象并查 error,再读取 choices/delta、元信息和 usage;坏类型统一转 TransientError,不让 AttributeError/TypeError 逃出治理。

字段 合并规则
content / reasoning_content 非 null 必为 str,逐片拼接;同帧同时收集;不 trim;从未出现则不造键,仅 null 则保留 null
role openai 模式若出现必须 assistant;只出现 role 不算活性;最终成功必须含 assistant role;缺失失败,不伪造字段
tool_calls 必为列表;每项 index 必为非负严格整数;按 index 分桶,末尾按 index 排序,索引不输出到最终工具对象
id / type 每工具的身份字段,缺失片段不覆盖;相同重复幂等,冲突拒绝;不把重复完整 id 拼两次
function.name / function.arguments 字符串增量按到达顺序逐字拼接,允许空片段;arguments 不 JSON 解析/修复/重排
response id / finish_reason 首个非 null 固定,后续相同值允许、冲突拒绝;model_reported 沿用既有首有效值口径

最终有效工具需非空 id、type="function"、非空 function.name,以及至少出现一次字符串 arguments(允许空串);重复工具 id、缺字段、非 function 工具类型都拒绝,不伪造或丢弃。index 可非连续,按排序输出,不按列表位置猜 index。JSON 参数内容及工具业务 schema 不由库验证。

SSE、非流式及缓存最终快照均要求 role="assistant";缺失或其他 role 为协议失败/缓存 miss;合法 role-only 开头不算活性。

非流式工具是完整列表:保留条目及扩展字段,仅校验上述必要字段/类型/唯一 id,不要求 index;存在的扩展字段不删除。SSE 保真仅支持 role/content/reasoning_content/tool_calls;未知 delta 字段或未知工具增量字段无法推定合并语义,明确拒绝为协议失败,避免宣称任意扩展字段无损;元数据如 logprobs 不属于本协议结果字段,文档明确不返回。

4.2 成功、截断与错误

场景 行为
openai:空正文 + 有完整工具 成功,不触发空补全重试
openai:非空投影正文、无工具 成功;存在任何不完整工具则仍拒绝,不用正文掩盖坏工具
openai:无正文且无有效工具 维持 empty_completion 瞬时失败;reasoning-only 不算成功
openai:EOF 无 DONE 无论 missing_done=salvage、是否已有正文/工具/finish,都失败;不打捞潜在可执行结果
text:EOF 无 DONE 保持已有 early_eof/retry/salvage 及 estimated/unavailable 行为
任一 chat 模式出现顶层 error 键 立即 TransientError,即使值 null/空对象;不读取后续 usage/DONE 挽救,不缓存
非流式 200 error / 非 JSON / 坏 envelope TransientError;HTTP 非 200 分类沿用现有规则

text 仍不接收 tool-only 成功,不承诺工具结果;共用解析路径的 error 检查、同帧双文本采集和真实工具进展活性修复适用于两模式。纯文本消费不新增工具完整性要求,以免把未承诺的工具结果变成默认请求失败。

5. 活性、治理、取消和可观测

一帧有非空正文、非空推理、非空工具 name/arguments 增量或首次建立工具身份时,产生一次进展事件。重复相同身份、空 arguments、空 delta、role-only、usage-only、finish-only、注释不刷新活性。同帧多个字段和两个工具仍只记录一个时间戳,避免制造零间隔或虚增 stall_count。

stream_with_liveness_timeouts 继续包进展迭代器;openai_compat 用同一事件更新时间戳、TTFT、max_gap、first_token_event、content_event。保持 _stall_from_chunk_times 与 retry 唯一取整点不变;真正沉默照常触发中段对冲。非流式不置两事件,stall=None,只保留纯时长阈值对冲。

请求经原 Telemetry→Cache→Structured(None 直通)→Retry→准入→Transport 链。重试、异源对冲、失败预算、源健康、限流结算均原样复用;输家取消仍保守 est 结算、不喂熔断,赢家携自身完整快照。CancelledError 全栈穿透,连接与 permit finally 释放,无新的 shield 或后台任务。

遵循 structured-logging:不新增数据库列/表/Recorder 参数。_build_response 搬运 completion;错误由 transport 抛出,经过既有 RetryMW._emit→TelemetryEmitter 单出口记 attempt/terminal 行。SSE error 使用现有 summarize_body/compose_message 有界摘要,携 source_name/operation=chat,最多记录有界错误体,不在日志新增完整工具参数、密钥或成功消息。

现有 llm_calls schema(research-wiki/schemas/llm-calls.md)及覆盖指标复用。确定性验收指标:error→成功恰好 attempts=2;tool-only attempts=1;命中 attempts=0;失败不写缓存;无孤儿 permit/task。失败 usage 仍 unavailable,费用仍未知,不删除失败事件;不扩展遥测全文覆盖,tool_calls.arguments 不受现有 TEXT_CAP 截断的已知边界须继续声明。

6. 缓存语义

build_cache_key 追加 response_mode: ResponseMode = "text";仅非 text 时将 response_mode 写入 key 对象。默认文本 key 逐字不变;这是请求语义维度,不引入包版本/revision。tools、tool_choice、raw effort 已在 sampling/source fingerprint;assistant/tool 消息全字段已在 digest_messages,直接复用。

CacheMW 显式序列化和重建 OpenAIChatCompletion,读时验证容器、单消息/工具必要字段与元信息类型。openai 条目 completion 缺失/null/畸形或无有效产物视 miss+warning,不能补默认伪造协议成功;text 旧条目允许缺新字段。transport 与缓存共用 types 内部纯值判据:完整工具或非空投影正文;无工具时还要求协议原正文非空,不能靠缓存旧投影伪装成功;不收紧快照构造用途。错误条目回源成功后覆写,失败不写。

缓存中的 message/usage 与调用结果独立;命中保留历史上游 id/finish/usage,生成本次 call_id、清除历史 call_stats,最终返回本次 attempts=0;cost=0 延续原口径。缓存读写和序列化失败仅 warning,不冒泡,取消例外穿透;将 _serialize 异常纳入缓存降级边界,避免新增嵌套字段扩大失败面。

旧文本缓存可能已被 error+DONE 毒化,不能自动判别。发布文档要求受影响调用集合首次读写前迁移 namespace/salt,保留租户前缀与 epoch;覆盖工厂和 per-call 身份。未迁移仍会回放旧值,不能宣称修复自动清除历史缓存;openai 新身份天然不命中旧文本。

7. 旧行为审计与非功能约束

既有行为 处置
旧字段/默认文本/structured 阶梯/受管推理 保留;默认文本同帧漏 reasoning 属修复
HTTP 分类、retry/backoff、两类对冲、TPM/熔断 保留,不改算法与预算
空补全、missing_done salvage 文本保留;openai tool-only 放行、缺 DONE 强制失败
Redis TTL、命名空间、遥测幂等与单 helper 保留;新增模式隔离与嵌套协议结果回放
并发与取消 每尝试状态隔离,重试/对冲不得复用聚合器,CancelledError 穿透
降级方向 缓存/遥测 warning;准入后端 fail-closed;不新增后端
持久化/原子性/恢复 仅成功后一次写完整缓存值;沿用后端原子 set;无新的任务持久化或断点状态
幂等与重复 重试/对冲可能产生重复上游计费,库不执行工具,因此不产生本地工具副作用;不承诺上游生成幂等

8. 测试、许可证与交付门

单元测试复用 MockTransport/SSE/受控事件设施;新增两工具交错分片、同帧 content+reasoning、原样下一轮、raw=max、stream=False、缺 DONE、安全判错、缓存和治理组合。每个行为组须保留基线红、新实现绿日志;不以本阶段基线通过冒充新功能证据。

真实 Redis 只测完整协议缓存往返/隔离与取消关联契约,不 mock Lua。真实模型跑专用通用函数工具双轮冒烟(标 slow,输出 Markdown),复用 .env 配置与预算,不在源码硬编码凭据;网络/额度失败如实失败,不改成无覆盖成功。不默认重跑全模型推理矩阵;本次不改 thinking 能力算法。

许可证:采用 Apache-2.0 已授权;实施前核查实际分发文件及注明移植/参考的片段,区分参考协议事实与复制代码。reference/ 只读且不打包;AGPL 仓存在不自动污染本库,也不由授权覆盖第三方权利。发现实际复制的不兼容片段须上报,不擅自改许可证。加入标准 LICENSE 与 pyproject 元数据;只有确有通知义务才加 NOTICE,不捏造第三方来源。

源码 docstring、README、CHANGELOG、ARCHITECTURE 同步;无新 env 键,.env.example 仅补 raw-only 源配置提示(不改默认)。Gitea Wiki 当前下线,不重建过期页面。线程代理文档说明:client 在同一常驻事件循环线程内创建/使用/关闭;工作线程 run_coroutine_threadsafe,等待超时/断连取消 Future;不逐请求 asyncio.run,不跨 loop 共享 client。

发布由父会话在独立代码审查、全套件、相关 slow、构建包 LICENSE/元数据核验之后执行;沿 CLAUDE 发布九步,外部验证 registry/Release/包页和下游下载,不仅 bump。issue 只有在可安装产物及验收证据齐备后关闭;本阶段不触发这些动作。