From 9185eab8c9f4e7441de7608e56e17033acee410e Mon Sep 17 00:00:00 2001 From: iomgaa Date: Thu, 1 Oct 2026 14:32:19 -0400 Subject: [PATCH] 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. --- .env.example | 4 + CHANGELOG.md | 10 + LICENSE | 202 ++++++++++ README.md | 74 +++- pyproject.toml | 3 +- research-wiki/ARCHITECTURE.md | 17 + ...26-10-01-issue29-openai-fidelity-design.md | 169 +++++++++ .../2026-10-01-issue29-license-audit.md | 18 + research-wiki/graph/edges.json | 36 ++ research-wiki/index.md | 34 +- research-wiki/log.md | 8 + .../metrics/call-telemetry-coverage.md | 6 + .../2026-10-01-issue29-openai-fidelity.md | 328 ++++++++++++++++ .../2026-10-01-issue29-cache-review.md | 26 ++ research-wiki/schemas/llm-calls.md | 7 + src/polygateway/__init__.py | 6 +- src/polygateway/client.py | 26 ++ src/polygateway/errors.py | 2 +- src/polygateway/middleware/cache.py | 31 +- src/polygateway/middleware/retry.py | 2 + src/polygateway/ports.py | 6 + .../transports/_openai_completion.py | 174 +++++++++ src/polygateway/transports/openai_compat.py | 140 +++++-- src/polygateway/types.py | 74 ++++ tests/e2e/conftest.py | 10 +- tests/e2e/test_openai_tools_live.py | 114 ++++++ tests/integration/test_redis_cache.py | 19 + .../test_redis_cross_connection.py | 1 + tests/unit/test_backpressure.py | 1 + tests/unit/test_cache.py | 111 ++++++ tests/unit/test_client.py | 2 + tests/unit/test_config.py | 8 +- tests/unit/test_hedge.py | 1 + tests/unit/test_live_evidence.py | 2 + tests/unit/test_openai_compat.py | 22 +- tests/unit/test_openai_completion.py | 227 +++++++++++ tests/unit/test_openai_fidelity.py | 354 ++++++++++++++++++ tests/unit/test_package.py | 12 + tests/unit/test_ports.py | 1 + tests/unit/test_retry.py | 3 + tests/unit/test_stall_hedge.py | 13 +- tests/unit/test_usage_source_domain.py | 1 + 42 files changed, 2227 insertions(+), 78 deletions(-) create mode 100644 LICENSE create mode 100644 research-wiki/designs/2026-10-01-issue29-openai-fidelity-design.md create mode 100644 research-wiki/findings/2026-10-01-issue29-license-audit.md create mode 100644 research-wiki/plans/2026-10-01-issue29-openai-fidelity.md create mode 100644 research-wiki/reviews/2026-10-01-issue29-cache-review.md create mode 100644 src/polygateway/transports/_openai_completion.py create mode 100644 tests/e2e/test_openai_tools_live.py create mode 100644 tests/unit/test_openai_completion.py create mode 100644 tests/unit/test_openai_fidelity.py diff --git a/.env.example b/.env.example index b853aa6..520c97d 100644 --- a/.env.example +++ b/.env.example @@ -200,3 +200,7 @@ PGW_TELEMETRY_BACKEND=none # sqlite | postgres | none(必填) # SOAK_OCR__GLOBAL__MAX_CONCURRENCY=16 # SOAK_OCR__GLOBAL__RPM=300 # SOAK_OCR_FAULT_SOURCES=monkey_3,monkey_4 + +# OpenAI 保真模式是 per-call response_mode="openai",无新增环境键。 +# raw-only reasoning_effort 放调用 overlay;对应源不得设置 ENABLE_THINKING 或 +# REASONING_EFFORT,请求 reasoning_effort=None 只继承源配置,不撤销源级意图。 diff --git a/CHANGELOG.md b/CHANGELOG.md index d529ff8..344ff79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## 1.4.0(2026-10-01) + +- 新增 `chat(response_mode="openai")`,返回 `LLMResponse.completion`:assistant 消息、工具调用、finish_reason、上游 response_id、usage 快照;支持流式聚合及非流式。 +- 修复 SSE 顶层 error 后接 DONE 被当成功、同帧 content/reasoning_content 丢失;工具实际进展纳入看门狗、首 token 与中段对冲。 +- 保真仅支持单 choice,拒绝 structured 组合;工具参数逐字拼接,不执行或修复工具;缺 DONE 不允许 salvage。 +- 保真缓存独立身份并完整回放;缺协议、畸形协议及无正文无工具的坏条目均按 miss 回源。**旧文本缓存可能含曾被吞错的半截结果,受影响集合须在升级首次读写前更换 namespace/salt(含 per-call 覆盖)**,库不会自动辨认旧污染。 +- 新模式不绕过 managed/raw 冲突校验;raw-only 仍须源级/请求级均无受管意图。`Transport.complete` 新增必填 `response_mode`,自定义实现需适配。 +- 分发采用 Apache-2.0;README 补充常驻事件循环线程桥接,不新增同步 API。 + + ## 1.3.8(2026-09-14) 流式中段沉默的计量与应对(issue #27 / #28):`generation_ms` 口径重定义为**净生成时间**(扣除所有超阈值中段沉默)并配 `stall_ms`/`stall_count` 归因字段与遥测两列;另加一条**默认关闭**的中段沉默对冲(主路首 token 之后相邻增量 chunk 间隔超阈值时,向异源并发再发一次)。两键均不设时行为逐字等于 1.3.7(默认关闭回归门: 既有 unit/contracts 全套件语义断言一行未改)。 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index e7f24ba..8c0516d 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ | 调用期限 | 一次逻辑调用可选一条**墙钟硬边界**(`{SCOPE}__CALL_DEADLINE_S` 或 `chat(call_deadline_s=...)`,缺省不启用):治理的是**等待**——重试退避、配额轮询、熔断冷却、结构化重问与 embedding 分批共享同一份期限。三条须知:①**返回时刻 = 期限 + 清理耗时**(遥测/结算/缓存收尾在 `finally` 里跑完,允许超期;实测构造达期限的 5–7 倍),库只承诺切断等待、不给返回上界;②**到期 ≠ 未产出、≠ 未计费**,上游可能已算完并计费;③**不配置即逐字保持 1.3.5 语义**(纯 429 序列仍可能长等、有限大 `Retry-After` 仍照睡)。到期抛 `CallDeadlineExceeded`,**不属四分类、不属 `GatewayUnavailableError` 族** | | 长尾对冲 | **默认关闭**的可选加速(`{SCOPE}__HEDGE__AFTER_S`,仅 chat):一次尝试挂起超阈值时向**异源**并发再发一次,先回者赢、输家取消。流式以「首 token 未至」为触发判据(不误杀慢生成),非流式只有纯总时长阈值;对冲走完整限流/熔断准入,拿不到配额静默放弃。成本须知:开启即用配额换延迟(触发窗口内 in-flight 翻倍),输家取消按 est 保留预扣且**可能已被上游计费**(取消止不住上游);输家不喂熔断,遥测 attempt 行标 `error="hedge_cancelled"` 可按 `logical_call_id` join 对账;两路皆败只计一次重试预算;与 `call_deadline_s` 正交组合(装配守卫强制对冲阈值 < 期限) | 中段沉默对冲(可选,超业界现状能力) | **默认关闭**(`{SCOPE}__HEDGE__STALL_AFTER_S`,仅流式 chat):主路**首 token 之后**相邻增量 chunk 沉默超阈值时向异源并发再发一次,与首 token 对冲 `HEDGE__AFTER_S` **独立生效**、可独设可组合;仅设它且请求非流式 → 逐字走单路(中段沉默无非流式概念)。**诚实标注:中段沉默触发并发副本在主流网关/库中无公开先例**(业界收敛为看门狗掐流重试)。成本须知:下游实测沉默 >20s 占 19.0%,触发率远高于首 token 对冲,开启即用配额换延迟,输家可能已被上游计费;看门狗掐死**喂**熔断而对冲输家取消**不喂**(不对称声明),详见[1.3.8 生成时间口径与中段沉默对冲](#138-生成时间口径与中段沉默对冲) | +| OpenAI 消息保真(1.4.0) | `chat(response_mode="openai")` 返回完整 assistant 消息/工具调用、finish_reason、上游 ID 与 usage;单 choice,复用治理,不执行工具;默认仍为文本模式 | | 响应缓存 | Redis/内存;key 含 model + messages 摘要 + namespace(缓存隔离单位)+ salt + 采样参数 + 请求级推理档位(同 messages 跑 low 与 max 不互相命中),多模态 content 先摘要再 hash(防毒化);可 per-call 绕过(科研重采样) | | 流式看门狗 | TTFT / inter-token / 总超时三层活性;thinking token 刷活性不计结果;截断流(缺 `[DONE]`)判瞬时不入缓存 | | 推理可观测性 | "这次到底推理没推理"由多信号裁定(推理正文压倒 usage 明细),三态落在 `LLMResponse.thinking_observation`:`observed` / `absent` / `unknown`——**`unknown` 是"本次判不出",不是"没推理"**;本次实发档位与实测观测矛盾时按 `(源, 模型, 生效档位)` 各告警一次(能力表过期、开启未生效、注入了却观测不到;同一模型的 low 与 max 是两个独立的矛盾,不共用节流键);裁定结果随遥测落库 | @@ -137,7 +138,7 @@ stats.total_latency_ms # 含缓存 IO、退避、准入等待、重 | `{SCOPE}__STREAM__STALL_THRESHOLD_MS` | 正整数毫秒 | **1000** | 仅改计量口径,不改任何行为;三层调整接口同权(env 键 / `GatewaySettings.stall_threshold_ms` / `GatewayClient(stall_threshold_ms=...)`);无 per-call 覆盖,不做自适应(防与对冲阈值耦出反馈环) | | `{SCOPE}__HEDGE__STALL_AFTER_S` | 有限正数秒 | **未设 = 关闭** | 独立于 `HEDGE__AFTER_S` 生效;守卫: `≥ min(各源 inter_token_timeout_s 或 timeout_s)`(对冲窗永不开)或 `≥ call_deadline_s` → 装配期 **ValueError**,单源 scope → warning;三层调整接口同权(env 键 / `GatewaySettings.hedge_stall_after_s` / `GatewayClient(hedge_stall_after_s=...)`);Embedding/OCR scope 配任一对冲键 → warning 后静默退化 | -对冲语义(两阶段触发窗/复检防误触发/取消穿透)与成本披露见 [CHANGELOG 未发布段](CHANGELOG.md);运维「挂起率」统计须知不对称:看门狗掐死**喂**熔断失败计数,对冲输家取消**不喂**熔断。 +对冲语义(两阶段触发窗/复检防误触发/取消穿透)与成本披露见 [CHANGELOG 1.3.8 段](CHANGELOG.md);运维「挂起率」统计须知不对称:看门狗掐死**喂**熔断失败计数,对冲输家取消**不喂**熔断。 ## 安装 @@ -145,7 +146,7 @@ stats.total_latency_ms # 含缓存 IO、退避、准入等待、重 ```bash pip install --extra-index-url https://gitea.iomgaa.online/api/packages/iomgaa/pypi/simple/ \ - "polygateway[redis,postgres,structured]>=1.3.8,<2" + "polygateway[redis,postgres,structured]>=1.4.0,<2" ``` 核心仅依赖 `httpx` + `pydantic`;按需选 extras: @@ -610,3 +611,72 @@ make ci # 只读全量验证 ## 兼容性承诺 `LLMResponse` 等被下游消费的公共类型,字段**只增不删不改名**且新增字段必带默认值;`{SCOPE}__{PROVIDER}__{N}__{FIELD}` 与平铺韧性键名(`LLM_TIMEOUT` 等)沿用三项目既有习惯,不做破坏性改名。实验室内部库,随实验室项目需求演进。 + +## 1.4.0 OpenAI 工具消息保真(单 choice) + +`chat(..., response_mode="openai")` 仍返回 `LLMResponse`,完整协议结果在 +`response.completion`:`message`、`finish_reason`、`response_id`、`usage`。 +`content/thinking` 是旧文本投影(可剥离 think 标签);回传代理或继续对话应使用 +completion.message,不能从旧投影重建工具响应。嵌套 JSON 是独立快照但仍可修改。 + +```python +response = await client.chat( + messages, + response_mode="openai", + overlay={"tools": tools, "tool_choice": "auto"}, +) +completion = response.completion +messages.append(completion.message) +# 工具执行与 role=tool 结果由调用方添加;库不会执行工具。 +``` + +保真是 JSON 字段和值保真,不是 HTTP 字节透明代理。仅支持单 choice(`n=1`), +可含多个并行 function tool_calls;arguments 逐字拼接,不解析、修复或执行。 +SSE 支持 role/content/reasoning_content/tool_calls,未知增量字段会明确失败; +非流式保留整个 message 扩展字段,不返回顶层 logprobs/created 等其他字段。 +上游实际模型仍读 `model_reported`,配置别名是 `model`。 + +| 边界 | 行为 | +|---|---| +| 空文本且有完整工具 | 正常成功;不完整工具或无任何有效产物仍失败 | +| error 帧 | 所有模式立即判失败并走已有重试;后续 DONE 不挽救 | +| 缺 DONE | 保真模式不受 salvage 放宽,防截断工具被执行 | +| finish_reason=length | 原样返回,不修补参数;调用方自行判断是否执行 | +| structured | 不可与保真并用;自行传 overlay.response_format 不启用库的修复/重问阶梯 | +| raw 推理 | 保真不是校验逃生口;raw-only 要求源 ENABLE_THINKING/REASONING_EFFORT 均未设置,请求档为 None,再通过 overlay.reasoning_effort 传原值 | + +工具实际增量与正文/推理共用首 token、inter-token、stall 对冲及沉默计量;注释、 +usage、role-only、重复工具身份不算进展。非流式无中段事件,保持纯时长阈值对冲。 +`Transport.complete` 的自定义实现须接收必填 `response_mode`;不支持保真时明确 +RequestRejected,不能伪造 completion 或静默退回文本。 + +缓存按返回模式隔离;缺协议、畸形协议或无正文无工具的条目按 miss 回源。命中保留历史上游 response_id/usage,生成本次治理 call_id, +本次 attempts=0、cost=0。**旧文本缓存可能含修复前 error+DONE 的截断产物**: +受影响调用集合须在首次读写前更换从未承载旧语义的 namespace 或 salt,包含工厂、 +全量注入和 per-call 覆盖,保留租户前缀与 epoch。未迁移仍可能读旧值,库不自动清除。 +遥测继续单一出口,不新增工具全文列;既有 TEXT_CAP 不覆盖 tool_calls.arguments。 + +### 线程模型的异步桥接 + +库不新增同步客户端。socketserver 等工作线程应向**一个常驻事件循环线程**提交: +client 必须在该 loop 内创建、使用并关闭,不能每请求 asyncio.run 或跨 loop 共用 +httpx/Redis 组件。下例的 loop 和 client 已由宿主完成初始化: + +```python +future = asyncio.run_coroutine_threadsafe( + client.chat(messages, response_mode="openai", overlay=overlay), loop +) +try: + response = future.result(timeout=timeout_s) +except TimeoutError: + future.cancel() # 断连也应取消;取消不等于上游未计费。 + raise +``` + +应用退出时先停止接单、取消/等待在途调用,在同一个 loop 中 await client.aclose(), +再停 loop 并 join 线程;注入组件仍由所有者关闭。不要从 loop 本身阻塞调用 future.result。 + +## 许可证 + +PolyGateway 使用 [Apache License 2.0](LICENSE)。内部移植代码的原创 owner 已明确授权; +分发范围不包含 `reference/` 外部参考仓。 diff --git a/pyproject.toml b/pyproject.toml index 06cb836..fc06554 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,11 +4,12 @@ build-backend = "setuptools.build_meta" [project] name = "polygateway" -version = "1.3.8" +version = "1.4.0" description = "PolyGateway:实验室统一的大语言模型(LLM/VLM/OCR)调度与中转库——多源、限流、重试、熔断、缓存、遥测" # registry 包页面的正文只认这一项:缺了页面就是一片空白(1.1.2 的教训,twine 会警告 # long_description missing 但不阻塞上传)。README 在打包时被固化进产物,发布后再改无效。 readme = "README.md" +license = {file = "LICENSE"} requires-python = ">=3.12" dependencies = [ "httpx>=0.27", diff --git a/research-wiki/ARCHITECTURE.md b/research-wiki/ARCHITECTURE.md index bea94bb..7628936 100644 --- a/research-wiki/ARCHITECTURE.md +++ b/research-wiki/ARCHITECTURE.md @@ -789,3 +789,20 @@ src/polygateway/ | Q6 | CHSAnalyzer 的 judge 迁移路径 | **已拍板(2026-07-22 用户)**: M4 实测 judge/core-eval 评估流水线**零调用方、从未接线**(全仓仅自测消费),且实验室网关无 claude 系模型——本轮**豁免不动**,judge.py 原样保留;待评估流水线真正启用时再收编走库(届时裁判模型从网关现有模型选) | | Q4 | conda 环境名 | `PolyGateway` | | Q5 | 本仓库工程脚手架(git init、`.claude/` skills、Makefile、pyproject、import-linter)何时落地 | 本文档终审通过后、M1 编码前一次落地 | + + +## 14. OpenAI 单 choice 消息保真(2026-10-01,#29 已批准增量) + +本节明确修订 §5/§6.2/§7.1/§7.2/§7.5/§7.6 中原仅文本契约,详细边界见 [设计](designs/2026-10-01-issue29-openai-fidelity-design.md)。不新增 Agent Loop 或第二套治理。 + +| 接缝 | 增量契约 | +|---|---| +| 公共返回 | chat 追加 response_mode=text/openai,默认 text;LLMResponse/TransportResult 末尾追加 completion,内含 assistant message、finish_reason、response_id、usage;旧字段继续文本兼容投影 | +| 请求所有权 | messages 字段保真;overlay 仍受 model/messages/stream/stream_options 保护;raw-only 复用既有路径,模式不绕过 managed 校验 | +| 协议 | openai 单 choice;工具按 index 排序,name/arguments 逐片拼接;SSE 支持 role/content/reasoning_content/tool_calls,未知增量拒绝;非流式保留消息扩展字段;成功必含 assistant role | +| 失败 | 所有 chat 模式 error 帧立即 TransientError;openai 缺 DONE 一律重试、不 salvage;tool-only 成功,纯空/不完整工具失败;text salvage 原语义不变 | +| 活性 | 同帧正文/推理/工具实际进展只记一次;role/usage/finish/空帧/重复身份不算;沿既有看门狗、首 token、stall 对冲与唯一取整点 | +| 缓存 | 仅 openai key 新增 response_mode 语义维度,默认 text key 不变;完整快照显式重建,缺 completion/role 或无有效正文/工具按 miss(与 transport 共用内核纯值判据,不依赖 transport 实现);历史上游 id 回放,本次治理 id 重建 | +| 迁移 | 旧 text 污染无法自动判断,受影响集合必须迁移 namespace/salt;新模式天然隔离;无新遥测列/表,统一 helper 与取消结算不变 | + +本增量不会执行工具;length 结束原因与参数原样交回,由下游判断可执行性。嵌套 JSON 是独立可变快照,不承诺深冻结。许可已由原创 owner 明确批准 Apache-2.0。 diff --git a/research-wiki/designs/2026-10-01-issue29-openai-fidelity-design.md b/research-wiki/designs/2026-10-01-issue29-openai-fidelity-design.md new file mode 100644 index 0000000..d9c2884 --- /dev/null +++ b/research-wiki/designs/2026-10-01-issue29-openai-fidelity-design.md @@ -0,0 +1,169 @@ +--- +type: design +node_id: design:2026-10-01-issue29-openai-fidelity-design +title: OpenAI消息保真设计 +date: 2026-10-01 +--- + +# #29 OpenAI 消息保真设计 + +> 状态:方向已获人类批准;计划独立审查 P1/P2 已修订并实施,首轮独立代码审查发现的缓存空产物及测试双向依赖已修复,等待父会话最终复核;1.4.0 发布准备,尚未发布。 +> 基线:`0e29deac1a72bfee348ed2f8061ea2c33be217e8` / 1.3.8;日期:2026-10-01。 +> 需求:[issue #29](https://gitea.iomgaa.online/iomgaa/PolyGateway/issues/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`: + +```python +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 只有在可安装产物及验收证据齐备后关闭;本阶段不触发这些动作。 diff --git a/research-wiki/findings/2026-10-01-issue29-license-audit.md b/research-wiki/findings/2026-10-01-issue29-license-audit.md new file mode 100644 index 0000000..0babcde --- /dev/null +++ b/research-wiki/findings/2026-10-01-issue29-license-audit.md @@ -0,0 +1,18 @@ +# #29 分发来源前置核查 + +日期:2026-10-01;基线 0e29dea;在 T1 前执行。本记录不是法律认证。 + +| 范围 | 实际核查与结论 | +|---|---| +| 跟踪源码 | `git ls-files src` 列出 polygateway Python 模块与 py.typed,无 vendor 子树、外部 LICENSE/NOTICE、打包外部代码目录 | +| 内部移植 | rg 检索移植/参考/Copyright/SPDX/copied/adapted:streaming、sources、Redis Lua、ports、openai_compat 的来源为三个内部项目;用户已明确均本人原创并授权 Apache-2.0 | +| 外部参考声明 | 阅读 types.py Effort、providers.py ThinkingWire/注册表、thinking.py 能力证据段;使用的是协议词汇/参数形态/实测结论,未发现来自外部仓的成段实现或第三方版权头;不把相同协议字符串认定为复制实现 | +| 打包边界 | pyproject 仅从 src 发现包,reference 不跟踪、不纳入分发;本次不复制外部参考实现 | +| 许可来源 | 系统 `/usr/share/common-licenses/Apache-2.0` 提供标准许可正文;构建时核对包内文本与元数据 | + +未发现需额外通知的实际复制片段,因此不编造 NOTICE。外部参考仓含 AGPL 不等于本库受其许可约束;用户对原创代码的授权也不能覆盖第三方。实施/审查若发现新的实际复制证据,停止并提交主管处理,不用本文替代核查。 + +## 审查意见处置 + +P1 采纳:协议快照缺 role 不可直接进入下一轮,SSE/非流式/缓存统一要求 assistant;不补造 role,默认 text 不变。 +P2 采纳:将本次来源核查前移到 T0,T6 仅复核及落实 LICENSE/打包/文档。两项均是已批准方向的正确性修订,无需新增用户授权。 diff --git a/research-wiki/graph/edges.json b/research-wiki/graph/edges.json index 1c6290f..d0bbe77 100644 --- a/research-wiki/graph/edges.json +++ b/research-wiki/graph/edges.json @@ -225,6 +225,21 @@ "id": "finding:2026-09-09-134-thinking-contracts-validation", "label": "1.3.4 T0–T4 与 T7 确定性验证", "type": "finding" + }, + { + "id": "design:2026-10-01-issue29-openai-fidelity-design", + "label": "OpenAI消息保真设计", + "type": "design" + }, + { + "id": "plan:2026-10-01-issue29-openai-fidelity", + "label": "OpenAI消息保真实现计划", + "type": "plan" + }, + { + "id": "review:2026-10-01-issue29-cache-review", + "label": "保真缓存审查修复与1.4.0准备", + "type": "review" } ], "links": [ @@ -472,6 +487,27 @@ "relation": "implements", "evidence": "1.3.5 逻辑调用统计与结构化失败诊断实施计划落地已批准设计", "added": "2026-09-09T13:42:00.298915+00:00" + }, + { + "source": "plan:2026-10-01-issue29-openai-fidelity", + "target": "design:2026-10-01-issue29-openai-fidelity-design", + "relation": "implements", + "evidence": "已批准方向与独立审查P1/P2修订", + "added": "2026-10-01T17:52:42.425248+00:00" + }, + { + "source": "schema:llm-calls", + "target": "design:2026-10-01-issue29-openai-fidelity-design", + "relation": "implements", + "evidence": "复用既有单出口与schema,无新增列;工具error为attempt失败", + "added": "2026-10-01T17:52:43.831331+00:00" + }, + { + "source": "review:2026-10-01-issue29-cache-review", + "target": "design:2026-10-01-issue29-openai-fidelity-design", + "relation": "informs", + "evidence": "统一缓存与transport的有效产物判据,保持内核依赖边界", + "added": "2026-10-01T18:27:16.434742+00:00" } ] } \ No newline at end of file diff --git a/research-wiki/index.md b/research-wiki/index.md index ad47a16..2672b77 100644 --- a/research-wiki/index.md +++ b/research-wiki/index.md @@ -1,9 +1,8 @@ # Research Wiki 索引 -> 自动生成,更新时间:2026-09-09 18:01 UTC - -## design (43) +> 自动生成,更新时间:2026-10-01 18:27 UTC +## design (47) - [1.3.4 推理意图与测试证据设计](designs/2026-09-09-134-thinking-contracts-design.md) `design:2026-09-09-134-thinking-contracts-design` - [2026-07-20-m1-core-design](designs/2026-07-20-m1-core-design.md) `design:2026-07-20-m1-core-design` - [2026-07-20-m2-distributed-design](designs/2026-07-20-m2-distributed-design.md) `design:2026-07-20-m2-distributed-design` @@ -25,6 +24,9 @@ - [2026-08-24-issue15-telemetry-pool-lifecycle-design](designs/2026-08-24-issue15-telemetry-pool-lifecycle-design.md) `design:2026-08-24-issue15-telemetry-pool-lifecycle-design` - [2026-09-04-reasoning-effort-design](designs/2026-09-04-reasoning-effort-design.md) `design:2026-09-04-reasoning-effort-design` - [2026-09-09-135-call-observability-design](designs/2026-09-09-135-call-observability-design.md) `design:2026-09-09-135-call-observability-design` +- [2026-09-09-136-call-budgets-design](designs/2026-09-09-136-call-budgets-design.md) `design:2026-09-09-136-call-budgets-design` +- [2026-09-10-24-hedged-requests-design](designs/2026-09-10-24-hedged-requests-design.md) `design:2026-09-10-24-hedged-requests-design` +- [2026-09-11-streaming-stall-design](designs/2026-09-11-streaming-stall-design.md) `design:2026-09-11-streaming-stall-design` - [est_tokens 解耦: 拆分限流预扣与遥测用量兜底(issue #2)](designs/est-tokens-decoupling.md) `design:est-tokens-decoupling` - [GatewaySettings 装配校验补齐(第二轮)](designs/settings-invariants-round-2.md) `design:settings-invariants-round-2` - [GatewaySettings 跨字段不变量守卫的生效范围](designs/settings-invariant-guards.md) `design:settings-invariant-guards` @@ -38,6 +40,7 @@ - [M2.5 治理韧性: 半死源隔离与健康感知调度](designs/m25-resilience.md) `design:m25-resilience` - [M3 OCR 端口族设计](designs/m3-ocr.md) `design:m3-ocr` - [M4 迁移验证设计(GovDoc→CHS,发 v1.0)](designs/m4-migration.md) `design:m4-migration` +- [OpenAI消息保真设计](designs/2026-10-01-issue29-openai-fidelity-design.md) `design:2026-10-01-issue29-openai-fidelity-design` - [stall 判定改为非生产性等待口径](designs/issue8-stall-budget.md) `design:issue8-stall-budget` - [响应可观测字段扩展(Issue #3)](designs/response-observability-fields.md) `design:response-observability-fields` - [建表前先探测,判死只认「确定写不进去」](designs/issue9-telemetry-ddl-probe.md) `design:issue9-telemetry-ddl-probe` @@ -48,18 +51,22 @@ - [调用方自定义维度设计(issue #11)](designs/issue11-caller-dimensions.md) `design:issue11-caller-dimensions` - [采样参数透传设计(issue #4)](designs/sampling-params.md) `design:sampling-params` -## finding (17) - +## finding (22) - [1.3.4 推理契约验证与发布准备](findings/2026-09-09-134-thinking-contracts-validation.md) `finding:2026-09-09-134-thinking-contracts-validation` - [1.3.5 T2/T3/T4 验收证据:36 列遥测、失败终态、PG 存储兼容与变异矩阵](findings/2026-09-09-135-call-observability-validation.md) `finding:2026-09-09-135-call-observability-validation` - [1.3.5 发布完成与外部验收:合并后门、registry 产物与页面亲查](findings/2026-09-09-135-release-completion.md) `finding:2026-09-09-135-release-completion` +- [1.3.6 可选调用期限与取消结算验证记录](findings/2026-09-10-136-call-deadline-validation.md) `finding:2026-09-10-136-call-deadline-validation` +- [1.3.8 流式中段沉默:净口径与中段对冲验证报告](findings/2026-09-11-streaming-stall-validation.md) `finding:2026-09-11-streaming-stall-validation` - [2026-07-20-m2-soak-workload](findings/2026-07-20-m2-soak-workload.md) `finding:2026-07-20-m2-soak-workload` - [2026-07-21-m25-acceptance](findings/2026-07-21-m25-acceptance.md) `finding:2026-07-21-m25-acceptance` - [2026-07-21-p6-soak-baseline](findings/2026-07-21-p6-soak-baseline.md) `finding:2026-07-21-p6-soak-baseline` - [2026-07-22-m4-acceptance](findings/2026-07-22-m4-acceptance.md) `finding:2026-07-22-m4-acceptance` - [2026-07-22-p7-ocr-soak](findings/2026-07-22-p7-ocr-soak.md) `finding:2026-07-22-p7-ocr-soak` +- [2026-09-11-streaming-stall-hedging-research](findings/2026-09-11-streaming-stall-hedging-research.md) `finding:2026-09-11-streaming-stall-hedging-research` +- [2026-10-01-issue29-license-audit](findings/2026-10-01-issue29-license-audit.md) `finding:2026-10-01-issue29-license-audit` - [issue #16/#17 实测: M3 推理正常,失效的是推理的可观测信号](findings/2026-08-25-thinking-observability-regression.md) `finding:2026-08-25-thinking-observability-regression` - [issue #18 实测: 偶发红的是安全网本身,不是被测脚本](findings/2026-08-26-issue18-shared-pg-test-isolation.md) `finding:2026-08-26-issue18-shared-pg-test-isolation` +- [issue #24 长尾对冲请求与裸生成时间验证报告](findings/2026-09-10-24-hedged-requests-validation.md) `finding:2026-09-10-24-hedged-requests-validation` - [M2 verifier 三项 Important 补齐(不变量接线/网关保护/P3 验收)](findings/m2-verifier-fixes.md) `finding:m2-verifier-fixes` - [M2 真实数据压测: 场景矩阵与数据清单](findings/m2-soak-workload.md) `finding:m2-soak-workload` - [M2.5 验收: P6 同场景 58.1% → 98.96%](findings/m25-acceptance.md) `finding:m25-acceptance` @@ -68,10 +75,11 @@ - [P7 OCR soak 验收: 99.73% 与 13 不变量全 PASS](findings/p7-ocr-soak.md) `finding:p7-ocr-soak` - [推理开关与 reasoning_tokens: 供应商实测与业界做法](findings/2026-08-02-thinking-switch-and-reasoning-tokens.md) `finding:2026-08-02-thinking-switch-and-reasoning-tokens` -## plan (38) - +## plan (42) - [1.3.4 推理契约实施计划](plans/2026-09-09-134-thinking-contracts.md) `plan:2026-09-09-134-thinking-contracts` - [1.3.5 逻辑调用统计与结构化失败诊断实施计划](plans/2026-09-09-135-call-observability.md) `plan:2026-09-09-135-call-observability` +- [1.3.6 可选调用期限与取消结算修复实施计划](plans/2026-09-10-136-call-deadline.md) `plan:2026-09-10-136-call-deadline` +- [1.3.8 流式中段沉默:计量口径与对冲实施计划](plans/2026-09-11-streaming-stall.md) `plan:2026-09-11-streaming-stall` - [2026-07-20-m1-core-plan](plans/2026-07-20-m1-core-plan.md) `plan:2026-07-20-m1-core-plan` - [2026-07-20-m2-distributed-plan](plans/2026-07-20-m2-distributed-plan.md) `plan:2026-07-20-m2-distributed-plan` - [2026-07-21-m25-resilience-plan](plans/2026-07-21-m25-resilience-plan.md) `plan:2026-07-21-m25-resilience-plan` @@ -90,12 +98,14 @@ - [2026-09-04-reasoning-effort](plans/2026-09-04-reasoning-effort.md) `plan:2026-09-04-reasoning-effort` - [est_tokens 解耦实施计划](plans/est-tokens-decoupling.md) `plan:est-tokens-decoupling` - [issue #18 实现计划: 权限边界替代行数快照 + --table 锁死目标](plans/2026-08-26-issue18-pg-test-isolation.md) `plan:2026-08-26-issue18-pg-test-isolation` +- [issue #24 长尾对冲请求与裸生成时间实施计划](plans/2026-09-10-24-hedged-requests.md) `plan:2026-09-10-24-hedged-requests` - [issue #8 实施计划: stall 非生产性等待口径](plans/issue8-stall-budget-plan.md) `plan:issue8-stall-budget-plan` - [M1 核心里程碑实现计划](plans/m1-core-plan.md) `plan:m1-core-plan` - [M2 分布式实现计划](plans/m2-distributed.md) `plan:m2-distributed` - [M2.5 治理韧性实现计划](plans/m25-resilience.md) `plan:m25-resilience` - [M3 OCR 实现计划](plans/m3-ocr.md) `plan:m3-ocr` - [M4 迁移实现计划(T0-T14)](plans/m4-migration.md) `plan:m4-migration` +- [OpenAI消息保真实现计划](plans/2026-10-01-issue29-openai-fidelity.md) `plan:2026-10-01-issue29-openai-fidelity` - [plan-issue14-admission-wait-policy](plans/plan-issue14-admission-wait-policy.md) `plan:plan-issue14-admission-wait-policy` - [响应可观测字段扩展实现计划](plans/response-observability-fields.md) `plan:response-observability-fields` - [实现计划: HTTP 错误响应体留存(Issue #10)](plans/issue10-error-body-retention-plan.md) `plan:issue10-error-body-retention-plan` @@ -109,15 +119,17 @@ - [调用方自定义维度实现计划(issue #11)](plans/issue11-caller-dimensions.md) `plan:issue11-caller-dimensions` - [采样参数透传实现计划(issue #4)](plans/sampling-params-plan.md) `plan:sampling-params-plan` -## review (1) - +## review (6) +- [2026-09-11-138-branch-review](reviews/2026-09-11-138-branch-review.md) `review:2026-09-11-138-branch-review` +- [2026-09-11-138-verifier](reviews/2026-09-11-138-verifier.md) `review:2026-09-11-138-verifier` +- [2026-09-11-streaming-stall-design-review](reviews/2026-09-11-streaming-stall-design-review.md) `review:2026-09-11-streaming-stall-design-review` +- [2026-09-11-streaming-stall-plan-review](reviews/2026-09-11-streaming-stall-plan-review.md) `review:2026-09-11-streaming-stall-plan-review` +- [保真缓存审查修复与1.4.0准备](reviews/2026-10-01-issue29-cache-review.md) `review:2026-10-01-issue29-cache-review` - [整分支审查: issue #14 熔断等待档](reviews/issue14-branch-review.md) `review:issue14-branch-review` ## schema (1) - - [表结构: llm_calls(遥测 36 字段)](schemas/llm-calls.md) `schema:llm-calls` ## metric (2) - - [OCR 治理调用成功率与错误分类分布](metrics/ocr-call-success.md) `metric:ocr-call-success` - [每次调用必录覆盖率(含缓存命中/失败/取消)](metrics/call-telemetry-coverage.md) `metric:call-telemetry-coverage` diff --git a/research-wiki/log.md b/research-wiki/log.md index 2aa5020..44c492b 100644 --- a/research-wiki/log.md +++ b/research-wiki/log.md @@ -160,3 +160,11 @@ - [2026-09-09 13:42 UTC] 重建索引: 100 篇页面 - [2026-09-09 16:49 UTC] 重建索引: 101 篇页面 - [2026-09-09 18:01 UTC] 重建索引: 102 篇页面 +- [2026-10-01 17:52 UTC] 新增 design: OpenAI消息保真设计 (design:2026-10-01-issue29-openai-fidelity-design) +- [2026-10-01 17:52 UTC] 新增 plan: OpenAI消息保真实现计划 (plan:2026-10-01-issue29-openai-fidelity) +- [2026-10-01 17:52 UTC] 新增边: plan:2026-10-01-issue29-openai-fidelity --implements--> design:2026-10-01-issue29-openai-fidelity-design +- [2026-10-01 17:52 UTC] 新增边: schema:llm-calls --implements--> design:2026-10-01-issue29-openai-fidelity-design +- [2026-10-01 17:52 UTC] 重建索引: 119 篇页面 +- [2026-10-01 18:24 UTC] 新增 review: 保真缓存审查修复与1.4.0准备 (review:2026-10-01-issue29-cache-review) +- [2026-10-01 18:27 UTC] 新增边: review:2026-10-01-issue29-cache-review --informs--> design:2026-10-01-issue29-openai-fidelity-design +- [2026-10-01 18:27 UTC] 重建索引: 120 篇页面 diff --git a/research-wiki/metrics/call-telemetry-coverage.md b/research-wiki/metrics/call-telemetry-coverage.md index 9c89ce8..1f814f0 100644 --- a/research-wiki/metrics/call-telemetry-coverage.md +++ b/research-wiki/metrics/call-telemetry-coverage.md @@ -31,3 +31,9 @@ date: 2026-07-20 | 存储兼容 | 真实 PG 沙箱: auto 追加十列、manual 裁剪写入、旧行新列恒 NULL、新旧进程混写共存 | 2026-09-09 实跑 30 passed(见下方证据索引) | 本版**新增十列**(见 schema:llm-calls),不新建表、不新增依赖。证据索引见 `findings/2026-09-09-135-call-observability-validation.md`;覆盖度真实基线仍待首次生产运行填入,不写伪百分比。 + +## #29 验收增量(2026-10-01) + +复用指标及 llm_calls,不另建表:error→正常 attempts=2、tool-only attempts=1、 +cache_hit attempts=0;协议失败不写缓存,取消不得遗留 permit/task。 +确定性证据位于 `tests/unit/test_openai_fidelity.py`;完整协议不新增遥测正文列。 diff --git a/research-wiki/plans/2026-10-01-issue29-openai-fidelity.md b/research-wiki/plans/2026-10-01-issue29-openai-fidelity.md new file mode 100644 index 0000000..95af718 --- /dev/null +++ b/research-wiki/plans/2026-10-01-issue29-openai-fidelity.md @@ -0,0 +1,328 @@ +--- +type: plan +node_id: plan:2026-10-01-issue29-openai-fidelity +title: OpenAI消息保真实现计划 +date: 2026-10-01 +--- + +# #29 OpenAI 消息保真实现计划 + +> 状态:计划及首轮代码独审已完成,P1/P2 均采纳;缓存空产物和测试双向依赖已修复,1.4.0 发布准备由本轮落实,最终独审与 T8 外部发布仍由父会话执行。 +> 设计:[批准方向的设计落实稿](../designs/2026-10-01-issue29-openai-fidelity-design.md)。 +> 目标:同一 chat 治理链返回可供下游继续工具对话的完整单 choice 协议结果,拒绝截断和 error 帧伪成功。 +> 方案:增加 response_mode 与嵌套 completion;协议解析与聚合内聚在 transport 内部模块,治理层仅传模式/搬结果;旧文本默认保持兼容。 +> 技术:Python 3.12+、frozen dataclass、httpx SSE、现有 asyncio 看门狗/对冲、pytest、真实 Redis;不加运行时依赖。 + +## 0. 工作区、审批与执行纪律 + +| 项目 | 已核验值 | +|---|---| +| 工作目录 | `/home/iomgaa/.config/superpowers/worktrees/PolyGateway/issue29-openai-fidelity` | +| 分支 | `feat/issue29-openai-fidelity` | +| 基线 | `0e29deac1a72bfee348ed2f8061ea2c33be217e8` / 1.3.8 | +| 原工作区 | `/home/iomgaa/Projects/PolyGateway`,main 上已有未提交文档与 .pi,不得覆盖或夹带提交 | +| 基线命令 | `conda run -n PolyGateway pytest tests/unit -q` | +| 基线结果 | 1627 passed in 12.25s;仅单元基线,不代表集成/slow/新增功能通过 | + +所有 Python 命令用 PolyGateway conda 环境;始终从上述隔离目录执行,确认 pytest 配置 pythonpath=src,避免误测主工作区 editable 安装。不要复制主树 `.env` 到受跟踪路径;需要真实验证时显式读取已有配置,不输出秘密。reference/ 不存在于新 worktree 的跟踪树,不为规划复制/修改参考仓。 + +技术方向、内部原创权属和 Apache-2.0 已获用户明确批准;细化设计与计划由独立 subagent 审查,Codex 不再调用。只在新产品/权利边界未获授权时暂停。父会话掌握审查、后续编码、合并/发布权限;当前任务不执行这些步骤。 + +本计划不涉及参考实现迁移,保真校验不适用;涉及既有移植流式看门狗的消费面,必须对照本库 `streaming.py`、1.3.8 stall 设计逐段保持其超时/取消/换算语义,不能借工具支持改算法。 + +## 1. 文件结构与职责锁定 + +| 路径(相对工作目录) | 动作与职责 | +|---|---| +| `src/polygateway/types.py` | 新 ResponseMode/OpenAIChatCompletion;末尾追加 ChatRequest.response_mode、两个响应 completion | +| `src/polygateway/__init__.py` | 导出新公共类型;最终发布时同步版本 | +| `src/polygateway/ports.py` | complete 增必填模式参数;说明工具进展与单 choice 契约 | +| `src/polygateway/client.py` | chat 新模式校验、消息/overlay 快照、组合拒绝;不重写治理入口 | +| `src/polygateway/transports/_openai_completion.py`(新) | 协议结构校验、每尝试聚合、非流式快照;无 I/O/计时 | +| `src/polygateway/transports/openai_compat.py` | error 检查、解析器接线、投影/usage/空补全/DONE、统一进展事件 | +| `src/polygateway/middleware/retry.py` | 模式传入、completion 搬运;无重试/对冲算法改写 | +| `src/polygateway/middleware/cache.py` | 模式 key、嵌套序列化/重建、缺协议 miss、序列化降级 | +| `tests/unit/test_openai_completion.py`(新) | 纯协议聚合与非流式校验的源命名测试 | +| `tests/unit/test_openai_compat.py` | 真实 transport 的 SSE/非流式/error/raw/DONE/活性测试 | +| `tests/unit/test_types.py`, `test_client.py`, `test_ports.py`, `test_cache.py` | 公共类型、校验、端口、缓存往返与兼容 | +| `tests/unit/test_retry.py`, `test_hedge.py`, `test_stall_hedge.py`, `test_deadline.py`, `test_backpressure.py` | 模式传递、错误重试、工具活性/对冲、取消与结算 | +| `tests/integration/test_governance_stack.py`, `test_redis_cache.py`, `test_redis_cross_connection.py` | 完整链路、真实缓存往返、必要 fake 签名适配 | +| `tests/e2e/conftest.py` | 现有取证 transport 包装显式转传模式,不改变 thinking 矩阵判据 | +| `tests/e2e/test_openai_tools_live.py`(新) | 受控少量真实工具双轮冒烟,独立 slow 用例 | +| `tests/unit/test_package.py` | LICENSE 文件/包元数据回归 | +| `LICENSE`, `pyproject.toml` | 标准 Apache-2.0 与打包元数据;不捆绑 reference/ | +| `README.md`, `CHANGELOG.md`, `.env.example`, `research-wiki/ARCHITECTURE.md` | 新模式、限制、raw 配置、缓存迁移、同步桥接说明 | +| `research-wiki/findings/2026-10-01-issue29-license-audit.md`(新) | 分发源码第三方来源核查证据与范围 | +| `research-wiki/graph/edges.json`, `index.md`, `log.md`, `schemas/llm-calls.md`, `metrics/call-telemetry-coverage.md` | 审查通过后的设计/计划/复用遥测关系注册,不新增表或指标实体 | + +其余测试若显式调用 complete 必须补参数,仅机械适配;不重构不相关 fixture。`thinking.py`/能力表/streaming.py 算法不在预期改动内。不要为“兼容 SDK”创建不存在的 openai_sdk.py。 + +## 2. 跨任务接口 + +T1 定义公共字段,T2–T5 均消费同一类型名,禁止再起 tool_response/raw_response 别名。 + +```python +ResponseMode = Literal["text", "openai"] + +@dataclass(frozen=True) +class OpenAIChatCompletion: + """单 choice 协议结果,message/usage 是独立 JSON 快照。""" + message: dict[str, Any] + finish_reason: str | None + response_id: str | None + usage: dict[str, Any] | None +``` + +公共与端口变化均是追加参数/字段: + +```python +# GatewayClient.chat 的末尾 keyword-only 参数;其余参数保持原样。 +response_mode: ResponseMode = "text" +# ChatRequest 末尾字段。 +response_mode: ResponseMode = "text" +# Transport.complete 末尾必填 keyword-only 参数。 +response_mode: ResponseMode +# TransportResult / LLMResponse 末尾字段。 +completion: OpenAIChatCompletion | None = None +``` + +`build_cache_key` 在已有 keyword-only 参数之后增加 `response_mode: ResponseMode = "text"`,仅非 text 时参与摘要。缓存重建只依赖内核类型/本层校验,不 import transports;若复用协议类型重建的最低形态检查,把纯 JSON 快照检查放类型自身的具名构造方法,不把 transport 的 SSE 状态机下沉到内核。 + +T2 内部模块只由同目录 transport 消费,不成为公共导出。采用以下窄接口(内部类型在该模块定义): + +```python +@dataclass(frozen=True) +class ChatDelta: + """一帧提取结果;progress 表示真实生成进展。""" + content: str + reasoning_content: str + progress: bool +``` + +```python +class ChatCompletionAccumulator: + """每次 transport 尝试独享的单 choice 聚合状态。""" + def __init__(self, *, response_mode: ResponseMode) -> None: ... + def add_chunk(self, chunk: dict[str, Any]) -> ChatDelta: ... + def finish(self) -> OpenAIChatCompletion: ... + + +def parse_chat_completion(body: dict[str, Any]) -> OpenAIChatCompletion: ... +``` + +上述 `...` 是签名约定而非待交付占位实现。聚合器抛含判据的 TransientError(operation=chat);openai_compat 负责补选中源上下文、复用有界摘要。聚合器不判断 DONE、不读时钟;transport 掌握线路完整性。text 模式可共用帧提取与进展识别,但不调用严格 openai finish,保持旧文本工具容忍边界。 + +## 3. 分任务与独立验收 + +### T0 — 审查与来源前置核查 + +- [x] T1 前已核查分发来源,见 findings/2026-10-01-issue29-license-audit.md;发现不兼容复制即停止。 +- [ ] 父会话安排全新上下文原生 subagent 审查设计/计划,重点:单 choice、未知增量字段、身份重复/冲突、raw/managed、缓存错误降级及取消。 +- [ ] 核对每条意见,修订文档并记录接受/不接受的理由;如改变用户已批边界,走主管决定,不静默改契约。 +- [ ] 在隔离树注册文档关系,复用 llm-calls schema/call-telemetry-coverage 指标;不在主树运行生成器。 +- [ ] 文档审查通过后才调用 commit skill 提交设计/计划;当前阶段禁止提交。 + +验证命令:`wc -l research-wiki/designs/2026-10-01-issue29-openai-fidelity-design.md research-wiki/plans/2026-10-01-issue29-openai-fidelity.md` → 分别 ≤400、≤1000;`git diff --check` → 无错误。Wiki 命令经 `conda run -n PolyGateway python .claude/tools/research_wiki.py --help` 核对实际参数后执行 add_entity/add_edge/rebuild_index;检查生成 diff 不改原有条目正文。 + +### T1 — 公共契约贯穿完整治理链 + +文件:types.py、__init__.py、ports.py、client.py、middleware/retry.py;单元 types/client/ports/retry;所有显式 fake complete 签名。 + +先补测试:默认 text 不变、新类型可导入、末尾带默认字段不破旧构造;非法 response_mode、openai+structured、n=2/0/True 在适用边界拒绝;保真消息与嵌套 overlay 脱离调用方容器,返回上一轮 assistant 和 role=tool 请求 JSON 逐字段相同。 + +实现:chat 校验放在 context/handler 前;选源后非法 source.extra_body.n 在 transport HTTP 前 RequestRejected;模式作为必填端口参数穿透重试/对冲,_build_response 原样搬 completion。不把模式塞进发给上游的 payload。 + +验收:调用级拒绝不发 HTTP;源级拒绝结算/释放正常;自定义 transport fake 看见准确模式;既有旧构造回归全绿。命令: + +```bash +conda run -n PolyGateway pytest tests/unit/test_types.py tests/unit/test_client.py tests/unit/test_ports.py tests/unit/test_retry.py -q +``` + +先红测试须为行为断言或缺失 API 的明确证据,记录基线 SHA 与测试名;绿后提交点:`feat: add explicit OpenAI response contract`。 + +### T2 — 内聚协议解析器与 error 帧修复 + +文件:新增 _openai_completion.py、openai_compat.py;新增 test_openai_completion.py,扩展 test_openai_compat.py。 + +补测 SSE/非流式/缓存缺 role、非 assistant role;合法 role-only 起始帧后工具成功。先红测试至少含下表各组;复用现有 SSE/MockTransport,不引入第二套 HTTP fake 框架。 + +| 组 | 输入与精确预期 | +|---|---| +| 并行工具 | 2 工具交错 index;同帧两工具;name/arguments 分片;空白/Unicode/引号逐字相等;最终按 index 排序 | +| 身份 | id/type 同值重复只保留一次,冲突失败;缺 id/type/name/arguments、重复 id、bool/负数 index、非 function 类型失败 | +| 文本 | 同帧正文+推理均保留;缺省/null/空串区别;原消息 think 标签不剥,旧投影仍剥 | +| envelope | 顶层数组/null、choices/delta/tool_calls 非容器、坏字段类型、多 choice/非零 choice index 均受控失败 | +| 非流式 |完整消息、扩展字段、usage/finish/id 全保留;工具参数不做业务 JSON 修复;原响应对象改动不影响快照 | +| error | 首帧 error、正文后 error+usage+DONE、error=null、error={}、salvage 配置下 error 都立即失败;text/openai 两模式参数化 | + +复用现有 summarize_body/compose_message,断言异常与遥测诊断有界,不打印工具参数正文。默认文本模式不因无用工具字段的严格 finish 校验而新增失败,但不吞顶层 error。无新 HTTP 状态映射。 + +命令:`conda run -n PolyGateway pytest tests/unit/test_openai_completion.py tests/unit/test_openai_compat.py tests/unit/test_http_error_body.py -q` → PASS;提交点:`fix: preserve chat deltas and reject upstream error frames`。 + +### T3 — 完成语义、活性与非流式接线 + +文件:openai_compat.py、ports.py docstring;test_openai_compat.py、test_streaming.py。 + +先红:tool-only + DONE 成功 attempts=1;空文本且空工具失败;正文+坏工具不成功;openai 缺 DONE 即使 missing_done=salvage 仍失败;text salvage 保持;finish_reason=length 原样交回,不解析 arguments。非流式工具同样成功,两事件永不置位、ttft/stall=None。 + +按设计接线:一帧一进展,同帧多个字段只记一个 chunk_time;真正非空工具增量/首次身份触发事件,重复身份和 role/usage/finish/空片段不触发。看门狗仍包进展迭代器,stall 派生/净额取整不动。 + +测试以受控 Event/FakeClock 驱动,避免用几十毫秒竞争制造脆弱测试:持续工具片段撑过 TTFT/inter-token;只有注释/usage 必超时;工具首进展设置 first_token_event;真实停顿计入 max_inter_token/stall,两字段与正文相同口径。 + +命令:`conda run -n PolyGateway pytest tests/unit/test_openai_completion.py tests/unit/test_openai_compat.py tests/unit/test_streaming.py -q` → PASS;提交点:`feat: complete tool responses with unified stream liveness`。 + +### T4 — 完整协议缓存往返与隔离 + +文件:middleware/cache.py;test_cache.py、tests/integration/test_redis_cache.py。 + +先红测试:同消息 text/openai key 不同、旧默认 text key 字节不变;tools、tool_choice、raw effort、tool result、assistant arguments 改动均 miss;相同请求完整 completion round-trip,非流式扩展字段不丢,message 的 null/缺省可区别;usage 缺省/null 均为 None;openai 缺 completion/null/坏 message/坏工具条目 miss 后回源,不冒 dict 假装 dataclass。 + +检查命中历史 response_id 不变、本次 call_id 重建、attempts=0、cache_hit=true、cost=0;变更一次命中消息不污染下一次命中;旧 text 缓存继续可读。失败路径无 set;序列化/backend set 抛错 warning 后仍返回结果;CancelledError 原样穿透。 + +实现保真重建校验不能 import transport(import-linter 必守);内核值类型可提供纯值构造校验,协议拼接算法只保留在 transport;避免两份 SSE/tool 聚合逻辑。 + +命令: + +```bash +conda run -n PolyGateway pytest tests/unit/test_cache.py -q +conda run -n PolyGateway pytest tests/integration/test_redis_cache.py -q +conda run -n PolyGateway lint-imports +``` + +真实 Redis 专用测试 namespace,沿既有 fixture 清理自有 key,不 flush 共享 DB;未配置 skip 必须记录为未覆盖,发布前补实测。提交点:`fix: isolate and round-trip OpenAI completion caches`。 + +### T5 — 完整链路、对冲、取消与遥测不变量 + +文件:test_retry.py、test_hedge.py、test_stall_hedge.py、test_deadline.py、test_backpressure.py、tests/integration/test_governance_stack.py、test_redis_cross_connection.py、tests/e2e/conftest.py;生产 retry 仅修必要搬运漏项,不改算法。 + +先红→绿证据须覆盖真实 OpenAICompatTransport 接入 GatewayClient,而不是只用返回预制 completion 的 fake 证明解析成功: + +| 场景 | 断言 | +|---|---| +| 第一次部分正文/工具→error→DONE,第二次正常 | 只返回第二次 completion,attempts=2;第一次 error 的字段不会污染结果或缓存 | +| 工具持续进展 | 首 token 对冲及时止窗,中段 event 复位,未错误触发 | +| 真沉默触发对冲 | 副路完整工具结果胜出,返回自己的 id/usage,输家不泄露半成品 | +| primary 超时但副路尚在运行 | 不提前判总失败;两败仍只烧一次超级尝试预算 | +| 流中/准入中/退避中取消与 deadline | CancelledError/CallDeadlineExceeded 类型保留,permit/inflight/task 收口,TPM 保守结算 | +| 观测与降级 | 正常/失败/cache_hit 遥测沿单 helper;attempt 行/terminal 行口径不变;后端写失败不影响结果 | + +命令: + +```bash +conda run -n PolyGateway pytest tests/unit/test_retry.py tests/unit/test_hedge.py tests/unit/test_stall_hedge.py tests/unit/test_deadline.py tests/unit/test_backpressure.py -q +conda run -n PolyGateway pytest tests/integration/test_governance_stack.py tests/integration/test_redis_cross_connection.py -q +``` + +不删除“不需要失败计费”对应的现有失败遥测/attempts/结算;本任务不新增失败 token 取证。提交点:`test: cover governed tool retries hedges and cancellation`。 + +### T6 — 来源审计、Apache-2.0 与用户文档 + +文件:LICENSE、pyproject.toml、test_package.py、新 license-audit finding、README、CHANGELOG、.env.example、ARCHITECTURE;设计/计划 Wiki 注册关系。 + +| 工作 | 验收 | +|---|---| +| 来源审计复核(初审前置 T0) | 查 tracked 分发文件的移植/参考/版权注释及具体片段;列内部原创授权记录,外部只读 reference 的路径/许可/实际使用范围;未发现不等于法律认证 | +| 第三方义务 | 对实际复制片段核实许可、保留通知;发现 AGPL 不兼容复制须上报,不借用户授权覆盖第三方 | +| LICENSE | 使用标准 Apache-2.0 全文,不自行重写;版权归属沿项目元数据,不猜用户真实姓名;NOTICE 仅在实际需要时新增 | +| 包元数据 | 当前 setuptools>=68,采用 `license = {file = "LICENSE"}` 兼容现有后端;验证 wheel/sdist 包含 license,不为 PEP 639 单独提高构建依赖 | +| 文档 | 新 chat 示例 ≤15 行;response_mode 与 raw 所有权分开;单 choice、未知 SSE 方言失败、非流式、length、无 DONE 不打捞、缓存旧值迁移、取消/线程桥接限制写清 | + +`.env.example` 只解释 raw-only 必须去掉源级受管意图,不加模式 env 键。README 应说明“完整协议结果”仅四字段及已定义 SSE 字段,不能暗示返回所有顶层 logprobs/created 等;上游模型使用 LLMResponse.model_reported,不把 model 配置别名当真实版本。 + +ARCHITECTURE 更新 §5 类型/签名、§6.2 空补全与 error、§7.1 DONE 与工具协议、§7.2/7.6 工具活性、§7.5 缓存维度,并明确这些只是批准方向的增量,不引入 Agent Loop。站点下线,无具体 Wiki 页面可同步。 + +验证:`conda run -n PolyGateway pytest tests/unit/test_package.py -q` 先红后绿;`git diff --check`;构建验收见 T8。提交点:`docs: document faithful chat and Apache-2.0 distribution`。 + +### T7 — 少量真实工具冒烟与独立验证 + +新增 tests/e2e/test_openai_tools_live.py,复用 tests/e2e/conftest.py 的请求取证/输出机制,不为本次重跑整个 thinking 矩阵。fixture 的 complete 显式转传 response_mode 是机械接线,不改取证判分算法。 + +测试使用通用无业务领域的函数,如 echo_value;强制工具选择但工具在测试内执行纯函数,库本体不执行。流式、非流式各一次工具请求,随后带原 assistant+tool 消息做第二轮;断言 ID、finish、arguments 可用和请求消息相等。并行两工具完整逐字拼接的硬门由确定性 fixture 保证,不把模型偶尔不发两个工具解释为库聚合正确。 + +真实测试标 slow/requires_llm,预算与超时不紧于现有 `.env` 生产配置。raw=max 使用已配支持模型的 raw-only 源;不发明新模型/API。Markdown 安全报告记录模式、源/model_reported、attempts、finish、工具数、结果/失败原因;不输出 key、敏感消息或完整参数。费用/网络异常如实呈现,不宽泛 skip。 + +命令: + +```bash +conda run -n PolyGateway pytest -m slow tests/e2e/test_openai_tools_live.py tests/e2e/test_smoke_gateway.py -q +make ci +``` + +全套件由当前前台执行;若预计长跑需 tmux,禁用缓冲、保存原退出码,不在 pytest 末尾接管道。生成唯一日志路径 `tests/outputs/issue29/<任务>--<时间>.log`,不提交产物。无新增功能代码的当前阶段仅跑过单元基线,不预填完成框。 + +父会话安排全新上下文 verifier 独立查实际 diff 与测试输出。若修改了准入/取消/退避生产逻辑,补 `tests/integration/test_redis_governance_time.py` 和 `test_redis_cross_connection.py` 对应 slow 子集;仅机械传参/协议解析时记录无 Lua/退避算法交集的理由。慢测试 skip/失败不得当通过。 + +- [ ] 审查员检查依赖铁律、未知字段策略、旧文本回归与大对象保留成本。 +- [ ] 每项行为能链接红绿日志、命令、SHA、退出码;基线失败必须归因,不能“重跑绿了”掩盖。 +- [ ] 合并前测试/独立审查通过;提交均调用 commit skill,不夹带主树既有文档差异。 + +### T8 — 发布与 issue 外部验收(父会话持有,不由本设计任务执行) + +发布版本由父会话按新增公共能力确定,不能在规划中冒称已发布。遵守 CLAUDE.md §4.4.1 全步骤:README/CHANGELOG/两处版本先完成 → 合并 main/push → 合并后 lint/全套件/相交 slow → tag push → 构建/twine check → registry 上传 → 实际下载核验 → Release/挂库/包页核验。 + +| 发布门 | 可接受证据 | +|---|---| +| 构建 | `conda run -n PolyGateway python -m build` 和 twine check 成功;解包核 LICENSE、许可元数据、completion 类型/参数/解析器均在产物中 | +| 安装 | 在独立临时环境从私有索引下载/安装已发布版本;必要时 --no-build-isolation 避免索引无 setuptools;核来源/hash,不误用本地 editable | +| 外部页面 | registry 包正文与仓库链接、Gitea Release/tag 实际可见;不以 POST 返回码代替最终状态 | +| 下游 | 以发布包运行不含业务逻辑的 tool 双轮接入验收;给 dissect3 说明同 loop 线程桥接和 raw-only 配置,不在本计划越权修改其业务仓 | +| 关闭 issue | 再查询 open issue,#29 评论附版本/验收/限制与缓存迁移说明后关闭;若出现新 issue 单独定范围,不假称本计划涵盖未来问题 | + +## 4. 自审清单与残余边界 + +| 需求 | 覆盖任务 | +|---|---| +| 工具双并行、消息往返、raw=max、非流式 | T1–T3、T7 | +| error+DONE 所有模式失败、attempts=2 | T2、T5 | +| 缺 DONE 不 salvage 工具、length 不修复 | T3 | +| 活性、对冲、取消、准入/结算 | T3、T5 | +| 完整缓存、模式隔离、旧默认 key 与污染迁移 | T4、T6 | +| Apache 授权与第三方来源、分发验证 | T6、T8 | +| 测试先红后绿、独立审查、外部发布完成 | 各任务、T7、T8 | + +当前残余:首轮代码独审已完成,修复后待父会话最终复核;严格 SSE 方言会拒绝未定义增量字段,不承诺任意 OpenAI 兼容网关;旧文本毒化缓存只能由使用方迁移身份;公共 dataclass 嵌套 JSON 可变,靠快照隔离而非深冻结;模型真实冒烟依赖外部可用性。以上均需在文档和验收中如实保留,不当成全部自动解决。 + +## 5. 本轮实施记录(2026-10-01,未发布) + +P1 最终 assistant role、P2 来源核查前置均已采纳,证据见来源核查 finding。 +T1–T6 代码/文档/许可已落实;T7 工具真实冒烟、Redis、质量门已执行,独立代码审查由父会话接续;T8 禁止本 worker 执行。 + +| 验证 | 实际结果/证据 | +|---|---| +| 基线红 | tests/outputs/issue29/baseline-red.log:error+DONE 未抛、双文本丢 reasoning、模式 API 缺失,3 failed | +| 扩展红绿 | expanded-red.log 19 failed(git archive 0e29dea 的 src,不改主树);expanded-green.log 同19 passed | +| 防御红绿 | defense-red.log 3 failed:usage 裸错误/空 tool_calls 丢失;defense-green.log 同3 passed | +| 全量 | ci-final.log + ci-final.exit=0:1812 passed、23 skipped、110 deselected,覆盖率95%;之后仅增测试/文档,无生产变更 | +| 最后定向 | targeted-final.log:264 passed,包含后增的非流式事件/取消/managed冲突/名称分片回归 | +| 真实缓存 | test_redis_cache.py 6 passed,专用随机 key,无共享数据清理 | +| 真实工具 | live-final.log + live-final.exit=0:流式/非流式双轮2 passed,101.34s;安全Markdown在 tests/outputs/issue29/live | +| 构建 | /tmp/pgw-issue29-dist-final 的 wheel/sdist、twine check、LICENSE/源码字节核验通过;仍1.3.8本地测试产物,绝非发布 | + +23 skips 为17个已转专用时间语义的 Redis 契约变体、6个缺私有图像语料的 OCR 测试;未把这些当本次通过。110 deselected 为默认 slow;未重跑全模型 thinking 矩阵,因为只机械转传端口参数、未改其判据或能力表。 + +未提交代码,供父会话独立验收完整工作区。conda 启动有基础环境 RequestsDependencyWarning(pytest本体无该告警),pi-lens 使用错误环境时的缺包/枚举提示已用 conda 实证排除,不据此改变依赖。 +Wiki add_entity 会覆写无frontmatter同名文件,现场检查发现后已从本会话原始 tool-write 精确恢复两文档,并加 frontmatter 后保留 P1/P2 修订;没有丢失设计内容。 + +补充最终证据:unit-final.log 1678 passed(23.84s),smoke.log + smoke.exit=0 为既有真实 chat 冒烟4 passed(97.18s);未跑昂贵全模型矩阵。源代码最后变更早于 ci-final,后续只增测试/文档;最后单元覆盖全部新增测试。 + + +## 6. 审查修复与1.4.0准备(2026-10-01,未发布) + +已读取首轮独立报告并核验采纳 P2 空产物缓存、Minor 测试双向依赖,详见 [审查处置](../reviews/2026-10-01-issue29-cache-review.md)。本轮只在隔离 feature 树写入,主树既有脏文档未改;不合并、不 push/tag/上传、不关issue。 + +| 本轮命令/证据(日志均在 tests/outputs/issue29,不提交) | 实际结果 | +|---|---| +| 新缓存回归修复前:review-cache-red.log | 10 failed、1 passed:五种无效产物×两种缓存投影误命中;合法tool-only通过 | +| 四模块定向:review-cache-green.log | 268 passed,17.71s;包含11项新增缓存回归 | +| 真实Redis+包测试:review-redis-package.log | 13 passed,2.66s(6 Redis、7包) | +| 首次 lint / make ci | 测试import顺序失败;已修正,不作为通过证据 | +| tmux make ci:review-ci-final.log / .exit=0 | 1827 passed、23 skipped、110 deselected,317.28s;95%覆盖;格式102文件、ruff通过、依赖契约1 kept/0 broken | +| 远端 tags 与 registry simple | 最大均1.3.8,没有1.4.0;版本两处改1.4.0,README约束>=1.4.0,<2,CHANGELOG定版 | +| build / twine / 解包:review-build-final.log、review-twine.log、review-artifacts.log | 1.4.0 wheel/sdist 均通过;所有Python源码、README、标准LICENSE逐字相同,Version/License-File正确且无reference | +| README数字断言 | inspect.signature 实测 TelemetryRecorder.record_llm_call 为38字段,未凭记忆更新 | + +wheel SHA256 `3186d01d7710c3b5ef727b59f95e8c2db0821277a79491ffb913dd2ce34a888d`;sdist SHA256 `b254dedf65d25673cf57986a613a1f4e9895da8b5be1e6ee241f00065a95ad90`。这些是隔离树本地构建,不是registry产物。解包探针首次使用email默认ASCII解码比较中文README产生误报,改为METADATA正文原始字节比较后通过,未改包来迎合错误探针。 + +跳过项仍为17个独立slow Redis时间变体及6个缺私有图像语料OCR;110 slow未选中。本轮不重复付费网关:复用前轮流式/非流式工具双轮2通过和chat冒烟4通过,当前生产改动只统一空产物判据,工具/文本正常行为已有定向与全套件证明;合并后slow发布门由父执行。构建有setuptools许可表格弃用提醒(兼容setuptools>=68设计),并有基础conda RequestsDependencyWarning;均不等于当前构建或测试失败。 + +按commit skill提交feature全部实现、修复、许可与发布准备,原main脏文件及运行产物不纳入。提交后交父最终独立复核;T8外部发布尚未完成,不能提前关闭#29。 diff --git a/research-wiki/reviews/2026-10-01-issue29-cache-review.md b/research-wiki/reviews/2026-10-01-issue29-cache-review.md new file mode 100644 index 0000000..c2cd447 --- /dev/null +++ b/research-wiki/reviews/2026-10-01-issue29-cache-review.md @@ -0,0 +1,26 @@ +--- +type: review +node_id: review:2026-10-01-issue29-cache-review +title: 保真缓存审查修复与1.4.0准备 +date: 2026-10-01 +--- + +# 保真缓存审查修复与1.4.0准备 + + +## 审查采纳 + +| 级别/问题 | 核验与处置 | +|---|---| +| Important/P2:空协议缓存绕过成功判据 | 已实证:新测试在修复前 10 failed、1 passed;role-only、null/空/空白正文+空工具、reasoning-only,分别带空投影/伪造旧投影均误命中。采纳并修复。 | +| Minor:协议/治理测试互相导入 | 已核验双向依赖;将帧构造放基础协议测试中,上层 fidelity 单向复用,没有新增 helpers 抽象。 | + +内核 `types._has_completion_output` 供 transport 和缓存共用:先经协议类型结构校验,有完整工具即有效;无工具须原协议正文和兼容投影均非空。快照类型仍允许描述空消息,不将成功判据强塞进构造器。缓存使用已有 warning/miss,text 旧缓存行为不变。没有 middleware→transport 依赖。 + +## 验证证据 + +定向 `test_cache/test_openai_completion/test_openai_fidelity/test_openai_compat` 为 268 passed;日志 `tests/outputs/issue29/review-cache-{red,green}.log`(运行产物不提交)。完整质量门1827 passed/23 skipped/110 deselected、95%覆盖、退出0;真实Redis+包13通过;1.4.0 wheel/sdist源码/README/LICENSE逐字校验及twine通过,详见计划最新记录;首轮 lint 发现一个测试 import 顺序,修正后重新运行全门,未掩盖首次失败。 + +## 发布准备边界 + +1.4.0 对应新增公共协议支持及 transport 端口参数;2026-10-01 远端 tags 和匿名 registry simple 最大版本均1.3.8,无1.4.0冲突。README安装约束、能力表、CHANGELOG定版、两处版本同步。新增 `.env.example` 仅四行说明,无凭据。源码/许可来源核查复用已独审 finding;新产物仍须父会话最终复核后发布。本轮不合并、不 push/tag/上传、不关issue;原main脏文件未改。 diff --git a/research-wiki/schemas/llm-calls.md b/research-wiki/schemas/llm-calls.md index 1369b33..0aa0a3d 100644 --- a/research-wiki/schemas/llm-calls.md +++ b/research-wiki/schemas/llm-calls.md @@ -177,3 +177,10 @@ ORDER BY model, calls DESC; ## 1.3.4 测试侧证据(不新增 schema) `tests/live_evidence.py`/e2e conftest 只在内存保存完整错误体与独立非流式身份,逐轮 Markdown 白名单输出到 `tests/outputs/134/live/`;凭据、Authorization、提示词、原始异常/响应均不落报告。生产数据仍经 TelemetryEmitter。评估复用 `metric:call-telemetry-coverage`,实际 live 覆盖基线待首次执行。 + +## #29 复用声明(2026-10-01) + +OpenAI 保真没有新增遥测表/列;仍经 RetryMW._emit → TelemetryEmitter。 +error 帧是失败 attempt,正文与思考仍为兼容投影,不新增工具全文列。 +本页原标题/表格停留 36 字段;实际 `telemetry/schema.py` 已含 stall_ms/stall_count +共 38 个 INSERT 字段(§7.8 已登记)。本次不改 DDL,真实定义以源码为准。 diff --git a/src/polygateway/__init__.py b/src/polygateway/__init__.py index 8eaa381..9d9ea7c 100644 --- a/src/polygateway/__init__.py +++ b/src/polygateway/__init__.py @@ -47,12 +47,14 @@ from polygateway.types import ( OcrLayoutElement, OcrLayoutResult, OcrTextResult, + OpenAIChatCompletion, + ResponseMode, SourceConfig, TelemetryStatus, ThinkingObservation, ) -__version__ = "1.3.8" +__version__ = "1.4.0" __all__ = [ "DEFAULT_PROFILES", @@ -70,6 +72,8 @@ __all__ = [ "GatewayUnavailableError", "GovernanceBackendError", "LLMResponse", + "ResponseMode", + "OpenAIChatCompletion", "ModelPrice", "OcrClient", "OcrLayoutElement", diff --git a/src/polygateway/client.py b/src/polygateway/client.py index dd8ee18..ea3bdd2 100644 --- a/src/polygateway/client.py +++ b/src/polygateway/client.py @@ -9,6 +9,7 @@ from __future__ import annotations import asyncio +import copy import dataclasses import hashlib import json @@ -48,6 +49,7 @@ from polygateway.types import ( ChatRequest, Effort, LLMResponse, + ResponseMode, TelemetryStatus, _CallContext, coerce_effort, @@ -372,9 +374,15 @@ class GatewayClient: tenant_id: str | None = None, meta: Mapping[str, Any] | None = None, call_deadline_s: float | None = None, + response_mode: ResponseMode = "text", ) -> LLMResponse: """一次治理调用(签名冻结,ARCH §5.2;与三项目 LLMProvider 协议兼容)。 + `response_mode="openai"` 返回 `completion` 完整单 choice 消息快照; + 不支持与 structured 并用,不执行工具。空正文但完整工具是合法结果; + 缺 [DONE] 不打捞。默认 text 保持既有文本投影。返回模式不绕过受管推理 + 校验;raw-only 要求源级与请求级推理意图均为 None。 + `overlay` 是采样参数覆盖层(`temperature`/`seed`/`max_tokens` 等),优先级 高于源级 `extra_body`、低于结构化输出的注入。带默认值的 keyword-only 参数不影响既有调用点(issue #4)。 @@ -393,6 +401,23 @@ class GatewayClient: `CallDeadlineExceeded`,但到期**不等于未产出、未计费**——在途请求可能已发出、 已被上游计费,且清理仍在 `finally` 里完成,故返回时刻 = 期限 + 清理耗时。 """ + if response_mode not in ("text", "openai"): + raise ValueError("response_mode 必须为 text 或 openai") + if response_mode == "openai": + if structured is not None: + raise ValueError("openai 模式不能与 structured 并用") + if ( + overlay is not None + and "n" in overlay + and (type(overlay["n"]) is not int or overlay["n"] != 1) + ): + raise ValueError("openai 模式仅支持 n=1") + try: + json.dumps(messages, allow_nan=False) + except (ValueError, TypeError) as exc: + raise ValueError("messages 必须可 JSON 序列化") from exc + messages = copy.deepcopy(messages) + overlay = copy.deepcopy(dict(overlay)) if overlay is not None else None if structured is not None and not self._structured_available: raise ImportError( "结构化输出未启用: 安装 pip install 'polygateway[structured]' 后重新装配" @@ -429,6 +454,7 @@ class GatewayClient: context = _CallContext(now=self._now) request = ChatRequest( messages=messages, + response_mode=response_mode, session_id=session_id, parent_call_id=parent_call_id, cache_salt=cache_salt, diff --git a/src/polygateway/errors.py b/src/polygateway/errors.py index 1fd324b..00a79b5 100644 --- a/src/polygateway/errors.py +++ b/src/polygateway/errors.py @@ -37,7 +37,7 @@ SOURCE_REASONS = frozenset( class PolyGatewayError(Exception): """库内一切领域错误的基类,携带来源上下文便于遥测与日志定位。 - `body_text` 是**非 2xx 响应体的摘要**——网关拒绝这次调用时说的话(issue #10)。 + `body_text` 是**HTTP 失败或 200 chat error 帧的摘要**——网关拒绝这次调用时说的话(issue #10)。 它与 `ResultInvalidError.raw_text` 是两回事,严禁混用: ============== ================================================== diff --git a/src/polygateway/middleware/cache.py b/src/polygateway/middleware/cache.py index af60be4..e199f62 100644 --- a/src/polygateway/middleware/cache.py +++ b/src/polygateway/middleware/cache.py @@ -17,7 +17,15 @@ from typing import TYPE_CHECKING, Any from loguru import logger -from polygateway.types import ChatRequest, Effort, LLMResponse, ThinkingObservation +from polygateway.types import ( + ChatRequest, + Effort, + LLMResponse, + OpenAIChatCompletion, + ResponseMode, + ThinkingObservation, + _has_completion_output, +) if TYPE_CHECKING: from collections.abc import Mapping @@ -107,6 +115,7 @@ def build_cache_key( *, sampling: Mapping[str, Any] | None = None, reasoning_effort: Effort | None = None, + response_mode: ResponseMode = "text", ) -> str: """缓存 key 公式;salt 仅非 None 时参与(VT 旧键语义: 不传 salt 键形不变)。 @@ -127,6 +136,8 @@ def build_cache_key( "messages": digest_messages(messages), "namespace": namespace, } + if response_mode != "text": + key_obj["response_mode"] = response_mode if salt is not None: key_obj["salt"] = salt if sampling: @@ -176,6 +187,7 @@ class CacheMW: # 请求级档位必须逐次进 key: `self._fingerprint` 是装配期的集合级指纹, # 同一个 client 上 low 与 max 两次调用在它眼里毫无分别(issue #20) reasoning_effort=request.reasoning_effort, + response_mode=request.response_mode, ) cached = await self._safe_get(key) if cached is not None: @@ -183,7 +195,12 @@ class CacheMW: if hit is not None: return hit response = await call_next(request) - await self._safe_set(key, self._serialize(response)) + try: + serialized = self._serialize(response) + except Exception as exc: + logger.warning("缓存序列化失败,跳过缓存: {}", type(exc).__name__) + else: + await self._safe_set(key, serialized) return response # —— 命中路径 —— @@ -193,6 +210,16 @@ class CacheMW: try: data = json.loads(raw) fields = {k: v for k, v in data.items() if k in _RESPONSE_FIELDS} + completion = fields.get("completion") + if completion is not None: + if not isinstance(completion, dict): + raise ValueError("缓存 completion 必须为对象") + fields["completion"] = OpenAIChatCompletion(**completion) + if request.response_mode == "openai": + if fields.get("completion") is None: + raise ValueError("保真缓存缺 completion") + if not _has_completion_output(fields.get("content", ""), fields["completion"]): + raise ValueError("保真缓存无有效正文或工具产物") structured_data = self._rebuild_structured(fields.get("content", ""), request) # JSON 里存的是 StrEnum 的字符串值,不转就复活成裸 str,与字段注解分叉 # (下游 `is ThinkingObservation.OBSERVED` 会在命中路径上静默为 False); diff --git a/src/polygateway/middleware/retry.py b/src/polygateway/middleware/retry.py index b048918..03c4f96 100644 --- a/src/polygateway/middleware/retry.py +++ b/src/polygateway/middleware/retry.py @@ -357,6 +357,7 @@ class RetryMW: gen_started = self._now() result = await self._transport.complete( messages=request.messages, + response_mode=request.response_mode, source=source, stream=request.stream, overlay=request.overlay, @@ -739,6 +740,7 @@ class RetryMW: ) -> LLMResponse: return LLMResponse( content=result.content, + completion=result.completion, thinking=result.thinking, model=source.model, provider=source.provider, diff --git a/src/polygateway/ports.py b/src/polygateway/ports.py index 88da72e..1eae955 100644 --- a/src/polygateway/ports.py +++ b/src/polygateway/ports.py @@ -20,6 +20,7 @@ from .types import ( OcrLayoutTransportResult, OcrTextResult, OcrTextTransportResult, + ResponseMode, SourceConfig, SourceStats, TelemetryStatus, @@ -40,6 +41,10 @@ class Middleware(Protocol): class Transport(Protocol): """一次原始调用的协议细节(请求组装/流式解析/错误翻译);不含任何治理。 + `response_mode` 为必填返回契约:openai 成功必须提供 completion, + 不支持者应 RequestRejected,不能静默退回文本。工具的实际增量与首次身份 + 同正文/推理共用活性事件;同帧只置位一次,重复身份不算进展。 + `reasoning_effort` 是本次调用要求的推理档位(`None` = 不表态,随源级配置)。 它必须走**协议参数**而不能让 transport 自己去读 `ChatRequest`: 端口只收拆开的 请求要素,是为了让 transport 不依赖洋葱内部的请求类型(P7 端口最内层)。 @@ -70,6 +75,7 @@ class Transport(Protocol): reasoning_effort: Effort | None, first_token_event: asyncio.Event | None, content_event: asyncio.Event | None, + response_mode: ResponseMode, ) -> TransportResult: ... diff --git a/src/polygateway/transports/_openai_completion.py b/src/polygateway/transports/_openai_completion.py new file mode 100644 index 0000000..39d0640 --- /dev/null +++ b/src/polygateway/transports/_openai_completion.py @@ -0,0 +1,174 @@ +"""OpenAI 单 choice 消息聚合:仅协议值处理,不做 I/O 或治理。""" + +from dataclasses import dataclass +from typing import Any + +from polygateway.errors import TransientError +from polygateway.types import OpenAIChatCompletion, ResponseMode + + +def _invalid(reason: str) -> TransientError: + """协议形态失败,不回显可能敏感的工具参数。""" + return TransientError(f"OpenAI 协议失败: {reason}", operation="chat") + + +def _object(value: Any, name: str) -> dict[str, Any]: + """外部对象先校验再读取。""" + if not isinstance(value, dict): + raise _invalid(f"{name} 必须为对象") + return value + + +def _identity(target: dict[str, Any], key: str, value: Any) -> bool: + """身份字段只接受同值重复,不把重复完整 ID 当分片拼接。""" + if value is None: + return False + if not isinstance(value, str): + raise _invalid(f"{key} 必须为字符串") + if key in target and target[key] != value: + raise _invalid(f"{key} 冲突") + changed = key not in target and bool(value) + target[key] = value + return changed + + +def _append(target: dict[str, Any], key: str, value: Any) -> str: + """字符串片段逐字拼接,保留缺省/null/空串区别。""" + if value is None: + target.setdefault(key, None) + return "" + if not isinstance(value, str): + raise _invalid(f"{key} 必须为字符串或 null") + target[key] = (target.get(key) or "") + value + return value + + +def _completion(message: dict[str, Any], metadata: dict[str, Any]) -> OpenAIChatCompletion: + """复用内核最终快照校验并翻译领域错误。""" + try: + return OpenAIChatCompletion( + message, metadata.get("finish_reason"), metadata.get("id"), metadata.get("usage") + ) + except (ValueError, TypeError) as exc: + raise _invalid(str(exc)) from exc + + +@dataclass(frozen=True) +class ChatDelta: + """一帧提取结果;progress 表示真实生成进展。""" + + content: str + reasoning_content: str + progress: bool + + +class ChatCompletionAccumulator: + """每次 transport 尝试独享的聚合器,文本模式不严格校验工具产物。""" + + def __init__(self, *, response_mode: ResponseMode) -> None: + """初始化尝试私有状态。""" + self.mode = response_mode + self.message: dict[str, Any] = {} + self.metadata: dict[str, Any] = {} + self.tools: dict[int, dict[str, Any]] = {} + + def add_chunk(self, chunk: dict[str, Any]) -> ChatDelta: + """聚合一个已解码帧,一帧至多一个活性事件。""" + chunk = _object(chunk, "chunk") + strict = self.mode == "openai" + if strict: + _identity(self.metadata, "id", chunk.get("id")) + if chunk.get("usage") is not None: + _object(chunk["usage"], "usage") + self.metadata["usage"] = chunk["usage"] + choices = chunk.get("choices", []) + if not isinstance(choices, list): + raise _invalid("choices 必须为数组") + if not choices: + return ChatDelta("", "", False) + if strict and len(choices) != 1: + raise _invalid("仅支持单 choice") + choice = _object(choices[0], "choice") + if strict: + if type(choice.get("index")) is not int or choice["index"] != 0: + raise _invalid("choice index 必须为 0") + _identity(self.metadata, "finish_reason", choice.get("finish_reason")) + delta = _object(choice.get("delta", {}), "delta") + if strict and delta.keys() - {"role", "content", "reasoning_content", "tool_calls"}: + raise _invalid("未知 delta 字段") + if "role" in delta and strict: + if delta["role"] != "assistant": + raise _invalid("role 必须为 assistant") + self.message["role"] = delta["role"] + content = _append(self.message, "content", delta["content"]) if "content" in delta else "" + reasoning = ( + _append(self.message, "reasoning_content", delta["reasoning_content"]) + if "reasoning_content" in delta + else "" + ) + progress = bool(content or reasoning) + if "tool_calls" in delta: + tools = delta["tool_calls"] + if not isinstance(tools, list): + if strict: + raise _invalid("tool_calls 必须为数组") + return ChatDelta(content, reasoning, progress) + if strict: + self.message.setdefault("tool_calls", []) + for raw in tools: + if not strict: + # 文本只消费可识别的工具进展,不承诺工具完整性。 + if isinstance(raw, dict) and type(raw.get("index")) is int: + index = raw["index"] + tool = self.tools.setdefault(index, {}) + for key in ("id", "type"): + if isinstance(raw.get(key), str) and raw[key] and key not in tool: + tool[key] = raw[key] + progress = True + fn = raw.get("function") + if isinstance(fn, dict): + progress |= any( + isinstance(fn.get(k), str) and bool(fn[k]) + for k in ("name", "arguments") + ) + continue + raw = _object(raw, "tool") + index = raw.get("index") + if type(index) is not int or index < 0: + raise _invalid("tool index 必须为非负整数") + if raw.keys() - {"index", "id", "type", "function"}: + raise _invalid("未知工具增量字段") + tool = self.tools.setdefault(index, {}) + for key in ("id", "type"): + progress |= _identity(tool, key, raw.get(key)) + if "function" in raw: + fn = _object(raw["function"], "function") + if fn.keys() - {"name", "arguments"}: + raise _invalid("未知 function 增量字段") + target = tool.setdefault("function", {}) + for key in ("name", "arguments"): + if key in fn: + if not isinstance(fn[key], str): + raise _invalid(f"function.{key} 必须为字符串") + progress |= bool(_append(target, key, fn[key])) + return ChatDelta(content, reasoning, progress) + + def finish(self) -> OpenAIChatCompletion: + """线路完整性由调用者保证;此处只校验最终消息。""" + message = dict(self.message) + if self.tools: + message["tool_calls"] = [self.tools[index] for index in sorted(self.tools)] + return _completion(message, self.metadata) + + +def parse_chat_completion(body: dict[str, Any]) -> OpenAIChatCompletion: + """非流式完整消息保留扩展字段,拒绝静默截取多 choice。""" + body = _object(body, "body") + choices = body.get("choices") + if not isinstance(choices, list) or len(choices) != 1: + raise _invalid("仅支持单 choice") + choice = _object(choices[0], "choice") + if "index" in choice and (type(choice["index"]) is not int or choice["index"] != 0): + raise _invalid("choice index 必须为 0") + metadata = {**body, "finish_reason": choice.get("finish_reason")} + return _completion(_object(choice.get("message"), "message"), metadata) diff --git a/src/polygateway/transports/openai_compat.py b/src/polygateway/transports/openai_compat.py index 23e2f47..3956bcc 100644 --- a/src/polygateway/transports/openai_compat.py +++ b/src/polygateway/transports/openai_compat.py @@ -38,11 +38,19 @@ from polygateway.thinking import ( validate_thinking_raw, ) from polygateway.transports._http_errors import compose_message, summarize_body +from polygateway.transports._openai_completion import ( + ChatCompletionAccumulator, + ChatDelta, + parse_chat_completion, +) from polygateway.types import ( Effort, EmbeddingTransportResult, + OpenAIChatCompletion, + ResponseMode, SourceConfig, TransportResult, + _has_completion_output, ) if TYPE_CHECKING: @@ -62,29 +70,6 @@ def _sse_data_payload(raw: str) -> str | None: return line[len("data:") :].strip() -def _sse_delta(chunk: dict[str, Any], usage_sink: dict[str, Any]) -> tuple[bool, str] | None: - """从 chunk 提取增量: (True, content) 或 (False, reasoning);usage 帧旁路进 sink。""" - if chunk.get("usage"): - usage_sink["usage"] = chunk["usage"] - if "model" not in usage_sink: - # 首个**有效**值即固定: 末帧的异常值不得覆盖它;但首帧报空串也不能锁死 - # sink——否则后续真实版本会丢(issue #3) - reported = _coerce_model_reported(chunk.get("model")) - if reported is not None: - usage_sink["model"] = reported - choices = chunk.get("choices") or [] - if not choices: - return None - delta = choices[0].get("delta") or {} - content = delta.get("content") - if content: - return (True, content) - reasoning = delta.get("reasoning_content") - if reasoning: - return (False, reasoning) - return None - - def _stall_from_chunk_times( times: list[float], threshold_ms: int ) -> tuple[float | None, float | None]: @@ -108,10 +93,25 @@ def _stall_from_chunk_times( return (total, float(count)) if count else (0.0, 0.0) +def _check_chat_error(chunk: Any) -> None: + """所有模式拒绝顶层 error;诊断复用有界摘要。""" + if not isinstance(chunk, dict): + raise TransientError("chat 响应必须为 JSON 对象", operation="chat") + if "error" in chunk: + summary = summarize_body(json.dumps({"error": chunk["error"]}, ensure_ascii=False)) + raise TransientError( + compose_message("上游 chat error 帧", summary), operation="chat", body_text=summary + ) + if chunk.get("usage") is not None and not isinstance(chunk["usage"], dict): + raise TransientError("chat usage 必须为对象或 null", operation="chat") + + async def _iter_sse_deltas( - lines: AsyncIterator[str], usage_sink: dict[str, Any] -) -> AsyncIterator[tuple[bool, str]]: - """逐行解析 SSE 流;[DONE] 置 sink["done"];畸形 JSON 帧 → 瞬时错误(可重试)。""" + lines: AsyncIterator[str], + usage_sink: dict[str, Any], + accumulator: ChatCompletionAccumulator, +) -> AsyncIterator[ChatDelta]: + """解析 SSE,一帧只产生一次进展事件。""" async for raw in lines: data = _sse_data_payload(raw) if data is None: @@ -122,11 +122,16 @@ async def _iter_sse_deltas( try: chunk = json.loads(data) except json.JSONDecodeError as exc: - raise TransientError( - f"SSE 帧畸形(malformed_json): {data[:80]!r}", operation="chat" - ) from exc - delta = _sse_delta(chunk, usage_sink) - if delta is not None: + raise TransientError("SSE 帧畸形(malformed_json)", operation="chat") from exc + _check_chat_error(chunk) + if chunk.get("usage") is not None: + usage_sink["usage"] = chunk["usage"] + if "model" not in usage_sink: + model = _coerce_model_reported(chunk.get("model")) + if model is not None: + usage_sink["model"] = model + delta = accumulator.add_chunk(chunk) + if delta.progress: yield delta @@ -458,6 +463,7 @@ class OpenAICompatTransport: reasoning_effort: Effort | None, first_token_event: asyncio.Event | None, content_event: asyncio.Event | None, + response_mode: ResponseMode, ) -> TransportResult: """一次原始调用;HTTP/线路/流式异常按 ARCH §6.2 翻译为领域错误。 @@ -468,6 +474,16 @@ class OpenAICompatTransport: `content_event` 为中段沉默对冲处置位(1.3.8): 同款约定,流式路径**每个** 增量 chunk 置位一次,非流式路径收它但永不置位。 """ + if response_mode not in ("text", "openai"): + raise RequestRejectedError( + "非法 response_mode", source_name=source.name, operation="chat" + ) + if response_mode == "openai": + for layer in (source.extra_body, overlay): + if "n" in layer and (type(layer["n"]) is not int or layer["n"] != 1): + raise RequestRejectedError( + "openai 模式仅支持 n=1", source_name=source.name, operation="chat" + ) profile = get_provider(source.provider, registry=self._registry) try: payload, applied_effort = self._build_payload( @@ -492,12 +508,30 @@ class OpenAICompatTransport: try: if stream: result = await self._complete_stream( - client, url, payload, source, profile, first_token_event, content_event + client, + url, + payload, + source, + profile, + first_token_event, + content_event, + response_mode, ) else: result = await self._complete_once( - client, url, payload, source, profile, first_token_event, content_event + client, + url, + payload, + source, + profile, + first_token_event, + content_event, + response_mode, ) + except TransientError as exc: + if exc.source_name is None: + exc.source_name = source.name + raise except StreamLivenessTimeout as exc: raise TransientError(f"{source.name} 流活性超时({exc.kind})", **ctx) from exc except httpx.TimeoutException as exc: @@ -585,6 +619,7 @@ class OpenAICompatTransport: profile: ProviderProfile, first_token_event: asyncio.Event | None, content_event: asyncio.Event | None, + response_mode: ResponseMode, ) -> TransportResult: started = time.monotonic() async with client.stream("POST", url, json=payload) as resp: @@ -594,8 +629,9 @@ class OpenAICompatTransport: source, resp.status_code, body, resp.headers, operation="chat" ) sink: dict[str, Any] = {} + accumulator = ChatCompletionAccumulator(response_mode=response_mode) guarded = stream_with_liveness_timeouts( - _iter_sse_deltas(resp.aiter_lines(), sink), + _iter_sse_deltas(resp.aiter_lines(), sink, accumulator), ttft_s=source.ttft_timeout_s or source.timeout_s, inter_token_s=source.inter_token_timeout_s or source.timeout_s, total_s=source.timeout_s, @@ -606,7 +642,7 @@ class OpenAICompatTransport: last = started max_gap = 0.0 chunk_times: list[float] = [] - async for is_content, text in guarded: + async for delta in guarded: now = time.monotonic() # 增量 chunk 时间戳(1.3.8 沉默计量): 循环体只会被真实增量触发—— # 空 chunk/usage 帧/SSE keepalive 已在 _sse_data_payload/_sse_delta @@ -625,10 +661,18 @@ class OpenAICompatTransport: else: max_gap = max(max_gap, (now - last) * 1000) last = now - (content_parts if is_content else thinking_parts).append(text) + if delta.content: + content_parts.append(delta.content) + if delta.reasoning_content: + thinking_parts.append(delta.reasoning_content) + if response_mode == "openai" and not sink.get("done"): + raise TransientError( + "保真响应缺 [DONE],禁止打捞", source_name=source.name, operation="chat" + ) + completion_result = accumulator.finish() if response_mode == "openai" else None salvaged = self._check_done(sink, content_parts, thinking_parts, source) content, thinking = self._finalize_text(content_parts, thinking_parts, profile) - self._reject_empty_completion(content, source) + self._reject_empty_completion(content, completion_result, source) prompt, completion, usage_source = _resolve_stream_usage(sink, salvaged) reasoning_tokens = _coerce_reasoning_tokens(sink.get("usage")) stall_ms, stall_count = _stall_from_chunk_times(chunk_times, source.stall_threshold_ms) @@ -643,6 +687,7 @@ class OpenAICompatTransport: stall_ms=stall_ms, stall_count=stall_count, raw={"usage": sink.get("usage")}, + completion=completion_result, cached_prompt_tokens=_coerce_cached_tokens(sink.get("usage")), model_reported=_coerce_model_reported(sink.get("model")), reasoning_tokens=reasoning_tokens, @@ -670,14 +715,16 @@ class OpenAICompatTransport: raise TransientError(f"{source.name} SSE missing_done: 截断且无 [DONE]", **ctx) return True - def _reject_empty_completion(self, content: str, source: SourceConfig) -> None: + def _reject_empty_completion( + self, content: str, completion: OpenAIChatCompletion | None, source: SourceConfig + ) -> None: """空补全 → 瞬时错误(2026-07-20 人类裁决,M1 验证发现)。 服务 200 且流程完整([DONE]/usage 正常)但 content 为空——MiniMax 等 网关的间歇异常形态。视为服务抖动: 退避重试/换源,**绝不缓存空响应**; 承 CHS "VLM 零 content"归瞬时的先例(invokers.py:309)。 """ - if not content.strip(): + if not _has_completion_output(content, completion): raise TransientError( f"{source.name} 空补全(empty_completion): 流程完整但零内容", source_name=source.name, @@ -704,6 +751,7 @@ class OpenAICompatTransport: profile: ProviderProfile, first_token_event: asyncio.Event | None, content_event: asyncio.Event | None, + response_mode: ResponseMode, ) -> TransportResult: """非流式快路径(三项目均无,库新增): 单 JSON 响应,仅 total 超时。 @@ -723,16 +771,25 @@ class OpenAICompatTransport: raise TransientError( f"{source.name} 非流式响应非法 JSON", source_name=source.name, operation="chat" ) from exc + _check_chat_error(body) + completion_result = parse_chat_completion(body) if response_mode == "openai" else None choices = body.get("choices") or [] if not choices: raise TransientError( f"{source.name} 响应缺 choices", source_name=source.name, operation="chat" ) - message = choices[0].get("message") or {} + if not isinstance(choices, list) or not isinstance(choices[0], dict): + raise TransientError("chat choices 形态非法", operation="chat") + message = choices[0].get("message", {}) + if not isinstance(message, dict) or any( + message.get(key) is not None and not isinstance(message[key], str) + for key in ("content", "reasoning_content") + ): + raise TransientError("chat message 形态非法", operation="chat") content, thinking = self._finalize_text( [message.get("content") or ""], [message.get("reasoning_content") or ""], profile ) - self._reject_empty_completion(content, source) + self._reject_empty_completion(content, completion_result, source) prompt, completion, usage_source = _resolve_usage(body.get("usage") or {}) reasoning_tokens = _coerce_reasoning_tokens(body.get("usage")) return TransportResult( @@ -744,6 +801,7 @@ class OpenAICompatTransport: ttft_ms=None, max_inter_token_ms=None, raw={"usage": body.get("usage")}, + completion=completion_result, cached_prompt_tokens=_coerce_cached_tokens(body.get("usage")), model_reported=_coerce_model_reported(body.get("model")), reasoning_tokens=reasoning_tokens, diff --git a/src/polygateway/types.py b/src/polygateway/types.py index 63e04c0..58ee7fd 100644 --- a/src/polygateway/types.py +++ b/src/polygateway/types.py @@ -4,6 +4,7 @@ fake,字段顺序即公共承诺;新增字段只增不删且必带默认值。 """ +import copy import dataclasses import json import math @@ -422,6 +423,73 @@ class _CallContext: return True +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 + + def __post_init__(self) -> None: + """校验协议快照;transport 翻译错误,缓存坏条目按 miss。""" + message = self.message + if not isinstance(message, dict) or message.get("role") != "assistant": + raise ValueError("completion message 必须含 assistant role") + for key in ("content", "reasoning_content"): + if message.get(key) is not None and not isinstance(message[key], str): + raise ValueError(f"completion {key} 必须为字符串或 null") + for value in (self.finish_reason, self.response_id): + if value is not None and not isinstance(value, str): + raise ValueError("completion 身份/结束原因必须为字符串或 null") + if self.usage is not None and not isinstance(self.usage, dict): + raise ValueError("completion usage 必须为对象或 null") + if "tool_calls" in message: + tools = message["tool_calls"] + if not isinstance(tools, list): + raise ValueError("completion tool_calls 必须为数组") + seen: set[str] = set() + for tool in tools: + if not isinstance(tool, dict): + raise ValueError("completion tool 必须为对象") + identity = tool.get("id") + fn = tool.get("function") + if ( + not isinstance(identity, str) + or not identity + or identity in seen + or tool.get("type") != "function" + or not isinstance(fn, dict) + or not isinstance(fn.get("name"), str) + or not fn["name"] + or not isinstance(fn.get("arguments"), str) + ): + raise ValueError("completion tool 身份/函数字段缺失、冲突或非法") + seen.add(identity) + json.dumps({"message": message, "usage": self.usage}, allow_nan=False) + object.__setattr__(self, "message", copy.deepcopy(message)) + object.__setattr__(self, "usage", copy.deepcopy(self.usage)) + + +def _has_completion_output(content: str, completion: OpenAIChatCompletion | None) -> bool: + """成功须有投影正文或完整工具;协议原文为空时不信任缓存旧投影。 + + 只判定产物存在性,不替代协议结构校验,也不收紧快照类型的构造用途。 + transport 与缓存共用此规则,避免空响应从缓存旁路成功。 + """ + if completion is not None: + if completion.message.get("tool_calls"): + return True + raw_content = completion.message.get("content") + if not isinstance(raw_content, str) or not raw_content.strip(): + return False + return isinstance(content, str) and bool(content.strip()) + + @dataclass(frozen=True) class LLMResponse: """一次治理调用的统一响应(与三项目超集兼容,ARCH §5.1)。""" @@ -487,6 +555,8 @@ class LLMResponse: call_stats: CallStats | None = None """本次**逻辑调用**的统计快照(1.3.5);`None` = 未知,不得读成 0。""" + completion: OpenAIChatCompletion | None = None + @dataclass(frozen=True) class ChatRequest: @@ -543,6 +613,8 @@ class ChatRequest: 洋葱各层经 `dataclasses.replace` 派生请求时保留**同一引用**(不是拷贝), 重试/重问/分批才能共享同一个逻辑 ID 与计数。""" + response_mode: ResponseMode = "text" + @dataclass(frozen=True) class Usage: @@ -627,6 +699,8 @@ class TransportResult: """超阈值相邻间隔个数(归因成对报告,Gregg 原则);值域与 `None` 语义同 `stall_ms`。""" + completion: OpenAIChatCompletion | None = None + @dataclass(frozen=True) class SourceConfig: diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index bdf1ac8..d0a9b00 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -22,7 +22,13 @@ from polygateway.client import ( ) from polygateway.providers import get_provider from polygateway.transports.openai_compat import OpenAICompatTransport -from polygateway.types import Effort, EmbeddingTransportResult, SourceConfig, TransportResult +from polygateway.types import ( + Effort, + EmbeddingTransportResult, + ResponseMode, + SourceConfig, + TransportResult, +) from tests.live_evidence import ( AttemptEvidence, HttpEvidence, @@ -293,6 +299,7 @@ class ObservedTransport: reasoning_effort: Effort | None, first_token_event: asyncio.Event | None, content_event: asyncio.Event | None, + response_mode: ResponseMode, ) -> TransportResult: """与生产端口逐参数同签名。""" with self._capture.attempt_context(call_id): @@ -306,6 +313,7 @@ class ObservedTransport: reasoning_effort=reasoning_effort, first_token_event=first_token_event, content_event=content_event, + response_mode=response_mode, ) async def embed( diff --git a/tests/e2e/test_openai_tools_live.py b/tests/e2e/test_openai_tools_live.py new file mode 100644 index 0000000..38acba5 --- /dev/null +++ b/tests/e2e/test_openai_tools_live.py @@ -0,0 +1,114 @@ +"""真实网关工具双轮冒烟;复用安全 Markdown 报告,不运行工具副作用。""" + +import dataclasses +import json +import os +from pathlib import Path +from uuid import uuid4 + +import pytest +from dotenv import dotenv_values + +from polygateway import GatewayClient, GatewaySettings +from tests.live_evidence import write_live_round + +_ENV = {k: v for k, v in {**dotenv_values(".env"), **os.environ}.items() if v is not None} +pytestmark = [pytest.mark.slow, pytest.mark.requires_llm] + + +@pytest.mark.parametrize("stream", [True, False]) +async def test_tools_two_rounds(stream): + """已配置源中选首源,只改变测试隔离与 raw-only 意图,不收紧生产超时。""" + if not any(k.startswith("LLM__") and k.endswith("__API_KEY") for k in _ENV): + pytest.skip("未配置真实源,未覆盖") + settings = GatewaySettings.from_env("LLM", env=_ENV) + source = dataclasses.replace(settings.sources[0], enable_thinking=None, reasoning_effort=None) + settings = dataclasses.replace( + settings, + sources=(source,), + cache_backend="none", + cache_namespace=None, + cache_ttl_s=None, + telemetry_backend="none", + telemetry_auto_migrate=False, + limiter_backend="memory", + breaker_backend="memory", + hedge_after_s=None, + hedge_stall_after_s=None, + ) + tools = [ + { + "type": "function", + "function": { + "name": "echo_value", + "description": "Return the supplied value", + "parameters": { + "type": "object", + "properties": {"value": {"type": "string"}}, + "required": ["value"], + }, + }, + } + ] + messages = [ + { + "role": "user", + "content": "Call echo_value with value hello. After receiving the tool result, reply with hello.", + } + ] + status, error_type, counts = "FAIL", None, {} + try: + async with GatewayClient.from_settings(settings) as client: + first = await client.chat( + messages, + stream=stream, + response_mode="openai", + overlay={ + "tools": tools, + "tool_choice": {"type": "function", "function": {"name": "echo_value"}}, + }, + ) + completion = first.completion + assert completion is not None and completion.message["role"] == "assistant" + assert completion.response_id and completion.finish_reason == "tool_calls" + calls = completion.message["tool_calls"] + counts["tools"] = len(calls) + assert calls + messages.append(completion.message) + for call in calls: + assert call["function"]["name"] == "echo_value" + arguments = json.loads(call["function"]["arguments"]) + assert arguments["value"] == "hello" + messages.append( + {"role": "tool", "tool_call_id": call["id"], "content": arguments["value"]} + ) + second = await client.chat( + messages, + stream=stream, + response_mode="openai", + overlay={"tools": tools, "tool_choice": "none"}, + ) + assert ( + second.completion is not None and second.completion.message["role"] == "assistant" + ) + assert "hello" in (second.completion.message.get("content") or "").lower() + counts["attempts"] = first.call_stats.attempts + second.call_stats.attempts + status = "PASS" + except Exception as exc: + error_type = type(exc).__name__ + raise + finally: + write_live_round( + Path("tests/outputs/issue29/live"), + run_id=uuid4().hex, + matrix_id="tools", + round_index=1, + safe_fields={ + "status": status, + "stream": stream, + "error_type": error_type, + "counts": counts, + "planned_rounds": 2, + "completed_rounds": 2 if status == "PASS" else 0, + }, + ) diff --git a/tests/integration/test_redis_cache.py b/tests/integration/test_redis_cache.py index 8ebf40a..341c69b 100644 --- a/tests/integration/test_redis_cache.py +++ b/tests/integration/test_redis_cache.py @@ -172,3 +172,22 @@ class TestClientWithRealRedis: second = await client.chat(_MSGS) assert first.cache_hit is False and second.cache_hit is True assert second.content == "hello" + + +async def test_real_completion_roundtrip(redis_cache, namespace): + """真实 Redis 保存完整嵌套协议对象,命中仍为值类型。""" + from dataclasses import replace + + from tests.unit.test_openai_completion import accumulate + + completion = accumulate().finish() + mw = CacheMW(backend=redis_cache, model_fingerprint="m", default_namespace=namespace, ttl_s=60) + + async def terminal(_): + return replace(_resp(), completion=completion) + + request = ChatRequest(messages=_MSGS, response_mode="openai") + first = await mw(request, terminal) + second = await mw(request, terminal) + assert second.cache_hit and second.completion == first.completion + assert second.completion is not first.completion diff --git a/tests/integration/test_redis_cross_connection.py b/tests/integration/test_redis_cross_connection.py index fc2f5a2..a57418f 100644 --- a/tests/integration/test_redis_cross_connection.py +++ b/tests/integration/test_redis_cross_connection.py @@ -86,6 +86,7 @@ class ScriptedTransport: reasoning_effort, first_token_event, content_event, + response_mode, ): self.calls.append(source.name) if self.hang: diff --git a/tests/unit/test_backpressure.py b/tests/unit/test_backpressure.py index c92581c..6a0a3d4 100644 --- a/tests/unit/test_backpressure.py +++ b/tests/unit/test_backpressure.py @@ -221,6 +221,7 @@ class ClockAdvancingTransport: reasoning_effort, first_token_event, content_event, + response_mode, ): self.calls.append((source.name, call_id)) advance, action = self.script.pop(0) diff --git a/tests/unit/test_cache.py b/tests/unit/test_cache.py index 113e6dd..959474a 100644 --- a/tests/unit/test_cache.py +++ b/tests/unit/test_cache.py @@ -15,6 +15,7 @@ from polygateway.types import ( ChatRequest, Effort, LLMResponse, + OpenAIChatCompletion, SourceConfig, ThinkingObservation, ) @@ -891,3 +892,113 @@ class TestCallStatsNotPoisoned: without = ChatRequest(messages=_MSGS) assert with_ctx.cache_namespace == without.cache_namespace assert digest_messages(with_ctx.messages) == digest_messages(without.messages) + + +class TestOpenAICompletionCache: + """协议缓存不得混模式或把字典冒充公共值对象。""" + + def test_mode_isolation_preserves_text_key(self): + assert build_cache_key("m", _MSGS, "proj", None) == build_cache_key( + "m", _MSGS, "proj", None, response_mode="text" + ) + assert build_cache_key("m", _MSGS, "proj", None) != build_cache_key( + "m", _MSGS, "proj", None, response_mode="openai" + ) + + @pytest.mark.parametrize( + "completion", + [ + None, + {}, + { + "message": {"content": "ok"}, + "finish_reason": "stop", + "response_id": None, + "usage": None, + }, + ], + ) + async def test_missing_or_bad_completion_misses(self, completion): + backend = InMemoryCache() + mw = CacheMW(backend=backend, model_fingerprint="m", default_namespace="proj", ttl_s=60) + raw = json.loads(mw._serialize(_resp())) + raw["completion"] = completion + assert ( + mw._rehydrate(json.dumps(raw), ChatRequest(messages=_MSGS, response_mode="openai")) + is None + ) + + async def test_serialization_failure_does_not_fail_call(self): + mw = CacheMW( + backend=InMemoryCache(), model_fingerprint="m", default_namespace="proj", ttl_s=60 + ) + response = _resp(cost=object()) + + async def terminal(_): + return response + + assert await mw(ChatRequest(messages=_MSGS), terminal) is response + + +@pytest.mark.parametrize( + "message", + [ + {"role": "assistant"}, + {"role": "assistant", "content": None, "tool_calls": []}, + {"role": "assistant", "content": "", "tool_calls": []}, + {"role": "assistant", "content": " \n", "tool_calls": []}, + {"role": "assistant", "reasoning_content": "reason only"}, + ], +) +@pytest.mark.parametrize("cached_content", ["", "stale projection"]) +async def test_openai_cache_without_output_misses_and_refills(message, cached_content): + """空协议产物不能靠旧投影正文伪装成功;miss 后回源并覆写。""" + backend = InMemoryCache() + mw = _mw(backend) + request = ChatRequest(messages=_MSGS, response_mode="openai") + key = build_cache_key("m", _MSGS, "proj", None, response_mode="openai") + bad = _resp( + content=cached_content, + completion=OpenAIChatCompletion(message, "stop", "bad", None), + ) + await backend.set(key, mw._serialize(bad), ttl_s=60) + fresh = _resp( + completion=OpenAIChatCompletion( + {"role": "assistant", "content": "cached"}, "stop", "fresh", None + ) + ) + terminal = _Terminal(fresh) + result = await mw(request, terminal) + assert terminal.calls == 1 and result is fresh and not result.cache_hit + hit = await mw(request, terminal) + assert terminal.calls == 1 and hit.cache_hit + assert hit.completion.response_id == "fresh" + + +async def test_openai_tool_only_cache_hits_without_origin(): + """有效工具本身是产物,空文本投影不妨碍命中。""" + backend = InMemoryCache() + mw = _mw(backend) + completion = OpenAIChatCompletion( + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call-1", + "type": "function", + "function": {"name": "echo", "arguments": "{}"}, + } + ], + }, + "tool_calls", + "tool-response", + None, + ) + request = ChatRequest(messages=_MSGS, response_mode="openai") + key = build_cache_key("m", _MSGS, "proj", None, response_mode="openai") + await backend.set(key, mw._serialize(_resp(content="", completion=completion)), ttl_s=60) + terminal = _Terminal(_resp()) + hit = await mw(request, terminal) + assert hit.cache_hit and terminal.calls == 0 + assert hit.completion == completion diff --git a/tests/unit/test_client.py b/tests/unit/test_client.py index 830548c..55d0ac9 100644 --- a/tests/unit/test_client.py +++ b/tests/unit/test_client.py @@ -232,6 +232,7 @@ class _ScriptedGenClockTransport: reasoning_effort, first_token_event, content_event, + response_mode, ): self.calls.append(call_id) result = self._results.pop(0) @@ -1844,6 +1845,7 @@ class _ClockJumpTransport: reasoning_effort, first_token_event, content_event, + response_mode, ): self.calls.append(call_id) self._clock.advance(self._jump) diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index e1920cb..66120c7 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -1397,9 +1397,7 @@ class TestStallConfig: def test_stall_after_s_single_source_warns(self): """单源 scope 设 stall 阈值: 装配期 warning 放行(1.3.7 §5 同款)。""" with _captured_warnings() as warnings: - s = GatewaySettings.from_env( - "LLM", env=_env(**{"LLM__HEDGE__STALL_AFTER_S": "8"}) - ) + s = GatewaySettings.from_env("LLM", env=_env(**{"LLM__HEDGE__STALL_AFTER_S": "8"})) assert s.hedge_stall_after_s == 8.0 assert any("单源" in m for m in warnings) @@ -1422,9 +1420,7 @@ class TestStallConfig: "EMBED__BATCH_SIZE": "64", } with _captured_warnings() as warnings: - EmbeddingSettings.from_env( - "EMBED", env={**embed_env, "EMBED__HEDGE__AFTER_S": "8"} - ) + EmbeddingSettings.from_env("EMBED", env={**embed_env, "EMBED__HEDGE__AFTER_S": "8"}) assert any("仅对 chat scope 生效" in m for m in warnings) with _captured_warnings() as warnings: EmbeddingSettings.from_env( diff --git a/tests/unit/test_hedge.py b/tests/unit/test_hedge.py index 2bd7ccc..c1c4cec 100644 --- a/tests/unit/test_hedge.py +++ b/tests/unit/test_hedge.py @@ -65,6 +65,7 @@ class HedgeTransport: reasoning_effort, first_token_event, content_event, + response_mode, ): self.calls.append(source.name) self.ft_events.append(first_token_event) diff --git a/tests/unit/test_live_evidence.py b/tests/unit/test_live_evidence.py index 6d6723d..f033bf7 100644 --- a/tests/unit/test_live_evidence.py +++ b/tests/unit/test_live_evidence.py @@ -279,6 +279,7 @@ async def _complete(observed, *, call_id="a", stream=False): reasoning_effort=None, first_token_event=None, content_event=None, + response_mode="text", ) @@ -1270,6 +1271,7 @@ async def test_structured_first_attempt_requires_exact_initial_messages(): reasoning_effort=None, first_token_event=None, content_event=None, + response_mode="text", ) event = capture.attempts(session_id="first", parent_call_id="parent")[0].http[0] assert not request_is_valid(event) diff --git a/tests/unit/test_openai_compat.py b/tests/unit/test_openai_compat.py index 79772d7..e345a1e 100644 --- a/tests/unit/test_openai_compat.py +++ b/tests/unit/test_openai_compat.py @@ -90,6 +90,7 @@ async def _complete( reasoning_effort=None, first_token_event=None, content_event=None, + response_mode="text", ): return await transport.complete( messages=[{"role": "user", "content": "hi"}], @@ -100,6 +101,7 @@ async def _complete( reasoning_effort=reasoning_effort, first_token_event=first_token_event, content_event=content_event, + response_mode="text", ) @@ -161,6 +163,8 @@ class TestSsePureFunctions: assert _sse_data_payload("data: [DONE]") == "[DONE]" async def test_iter_deltas_yields_and_flags_done(self): + from polygateway.transports._openai_completion import ChatCompletionAccumulator, ChatDelta + async def lines(): for raw in [ _chunk(content="he"), @@ -173,16 +177,28 @@ class TestSsePureFunctions: yield line sink = {} - out = [d async for d in _iter_sse_deltas(lines(), sink)] - assert out == [(True, "he"), (False, "think")] + out = [ + d + async for d in _iter_sse_deltas( + lines(), sink, ChatCompletionAccumulator(response_mode="text") + ) + ] + assert out == [ChatDelta("he", "", True), ChatDelta("", "think", True)] assert sink["done"] is True and sink["usage"] == _USAGE async def test_malformed_frame_raises_transient(self): + from polygateway.transports._openai_completion import ChatCompletionAccumulator + async def lines(): yield "data: {not-json" with pytest.raises(TransientError, match="malformed"): - _ = [d async for d in _iter_sse_deltas(lines(), {})] + _ = [ + d + async for d in _iter_sse_deltas( + lines(), {}, ChatCompletionAccumulator(response_mode="text") + ) + ] class TestStreamHappyPath: diff --git a/tests/unit/test_openai_completion.py b/tests/unit/test_openai_completion.py new file mode 100644 index 0000000..af97462 --- /dev/null +++ b/tests/unit/test_openai_completion.py @@ -0,0 +1,227 @@ +"""协议聚合器的分片、防御与快照契约。""" + +import copy +import json + +import pytest + +from polygateway.errors import TransientError +from polygateway.transports._openai_completion import ( + ChatCompletionAccumulator, + parse_chat_completion, +) + + +def chunk(delta, **extra): + """构造单 choice 协议帧。""" + return ( + "data: " + + json.dumps({"id": "upstream-1", "choices": [{"index": 0, "delta": delta, **extra}]}) + + "\n\n" + ) + + +def tool_frames(): + """两个交错分片工具,保留空白及 Unicode。""" + return [ + chunk({"role": "assistant", "content": None}), + chunk( + { + "tool_calls": [ + { + "index": 1, + "id": "b", + "type": "function", + "function": {"name": "echo", "arguments": '{"v":'}, + }, + { + "index": 0, + "id": "a", + "type": "function", + "function": {"name": "echo", "arguments": "{"}, + }, + ] + } + ), + chunk( + { + "tool_calls": [ + {"index": 0, "function": {"arguments": '"v": "中" }'}}, + {"index": 1, "function": {"arguments": "2}"}}, + ] + }, + finish_reason="tool_calls", + ), + ] + + +def accumulate(): + """复用双工具网关帧样本。""" + state = ChatCompletionAccumulator(response_mode="openai") + for frame in tool_frames(): + state.add_chunk(json.loads(frame.removeprefix("data: "))) + return state + + +@pytest.mark.parametrize("role", [None, "user", "tool"]) +def test_role_is_required_everywhere(role): + """缺角色不能被协议成功掩盖。""" + state = accumulate() + state.message.pop("role") + if role: + state.message["role"] = role + with pytest.raises(TransientError, match="role"): + state.finish() + with pytest.raises(TransientError, match="role"): + parse_chat_completion({"choices": [{"message": state.message}]}) + + +@pytest.mark.parametrize( + "change", + [ + lambda t: t.pop("id"), + lambda t: t.update(type="custom"), + lambda t: t["function"].pop("name"), + lambda t: t["function"].pop("arguments"), + lambda t: t["function"].update(arguments=12), + ], +) +def test_incomplete_tool_is_not_success(change): + """工具必需字段不能靠正文兜底。""" + state = accumulate() + state.message["content"] = "text" + change(state.tools[0]) + with pytest.raises(TransientError): + state.finish() + + +@pytest.mark.parametrize( + "delta", + [ + [], + {"audio": {}}, + {"tool_calls": {}}, + {"content": []}, + {"tool_calls": [{"index": True}]}, + {"tool_calls": [{"index": -1}]}, + {"tool_calls": [{"index": 0, "extra": 1}]}, + {"tool_calls": [{"index": 0, "function": {"arguments": None}}]}, + ], +) +def test_invalid_delta_is_domain_error(delta): + """畸形输入不泄漏裸类型错误。""" + state = ChatCompletionAccumulator(response_mode="openai") + with pytest.raises(TransientError): + state.add_chunk({"choices": [{"index": 0, "delta": delta}]}) + + +def test_identity_repeat_does_not_refresh_progress(): + """身份只初次生效算进展;同值重复不能续命。""" + state = accumulate() + repeated = { + "choices": [ + {"index": 0, "delta": {"tool_calls": [{"index": 0, "id": "a", "type": "function"}]}} + ] + } + assert not state.add_chunk(repeated).progress + repeated["choices"][0]["delta"]["tool_calls"][0]["id"] = "other" + with pytest.raises(TransientError, match="冲突"): + state.add_chunk(repeated) + + +@pytest.mark.parametrize("delta", [{"role": "assistant"}, {}, {"content": ""}]) +def test_metadata_not_progress(delta): + """空帧和角色不算 token。""" + state = ChatCompletionAccumulator(response_mode="openai") + assert not state.add_chunk({"choices": [{"index": 0, "delta": delta}]}).progress + + +def test_nonstream_snapshot_and_length_preserved(): + """非流式扩展字段与参数原文独立保存,不修复 length 产物。""" + message = accumulate().finish().message + message["extension"] = {"nested": [1]} + message["tool_calls"][0]["function"]["arguments"] = '{"unfinished":' + original = copy.deepcopy(message) + result = parse_chat_completion( + { + "id": "id", + "choices": [{"message": message, "finish_reason": "length"}], + "usage": {"extra": 3}, + } + ) + message["extension"]["nested"].append(2) + assert result.message == original + assert result.finish_reason == "length" and result.usage == {"extra": 3} + + +@pytest.mark.parametrize("choices", [[], [{"index": 1, "message": {}}], [{}, {}]]) +def test_nonstream_single_choice(choices): + """不会静默取第一个 choice。""" + with pytest.raises(TransientError): + parse_chat_completion({"choices": choices}) + + +def test_explicit_empty_tools_remains_present(): + """显式空工具列表与缺省不同,不删除已接收字段。""" + state = ChatCompletionAccumulator(response_mode="openai") + state.add_chunk( + { + "choices": [ + {"index": 0, "delta": {"role": "assistant", "content": "ok", "tool_calls": []}} + ] + } + ) + assert state.finish().message["tool_calls"] == [] + + +def test_fragmented_name_and_arguments_with_usage(): + """名称/参数分片逐字拼接,usage 扩展原样快照。""" + state = ChatCompletionAccumulator(response_mode="openai") + state.add_chunk( + { + "id": "response", + "choices": [ + { + "index": 0, + "delta": { + "role": "assistant", + "tool_calls": [ + { + "index": 3, + "id": "call", + "type": "function", + "function": {"name": "ec", "arguments": " "}, + } + ], + }, + } + ], + } + ) + progress = state.add_chunk( + { + "choices": [ + { + "index": 0, + "delta": { + "content": "text", + "reasoning_content": "reason", + "tool_calls": [ + {"index": 3, "function": {"name": "ho", "arguments": "{} "}} + ], + }, + "finish_reason": "length", + } + ] + } + ) + assert ( + progress.content == "text" and progress.reasoning_content == "reason" and progress.progress + ) + usage = {"prompt_tokens": 7, "completion_tokens": 2, "extra": {"nested": 9}} + assert not state.add_chunk({"choices": [], "usage": usage}).progress + result = state.finish() + usage["extra"]["nested"] = 10 + assert result.message["tool_calls"][0]["function"] == {"name": "echo", "arguments": " {} "} + assert result.usage["extra"]["nested"] == 9 + assert result.finish_reason == "length" diff --git a/tests/unit/test_openai_fidelity.py b/tests/unit/test_openai_fidelity.py new file mode 100644 index 0000000..bc7e77f --- /dev/null +++ b/tests/unit/test_openai_fidelity.py @@ -0,0 +1,354 @@ +"""OpenAI 消息保真回归:按网关 Chat Completions 形态构造。""" + +import json + +import httpx +import pytest + +from polygateway.errors import AllSourcesExhausted, TransientError +from tests.unit.test_client import _client +from tests.unit.test_openai_compat import _complete, _source, _sse_stream, _transport_for +from tests.unit.test_openai_completion import accumulate, chunk, tool_frames + + +async def test_error_before_done_is_failure(): + """文本模式也不能接受 error 后的 DONE。""" + transport = _transport_for( + lambda _: _sse_stream(chunk({"content": "partial"}), 'data: {"error": null}\n\n') + ) + try: + with pytest.raises(TransientError): + await _complete(transport, _source()) + finally: + await transport.aclose() + + +async def test_mixed_content_reasoning_survive(): + """同一帧双文本不互斥。""" + transport = _transport_for( + lambda _: _sse_stream(chunk({"content": "answer", "reasoning_content": "reason"})) + ) + try: + result = await _complete(transport, _source()) + assert (result.content, result.thinking) == ("answer", "reason") + finally: + await transport.aclose() + + +async def test_tools_roundtrip_and_retry(): + """真实 transport 经过治理,失败重试不污染工具,消息可直接往返。""" + requests = [] + + def handler(request): + requests.append(json.loads(request.content)) + if len(requests) == 1: + return _sse_stream( + chunk({"content": "partial"}), + 'data: {"error": {"code": "upstream_stream_read_error"}}\n\n', + ) + return _sse_stream(*tool_frames()) + + client = _client(handler=handler) + try: + response = await client.chat( + [{"role": "user", "content": "hi"}], + response_mode="openai", + overlay={"reasoning_effort": "max"}, + ) + assert response.call_stats.attempts == 2 + message = response.completion.message + assert message["role"] == "assistant" and message["content"] is None + assert [t["id"] for t in message["tool_calls"]] == ["a", "b"] + assert message["tool_calls"][0]["function"]["arguments"] == '{"v": "中" }' + assert response.completion.response_id == "upstream-1" + assert response.completion.finish_reason == "tool_calls" + messages = [message, {"role": "tool", "tool_call_id": "a", "content": "ok"}] + await client.chat(messages, response_mode="openai") + assert requests[-1]["messages"] == messages + assert requests[0]["reasoning_effort"] == "max" + finally: + await client._transport.aclose() + + +@pytest.mark.parametrize("role", [None, "user"]) +async def test_final_role_required(role): + """非流式缺失或错误 role 必须协议失败。""" + message = {"content": "ok"} + if role: + message["role"] = role + client = _client( + handler=lambda _: httpx.Response(200, json={"choices": [{"message": message}]}) + ) + try: + with pytest.raises(AllSourcesExhausted): + await client.chat( + [{"role": "user", "content": "hi"}], stream=False, response_mode="openai" + ) + finally: + await client._transport.aclose() + + +@pytest.mark.parametrize("stream", [True, False]) +async def test_completion_cache_roundtrip(stream): + """完整结果回放,修改返回对象不毒化缓存;模式隔离。""" + from polygateway.backends.memory.cache import InMemoryCache + + completion = accumulate().finish() + + def handler(_): + if stream: + return _sse_stream(*tool_frames()) + return httpx.Response( + 200, + json={ + "id": "upstream-1", + "choices": [{"message": completion.message, "finish_reason": "tool_calls"}], + }, + ) + + client = _client( + handler=handler, cache=InMemoryCache(), cache_namespace="tools", cache_ttl_s=60 + ) + try: + first = await client.chat([], stream=stream, response_mode="openai") + second = await client.chat([], stream=stream, response_mode="openai") + assert not first.cache_hit and second.cache_hit + assert second.completion == first.completion + assert second.call_id != first.call_id and second.call_stats.attempts == 0 + second.completion.message["tool_calls"].clear() + third = await client.chat([], stream=stream, response_mode="openai") + assert len(third.completion.message["tool_calls"]) == 2 + finally: + await client._transport.aclose() + + +@pytest.mark.parametrize( + "kwargs", + [ + {"response_mode": "bad"}, + {"response_mode": "openai", "structured": "json"}, + {"response_mode": "openai", "overlay": {"n": 2}}, + {"response_mode": "openai", "overlay": {"n": True}}, + ], +) +async def test_invalid_mode_combinations_fail_before_http(kwargs): + """组合错误入口拒绝。""" + + def unexpected(_): + pytest.fail("不应发送 HTTP") + + client = _client(handler=unexpected) + try: + with pytest.raises(ValueError): + await client.chat([], **kwargs) + finally: + await client._transport.aclose() + + +async def test_openai_never_salvages(): + """已收完整工具也必须有 DONE。""" + transport = _transport_for(lambda _: _sse_stream(*tool_frames(), done=False)) + try: + with pytest.raises(TransientError, match="DONE"): + await transport.complete( + messages=[], + source=_source(missing_done="salvage"), + stream=True, + overlay={}, + call_id="c", + reasoning_effort=None, + first_token_event=None, + content_event=None, + response_mode="openai", + ) + finally: + await transport.aclose() + + +async def test_tool_progress_sets_events(): + """工具首进展驱动两个事件与时间观测。""" + import asyncio + + first, progress = asyncio.Event(), asyncio.Event() + transport = _transport_for(lambda _: _sse_stream(*tool_frames())) + try: + result = await transport.complete( + messages=[], + source=_source(), + stream=True, + overlay={}, + call_id="c", + reasoning_effort=None, + first_token_event=first, + content_event=progress, + response_mode="openai", + ) + assert first.is_set() and progress.is_set() + assert result.ttft_ms is not None and result.stall_count == 0 + finally: + await transport.aclose() + + +async def test_real_transport_stall_hedge_cancels_loser(): + """真实工具流沉默触发异源对冲,连接关闭且赢家不混入半成品。""" + import asyncio + import time + + from polygateway.sources import RoundRobinSelector + from polygateway.types import ChatRequest, _CallContext + from tests.unit.test_retry import _src + from tests.unit.test_stall_hedge import _harness + + entered, closed = asyncio.Event(), asyncio.Event() + + class Hanging(httpx.AsyncByteStream): + """工具首帧后阻塞,取消由真实 httpx 流处理。""" + + async def __aiter__(self): + yield tool_frames()[0].encode() + yield tool_frames()[1].encode() + entered.set() + await asyncio.Event().wait() + + async def aclose(self): + closed.set() + + count = 0 + + def handler(_): + nonlocal count + count += 1 + if count == 1: + return httpx.Response(200, stream=Hanging()) + return _sse_stream(*tool_frames()) + + transport = _transport_for(handler) + sources = [_src("s1"), _src("s2")] + mw, limiter, *_ = _harness( + sources, transport, stall_after_s=0.05, selector=RoundRobinSelector() + ) + context = _CallContext(now=time.monotonic) + try: + response = await asyncio.wait_for( + mw(ChatRequest(messages=[], response_mode="openai", call_context=context)), 3 + ) + assert entered.is_set() and closed.is_set() + assert response.source_name == "s2" and len(response.completion.message["tool_calls"]) == 2 + assert context.snapshot().hedges == 1 and context.snapshot().attempts == 2 + finally: + await transport.aclose() + + +@pytest.mark.parametrize("stream", [True, False]) +async def test_bad_usage_is_domain_failure(stream): + """坏 usage 不能穿透成裸 AttributeError,包括默认文本模式。""" + transport = _transport_for( + lambda _: ( + _sse_stream(chunk({"content": "ok"}), 'data: {"choices": [], "usage": [1]}\n\n') + if stream + else httpx.Response( + 200, json={"choices": [{"message": {"content": "ok"}}], "usage": [1]} + ) + ) + ) + try: + with pytest.raises(TransientError): + await _complete(transport, _source(), stream=stream) + finally: + await transport.aclose() + + +async def test_nonstream_tool_result_has_no_liveness_events(): + """非流式工具不制造流式观测。""" + import asyncio + + completion = accumulate().finish() + first, progress = asyncio.Event(), asyncio.Event() + transport = _transport_for( + lambda _: httpx.Response( + 200, json={"choices": [{"message": completion.message, "finish_reason": "tool_calls"}]} + ) + ) + try: + result = await transport.complete( + messages=[], + source=_source(), + stream=False, + overlay={}, + call_id="c", + reasoning_effort=None, + first_token_event=first, + content_event=progress, + response_mode="openai", + ) + assert result.completion.message == completion.message + assert not first.is_set() and not progress.is_set() + assert result.ttft_ms is None and result.stall_ms is None + finally: + await transport.aclose() + + +async def test_openai_does_not_bypass_managed_conflict(): + """保真模式不静默撤销源级受管意图。""" + from polygateway.errors import RequestRejectedError + + def unexpected(_): + pytest.fail("冲突请求不应发送 HTTP") + + transport = _transport_for(unexpected) + try: + with pytest.raises(RequestRejectedError): + await transport.complete( + messages=[], + source=_source(enable_thinking=True), + stream=True, + overlay={"reasoning_effort": "max"}, + call_id="c", + reasoning_effort=None, + first_token_event=None, + content_event=None, + response_mode="openai", + ) + finally: + await transport.aclose() + + +async def test_cancel_tool_stream_releases_resources(): + """显式取消真实工具流,连接关闭且配额归还。""" + import asyncio + + from polygateway.backends.memory.limiter import InMemoryLimiter + from polygateway.types import GlobalLimits + + entered, closed = asyncio.Event(), asyncio.Event() + + class Hanging(httpx.AsyncByteStream): + """可确定取消窗口的 HTTP 流。""" + + async def __aiter__(self): + for frame in tool_frames()[:2]: + yield frame.encode() + entered.set() + await asyncio.Event().wait() + + async def aclose(self): + closed.set() + + source = _source(max_concurrency=1) + limiter = InMemoryLimiter( + scope="llm", sources={source.name: source}, global_limits=GlobalLimits(1, 0, 0) + ) + client = _client( + sources=[source], limiter=limiter, handler=lambda _: httpx.Response(200, stream=Hanging()) + ) + task = asyncio.create_task(client.chat([], response_mode="openai")) + try: + await asyncio.wait_for(entered.wait(), 2) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert closed.is_set() + assert (await limiter.source_stats(source.name)).inflight == 0 + finally: + task.cancel() + await client._transport.aclose() diff --git a/tests/unit/test_package.py b/tests/unit/test_package.py index c927049..2125e52 100644 --- a/tests/unit/test_package.py +++ b/tests/unit/test_package.py @@ -88,3 +88,15 @@ def test_every_promised_export_is_actually_importable(): """ missing = [name for name in polygateway.__all__ if not hasattr(polygateway, name)] assert not missing, f"__all__ 承诺了但没绑上的符号: {missing}" + + +def test_apache_license_metadata(): + """许可正文和包元数据同时存在,不以版本说明替代许可。""" + import tomllib + from pathlib import Path + + root = Path(__file__).resolve().parents[2] + assert "Apache License" in (root / "LICENSE").read_text() + assert "Version 2.0" in (root / "LICENSE").read_text() + project = tomllib.loads((root / "pyproject.toml").read_text())["project"] + assert project["license"] == {"file": "LICENSE"} diff --git a/tests/unit/test_ports.py b/tests/unit/test_ports.py index 0102553..be90f5e 100644 --- a/tests/unit/test_ports.py +++ b/tests/unit/test_ports.py @@ -83,6 +83,7 @@ class _DummyTransport: reasoning_effort, first_token_event, content_event, + response_mode, ): raise NotImplementedError diff --git a/tests/unit/test_retry.py b/tests/unit/test_retry.py index 7c9f5b3..7f4297b 100644 --- a/tests/unit/test_retry.py +++ b/tests/unit/test_retry.py @@ -90,6 +90,7 @@ class FakeTransport: reasoning_effort, first_token_event, content_event, + response_mode, ): self.calls.append((source.name, call_id)) self.efforts.append(reasoning_effort) @@ -132,6 +133,7 @@ class _GenClockTransport: reasoning_effort, first_token_event, content_event, + response_mode, ): advance = self._advances.pop(0) stall = self._stalls.pop(0) if self._stalls is not None else None @@ -144,6 +146,7 @@ class _GenClockTransport: reasoning_effort=reasoning_effort, first_token_event=first_token_event, content_event=content_event, + response_mode=response_mode, ) if stall is not None: result = dataclasses.replace(result, stall_ms=stall[0], stall_count=stall[1]) diff --git a/tests/unit/test_stall_hedge.py b/tests/unit/test_stall_hedge.py index 85f17bc..059bff8 100644 --- a/tests/unit/test_stall_hedge.py +++ b/tests/unit/test_stall_hedge.py @@ -78,6 +78,7 @@ class StallHedgeTransport: reasoning_effort, first_token_event, content_event, + response_mode, ): self.calls.append(source.name) self.ft_events.append(first_token_event) @@ -271,9 +272,7 @@ class TestStallHedgeTrigger: """参数化四格(设计 §5.2 逐字): 两阈值独立生效,各格触发语义不串。""" # 格 1: 仅 AFTER_S + 流式 pre-TTFT 挂起 → AFTER_S 触发(1.3.7 语义不变) s1, s2 = _src("s1"), _src("s2") - transport = StallHedgeTransport( - {"s1": [("hang",), ("ok", "late")], "s2": [("ok", "fast")]} - ) + transport = StallHedgeTransport({"s1": [("hang",), ("ok", "late")], "s2": [("ok", "fast")]}) mw, _, _ = _harness([s1, s2], transport, hedge_after_s=_STALL_AFTER_S) ctx = _ctx() resp = await asyncio.wait_for(mw(_req(ctx=ctx)), timeout=5) @@ -312,9 +311,7 @@ class TestStallHedgeTrigger: # 格 3b: 两者皆设 + pre-TTFT 挂起 → 阶段 1 AFTER_S 触发(两阶段各管一段) s1, s2 = _src("s1"), _src("s2") - transport = StallHedgeTransport( - {"s1": [("hang",), ("ok", "late")], "s2": [("ok", "fast")]} - ) + transport = StallHedgeTransport({"s1": [("hang",), ("ok", "late")], "s2": [("ok", "fast")]}) mw, _, _ = _harness( [s1, s2], transport, hedge_after_s=_STALL_AFTER_S, stall_after_s=_STALL_AFTER_S ) @@ -340,9 +337,7 @@ class TestStallHedgeTrigger: async def test_stall_only_non_stream_degrades_to_single_leg(self): """仅 stall 已设 + `stream=False` → 逐字单路(中段沉默无非流式概念,入口门)。""" s1, s2 = _src("s1"), _src("s2") - transport = StallHedgeTransport( - {"s1": [("hang",), ("ok", "x")], "s2": [("ok", "other")]} - ) + transport = StallHedgeTransport({"s1": [("hang",), ("ok", "x")], "s2": [("ok", "other")]}) mw, _, _ = _harness([s1, s2], transport, stall_after_s=_STALL_AFTER_S) ctx = _ctx() task = asyncio.ensure_future(mw(_req(stream=False, ctx=ctx))) diff --git a/tests/unit/test_usage_source_domain.py b/tests/unit/test_usage_source_domain.py index 152e9fc..fb438dd 100644 --- a/tests/unit/test_usage_source_domain.py +++ b/tests/unit/test_usage_source_domain.py @@ -141,6 +141,7 @@ async def test_salvage_override_stays_in_domain(usage): reasoning_effort=None, first_token_event=None, content_event=None, + response_mode="text", ) assert result.usage_source in USAGE_SOURCES