AI 网关与模型路由实战

从流式协议到预算与故障恢复

Aias00

2026-10-21

前言:把一条模型请求变成可以解释的系统行为

一个应用第一次调用模型时,代码往往只有几十行:准备消息、发送请求、读出回答。接入第二个模型,开始有人要求“失败时自动切换”;接入第二个团队,又需要区分密钥、限额与账单。再加上长时间流式输出、用户取消和工具调用,原先的一次函数调用便有了协议、资源和责任边界。

这本书从这些边界出发。我们要回答的不是模型知道多少知识,而是:请求为什么被路由到这里,系统已经对外承诺了什么,失败后哪些动作还能做,付出了多少资源,以及维护者能拿出什么证据。

本书适合了解 HTTP、JSON 和基本编程的后端开发者。做过 API 网关、Java 服务或 Agent 的读者可以直接连接自己的经验;Python 只用于降低实验的启动成本,不要求读者把自己的生产服务改成 Python。涉及锁、并发和网络时,正文会先解释约束,再进入实现。

一条贯穿全书的请求

设想一家团队提供内部知识助手。租户 north 与 south 使用同一个入口,应用提交一段提示、期望的能力和输出上限。网关选择有对应能力的模型服务,把增量结果送回客户端,并在最后处理用量。我们给这条业务请求一个稳定标识,再给每次上游尝试单独编号。这样,“用户问了一次”与“上游被调用了几次”就能分别统计。

随书项目使用真实 HTTP 连接、SSE 事件和本地模型服务替身。替身能够拒绝请求、延迟、输出部分结果后断开,因此不需要购买模型额度,就能观察普通成功演示难以暴露的失败窗口。替身的计量单位是按实验合同定义的 UTF-8 字节 credit;它不是任何供应商的 tokenizer,也不能据此估算真实费用。

怎样阅读

第 1—4 章建立请求生命周期、协议、能力和路由模型;第 5—9 章讨论流、取消、尝试、租户与结算;第 10—12 章把安全、观测和验证放进同一套约束。第 13—15 章带着你运行实验并阅读证据,第 16—17 章讨论如何从教学实现走向自己的工程方案。

可以先运行第 13 章的演示,再回到前面的概念。代码附录从项目源码生成,便于离线阅读。正文中的伪代码负责表达规则;以实际仓库中的命令和测试作为运行入口。每章的练习都有明确检查目标,附录提供解题方向,避免只做术语记忆。

关于实现所能证明的事情

本地实验能够证明特定输入和故障注入下的程序行为,例如“下游已经收到事件后不再发起第二次尝试”。它不能证明真实供应商在断开连接后停止计算,也不能证明一个单进程锁在多节点部署中仍然有效。生产章节会明确需要替换的存储、身份、传输与运维机制。

本书的案例为可复现教学场景,不宣称来自某家公司的生产事故。正文所引协议与产品资料见来源附录;供应商接口和实现会变化,接入真实服务时应固定版本,重新验证能力与费用合同。希望读完以后,你不仅能让请求成功,还能解释它为什么成功、失败后留下什么,以及下一步应该由谁处理。

第 1 章:从用户请求到一次上游尝试

产品经理说“自动切换到备用模型”,通常是在描述一个业务愿望。实现者需要先补齐几个事实:原模型是否收到请求,是否已经执行,是否有结果发给用户,以及新模型是否允许承担同一个任务。没有这些事实,切换策略会把不确定性隐藏起来。

1.1 三种身份分别回答三个问题

业务任务标识回答“用户想完成哪件事”,网关请求标识回答“这次入口调用是什么”,尝试标识回答“哪个上游实际收到了哪次调用”。用户重新点击提交,可能产生新的网关请求;一次明确授权的故障切换,也可能产生第二次尝试。三者不能用同一个递增计数器含混替代。

例如,任务 task-42 的第一次请求 req-a 因网络状态未知而失败。客户端随后发送 req-b。若系统只用 req-a 判断重复,它无法知道两个请求属于同一个业务任务;若系统把 task-42 永远缓存为成功,又会错误吞掉用户修改输入后的新任务。需要去重的范围、保存期限和输入指纹必须一起定义。

本地实验聚焦单次请求,并用租户和 request_id 约束运行标识。它不提供完整的跨进程幂等结果存储。接入业务系统时,不能把一个内存集合当作持久任务账本。

1.2 两个方向的提交点

上游接受任务与下游收到输出,是两个不同提交点。上游可能已经开始生成,网络却还没有把第一个事件传回来;下游收到响应头,也不等于收到一个完整 SSE 事件。

已知事实 可以支持的判断 仍然不知道的事情
已把请求写入连接 本地发送步骤完成 上游是否接受和执行
收到上游响应头 上游返回了状态与头字段 后续事件是否完整
下游收到首个完整事件 用户已经观察到一部分结果 后续流能否正常结束
收到完整终止事件与可信用量 满足本次合同的结束条件 供应商账单是否另有调整

因此,状态模型至少需要区分未发起、已发送但结果未知、已向下游提交、完整结束和中断。把所有异常都归为 failed 会损失恢复决策所需的信息。

请求阶段与两个独立的终态维度

1.3 网关承担什么责任

入口网关适合统一身份、选择上游、管理资源、转换协议并记录证据。业务是否已经完成则需要业务验收。例如模型说“邮件已发送”,而工具系统没有相应记录,网关不能仅凭文字把邮件任务记为完成。

同样,网关允许某个模型使用 tools 能力,不代表模型输出的每个工具调用都已经获得执行授权。模型建议、应用决策和工具执行分别位于不同边界;把它们塞进一个“AI 成功率”指标,会让故障定位变得困难。

1.4 先写不变量,再选择框架

我们的第一个不变量是:一个请求已经向客户端提交增量输出后,网关不再用另一段从头生成的文本悄悄接续。第二个不变量是:用量信息缺失时,账本保留未知状态。第三个不变量是:租户身份来自认证,不能被请求正文中的任意字符串替换。

这些规则可以用任意语言和网关框架实现。框架选择会影响吞吐、部署和扩展方式,但不能替你决定“流中断以后预算怎么办”。先有行为合同,后面的配置和测试才有共同的判断依据。

练习。 客户端显示了一段回答,网关日志却只有一条 timeout。还应记录哪三项信息,才能决定是否可以恢复?提示:分别考虑下游提交状态、上游尝试身份和用量完整性。

第 2 章:把协议兼容写成可以测试的合同

两个接口都接受 JSON,不意味着它们表达同一件事。模型请求里的角色、内容块、工具定义和输出限制,都会影响任务语义。接入层如果默默忽略不认识的字段,得到的常常是“HTTP 成功,业务已经改变”。

2.1 保留原意比统一字段名更重要

假设入口允许调用者要求结构化输出。上游 A 能约束生成格式,上游 B 只能接受一段提示。把“必须符合 schema”改写成“请尽量输出 JSON”,是保证等级的变化。调用者应能选择拒绝、明确接受降级,或者把 B 从候选集合移除。

类似差异也发生在上下文限制、图片格式、工具参数、并行工具调用和流式用量事件上。能力合同需要区分支持、不支持、经验证的转换和未知。未知不能默认映射为支持。

项目 接入前要问的问题 失败时的合理出口
内容类型 文本、图片或其他内容块怎样编码? 请求校验错误
输出限制 限制指 token、字符、字节还是其他单位? 拒绝无法准确转换的限制
工具调用 参数什么时候完整,谁执行,怎样关联结果? 不执行未完成调用
用量 谁报告,何时报告,缺失如何标识? 保留未知用量

2.2 随书接口刻意保持独立

实验的入口是 POST /v1/generate,请求体包含 prompt、capabilities、max_output_units 和 request_id。身份放在 Bearer 凭证中。这里的路径和字段构成实验自己的协议;它不宣称完整兼容某家真实模型服务。

例如下面的对象表达输入意图,具体可运行调用由第 13 章的演示程序构造:

{
  "request_id": "example-001",
  "prompt": "解释一次请求的提交点",
  "capabilities": ["text"],
  "max_output_units": 128,
  "allow_failover": false
}

max_output_units 在实验中是字节 credit 上限,不能直接传给真实供应商的 token 限制字段。生产适配器应保存原始限制与转换策略,并把无法无损转换的情况暴露给调用者。

2.3 错误需要说明阶段

认证失败、能力不匹配、准入被拒绝、上游明确拒绝以及流中断,需要不同处理。流开始前,网关可以返回相应 HTTP 错误;流开始后,状态码已经无法重新写成另一个值。此时应按协议提供终止错误事件或断开,并在审计中记录不完整。

一次 HTTP 200 只能说明响应已按该状态开始。客户端还要检查业务终止条件。只检查状态码的监控,会把中途断开的流计为成功。

2.4 适配器的双向测试

请求转换测试验证入口语义是否被保存,响应转换测试验证事件边界是否被保存。可以给适配器一个固定输入,断言上游请求的必需字段;再给它一组分片字节,断言输出事件一致。把原始字节任意拆开之后,仍应得到同样的事件序列。

对于不能支持的能力,最重要的测试可能是“确实拒绝”。一个始终返回回答的适配器,反而可能掩盖契约丢失。

练习。 上游只报告最终总用量,不报告输入与输出拆分。入口希望按两种不同费率计费,应怎样处理?请区分展示一个总量、估算成本和确认结算三种行为。

第 3 章:为上游建立能力、信任和版本清单

路由配置里最醒目的字段通常是模型名。它方便展示,却不足以决定请求能否正确完成。同一个名称可能对应不同部署、区域、权限和版本;同样叫“支持工具”,也可能在并行调用、参数约束和流式形式上存在差异。

3.1 把候选资格与排序分开

先判断资格,再在合格候选里排序。资格约束包括租户授权、数据可发送的区域、协议能力、输入大小和当前可接纳状态。排序才考虑延迟、成本、偏好和权重。

如果把这些条件混成一个分数,低价格可能抵消“不允许传输到该区域”这种硬约束。一个不合格上游即使便宜十倍,也应该从候选集合中排除。

可以用如下伪代码表达次序:

candidates = inventory(snapshot_version)
eligible = candidates satisfying every required constraint
if eligible is empty: reject with a reason category
selected = rank(eligible, policy_version)
record selected identity and both versions

这段伪代码说明决策结构,并不冒充某个产品的配置文件。真实清单需要结合自己的密钥系统和能力验证机制实现。

3.2 能力清单也是证据清单

对于每项能力,至少记录验证对象、验证时间、协议版本和测试依据。供应商文档说某个模型支持图片,并不能证明你的账号、部署端点和适配器已经走通图片处理。一次成功调用也不能证明所有图片大小都可用。

清单可以采用“已验证支持、明确不支持、待验证”三种状态。验证失效之后,不必立刻删除上游,但应限制需要该保证的流量。这样能把“产品声称”与“本系统验证”分别保留。

3.3 信任范围决定哪些信号可以使用

随书替身会提供一项特殊保证:某种拒绝响应明确表示尚未开始处理。只有实验控制的替身才拥有这个合同。网关不能把互联网上任意 503 响应解释成同样的信号,更不能仅凭对方返回一个同名头字段,就允许它控制重试和账本。

真实接入需要定义信任根:固定端点、经过认证的连接、经过审核的适配器,以及该服务关于请求接受和幂等的正式合同。如果中间还有代理,头字段是否可被伪造或重写,也必须纳入判断。

3.4 配置变更需要一个稳定视图

请求开始时取到清单版本 v7,执行到一半切换成 v8,如果两个版本对同一个上游的能力解释不同,审计记录会很难还原。一个实用做法是让单次请求持有不可变配置快照,新的请求使用新版本。

快照不意味着忽略紧急吊销。密钥泄露或区域禁用可能需要立即阻止未发起的尝试。系统应把普通配置升级与紧急阻断分开:前者保持请求内一致性,后者优先终止风险,并明确记录终止原因。

练习。 两个上游都支持 text,其中一个支持结构化输出但尚未验证工具调用。请求同时要求这两项能力时,候选资格应怎样判定?请写出拒绝原因,而不是只返回“无可用模型”。

第 4 章:让路由决策可以解释,也可以回退

一个路由器返回“选择上游 B”很容易。维护者更需要知道:A 为什么没有入选,B 为什么排在 C 前面,策略升级是否改变了某类租户的结果。可解释性来自保留决策结构,而不是把完整提示词复制进日志。

4.1 从固定优先级开始

最初可以采用经过资格过滤的固定优先级。它容易复现,也便于建立基线。等到有足够的观测数据,再考虑按延迟、成本或质量分流。复杂算法无法补救缺失的能力清单和错误的延迟指标。

对于流式请求,“更快”至少包含首个有意义事件到达时间和完成时间。上游很快返回响应头,却长时间没有内容,不一定改善用户体验。排序指标应与实际目标一致,并写清窗口和样本范围。

4.2 一次决策应留下哪些信息

推荐保留请求标识、租户的内部标识、策略版本、清单版本、约束类别、候选排除原因、选中上游和尝试编号。排除原因可以是 capability_missing、tenant_denied 或 capacity_unavailable 这类受控枚举。

提示正文、密钥和工具凭证不属于默认决策日志。确需排查内容问题时,应使用经过授权、脱敏和保留期限约束的诊断渠道。

路由记录有时比成功回答更能解释问题。比如主上游始终没有收到流量,可能并不是熔断器坏了,而是新版本将它的 text 能力误写为未知,导致资格阶段提前排除。

4.3 灰度要同时考虑质量与资源

将 5% 请求交给新模型,不能只观察平均延迟。还要检查错误率、完整结束率、用量缺失率、预算占用和目标任务的质量。若新模型偶尔生成很长的输出,低比例流量也可能占据很大比例的资源。

影子请求会产生额外调用和数据传输,应有明确授权和预算。对于包含工具或写操作的任务,影子执行还可能产生业务副作用;只复制 HTTP 请求并不是一个安全的验证方案。

4.4 回退策略与恢复动作分开

配置回退表示后续请求重新使用旧策略,不会自动撤销已经生成的内容,也不会抹去已经发生的调用成本。进行中的请求应该继续持有原快照,或按明确规则终止。两种策略都应在变更记录中说明。

本地实验的固定候选与能力判断用于验证资格和尝试边界。它没有实现动态控制平面、质量评分或灰度平台。你可以在课程项目中增加这些功能,但应先保存策略版本并写出回归用例,再让策略自动更新。

练习。 新模型的平均延迟下降,但一半流缺少最终用量事件。是否应该扩大灰度?请给出至少三个待检查指标,以及一个可以回退的客观触发条件。

第 5 章:流式输出是一串有边界的事件

当屏幕上的文字逐步出现时,很容易把一条网络读取结果当作一个 token 或一条消息。实际上,TCP 提供字节流,HTTP 客户端的读取边界也不是 SSE 事件边界。一段中文可能被拆在两个读取结果里,两个事件也可能一次到达。

5.1 从字节到事件需要两层缓冲

需要保留跨网络分片的 UTF-8 字节,再按 SSE 的行和空行规则组装事件。可以采用增量解码器,也可以像实验一样,先在有界字节缓冲中找到完整行,再解码该行。不能对每个网络分片单独调用无状态的字符串解码,然后用替换字符掩盖错误。那样可能在网络健康时就损坏内容。

WHATWG 的 SSE 格式允许多个 data 行构成一个事件;注释行、事件类型与事件标识也有独立含义。一个只寻找 data: 并截取当前分片的解析器,通常经不起分片、换行和多行数据测试。

本书实验通过 POST 发起生成,再由自己的客户端解析响应流。浏览器原生 EventSource 的建连方式和请求限制与这个接口不同。使用 SSE 编码,并不自动获得 EventSource 的重连和 Last-Event-ID 语义;接入浏览器时要选择与认证、请求体和恢复合同匹配的客户端。

5.2 完整事件才进入下一步

实验按 token、usage、done 的逻辑组织流。这里的 token 事件只是增量文本事件的名称,不代表真实 tokenizer 的一个 token。客户端要累积事件,并检查最后是否存在符合合同的结束标志。

假设上游发送以下逻辑序列:

event: token
data: {"text":"你好"}

event: usage
data: {"contract":"utf8-bytes-v1", "input_units":3, "output_units":6}

event: done
data: {}

这是帮助理解事件角色的示意。usage 中的输入值取决于实际 prompt 的 UTF-8 字节数,不能把这组数直接用来替代运行记录。网关要先验证输入量、已转发输出量和计量合同,再决定是否接受结算。

5.3 提交以后,错误的表达方式改变

网关一旦把事件发给客户端,就已经改变了用户看到的状态。上游在此之后中断,客户端应保留“已显示部分结果但未完成”的事实。用备用模型从头生成再拼到后面,会让用户看到重复或矛盾内容。

如果业务允许用户明确点击“重新生成”,新请求可以作为新的操作发起。界面应标明先前结果不完整,审计应保留两次调用。把重新生成作为显式业务行为,比在传输层偷偷拼接更容易解释和计费。

5.4 慢客户端会把内存变成队列

上游每秒产生大量内容,而下游缓慢读取。如果中间使用无界队列,网关内存会随持续时间增长。有界队列让生产者在缓冲满时等待,进而减少上游读取;连接和操作系统缓冲也有容量,这些层次共同决定资源占用。

有界队列只证明本地该队列不会无限增长,不能证明供应商已经停止生成或计费。生产系统还需设定最大事件尺寸、总输出量、写入超时和慢连接处理策略。按“事件个数”限制队列时,单个巨大事件仍可能占据大量内存,应另加字节上限。

练习。 将一个包含中文的 SSE 事件在每一个可能的字节位置切成两段。解析后应该得到什么?如果测试只使用英文,它可能漏掉哪种错误?

第 6 章:超时、取消和业务撤销是三件事

用户关闭页面后,入口连接消失了。服务器是否立刻知道,取决于连接状态和下一次读写;上游是否停止计算,还取决于它的实现。即使所有连接都关闭,已经执行的工具动作和已经产生的费用也不会自动消失。

6.1 用期限管理一整次请求

只设置单次读取超时,可能让一条每隔几秒发送一个字节的流持续占据资源。只设置总超时,又可能让正常长回答被粗暴切断。实用的时间策略通常同时包含建连期限、首事件期限、读取空闲期限、写入期限和请求总期限。

这些期限不是可以直接相加的独立预算。假设入口最多允许 30 秒,路由和等待已用去 8 秒,后续尝试就不能重新获得完整 30 秒。应以单调时钟记录剩余时间,向下游依赖传播或转换,而不是每层重置一个新的计时器。

Envoy 的 route timeout、idle timeout 和 per-try timeout 有不同作用。接入时应阅读所用版本的文档,确认计时起点与是否覆盖流式场景。照搬一个名为 timeout 的配置值,不足以说明整个链路的行为。

6.2 客户端退出是一种观测,不是一份结算单

服务器发现写入失败,能说明这条连接无法继续按原方式交付。它不能据此判断客户端到底看到了多少内容,也不能把实际用量改成零。对于可能计费的上游,缺少可靠终止记录时仍需保留待核查状态。

有些系统支持显式取消接口。它使客户端不用等待操作系统发现断开,就能表达取消意图。取消接口同样需要认证,并验证调用者有权取消该运行,防止一个租户使用猜到的标识终止另一个租户的任务。

6.3 实验中的取消范围

随书网关提供 DELETE /v1/runs/<request_id>。它按租户识别运行,将取消意图传给工作线程,并尝试关闭上游连接。本地替身会观察连接终止,测试能检查它在受控窗口内停止发送。

这个证据说明实验里的取消传播生效。它不等于远程供应商退款,也不等于工具系统回滚。接入真实服务时,需要确认是否存在供应商取消 API、取消请求是否被接受,以及取消后仍可能产生哪些费用。

6.4 释放资源要按类别分别处理

运行停止后,并发槽位应有可靠的 finally 清理路径;连接和线程也要退出。预算则取决于处理证据。明确尚未执行的拒绝可以释放预留,执行状态未知的中断不能沿用同一个释放规则。

资源或状态 停止请求后的处理依据
本地并发槽位 本地工作确实结束,且只释放一次
本地网络连接 已关闭或进入受控清理路径
预算预留 可信用量、明确未开始,或进入未知核查
业务副作用 外部业务记录与补偿协议

练习。 取消请求与上游 done 事件几乎同时到达。你会使用哪个受保护状态作为结算依据?请写出一个能接受的线性化顺序,并说明怎样避免重复释放。

第 7 章:重试之前,先判断自己知道什么

“没有看到回答”不是“没有发生执行”的证据。请求可能已经被上游接收,只是响应在网络中丢失;模型服务可能已消耗资源,工具也可能已经产生外部动作。重试策略首先是语义与授权问题,其次才是次数和退避算法。

7.1 HTTP 方法对自动重试有约束

RFC 9110 第 9.2.2 节定义幂等方法,并对自动重试提出限制,其中明确禁止代理自动重试非幂等请求。不能因为接口路径中有 generate,就把任意 POST 当作可安全重放。

应用可以定义有明确去重、接受确认和操作合同的编排协议,但那需要端到端保证。单纯收到 503、连接断开或者尚未看到 token,都不能构成该保证。请求体中的一个 request_id 也只有在上游实际据此去重时才有用。

7.2 本地演示为何允许一个受限分支

实验默认 allow_failover=false。调用者显式允许切换,路由配置又把该替身列入可信清单,且替身返回 HTTP 503 和私有信号 X-Lab-Execution: not-started 时,编排器才允许在受限次数内选择下一个合格上游。

这里的信号由本地受控替身产生,表达“该实验操作尚未开始”。这是教学应用协议的一部分,不是通用 HTTP 503 的含义。未列入信任范围的服务即使返回同名字段,也不应获得同样待遇。

情况 实验的动作 原因
未显式允许切换 不发起第二次尝试 调用者没有授权
可信替身明确未开始,且满足授权 最多进入一次后续尝试 有受控合同支撑
普通 503 不透明切换 无法确认是否已执行
发送后超时,尚无事件 保留未知,不重放 没有输出不等于没执行
已发出事件后断流 标记不完整,不拼接另一段生成 下游已观察到结果

7.3 幂等不等于零成本

一个真正的幂等接口保证重复操作的预期效果与执行一次相同,并不自动保证每次尝试都不占用资源。去重记录可能有过期时间,身份与参数也可能影响去重范围。设计重试时要写明这些条件,并保留每次尝试的成本证据。

如果上游支持幂等键,网关需要验证键的作用域、参数冲突行为、保留期限,以及流式结果是否可以重取。不同上游之间通常没有共享去重账本,不能直接把同一个键发给 B,就认为 A 与 B 的两次执行会被统一合并。

7.4 次数、期限与拥塞共同限制尝试

重试需要同时受总次数、总期限和资源预算约束。退避与抖动用于分散请求恢复时刻,但不能把本来不安全的操作变成安全操作。熔断器也只是根据故障信号限制流量,不会撤销已执行的任务。

实验为便于观察,只设置很小的尝试上限,没有实现完整的生产退避调度器。生产方案应避免每层各自重试造成次数相乘,并把上游重试、SDK 重试和业务重试一起纳入尝试审计。

练习。 网关最多两次,SDK 最多三次,业务任务又重跑两次。在没有共享尝试预算时,可能产生多少次上游调用?这个数字为什么仍然不能单独描述业务副作用风险?

第 8 章:先获得准入,再消耗共享资源

一个租户同时发来几百条长回答请求,会占据连接、线程和上游额度。请求次数限流只能控制其中一个维度;不同长度的提示和输出,成本可能差很多。准入层需要把身份、并发和预算放到同一个决策流程。

8.1 身份决定归属,正文表达需求

随书接口用 Bearer 凭证确定租户。请求正文可以描述 prompt 和能力需求,但不能自己声明“把费用记到另一个租户”。入口若接受客户端提交的 tenant_id,应把它当作需要授权校验的目标,而非已经可信的身份。

密钥映射和权限检查要在实际调用上游前完成。认证错误不应启动模型请求;权限拒绝也不应通过错误详情泄露其他租户的资源名称与余额。

8.2 准入检查需要原子性

先检查“余额足够”,释放锁,随后才扣预留,会留下竞争窗口。两个请求可能同时看到相同余额,然后都通过。并发槽位也有同样问题。

在单进程实验里,可以把检查与状态变化放在同一个受保护临界区。多进程部署需要数据库事务、原子存储操作或其他能够提供相应保证的机制。把 Python 锁替换为另一个机器上的锁,不能自动得到持久化与故障恢复能力。

一个概念流程如下:

验证凭证与输入
计算本次预留
在同一准入事务中:检查重复标识、并发和可用预算,再建立运行
事务成功后才联系上游
停止运行时,按结束证据分别处理并发与预算

8.3 拒绝通常比无限排队更诚实

队列不是免费容量。排队请求仍保存上下文并占据等待预算;客户端超时后,它可能已经失去继续执行的价值。若要排队,需要限定长度、最长等待时间、租户公平性和取消行为。

实验采用明确的并发限制和拒绝策略,没有实现公平排队器。这样可以直接检查“准入失败没有联系上游”。生产系统可以增加排队,但应把等待时间记入总期限,也要验证一个大租户不会长时间挤占所有位置。

8.4 重复标识的范围需要说明

request_id 适合关联日志和防止同一租户重复提交同一个实验运行。它不能天然替代业务任务标识或供应商幂等键。一个 ID 被拒绝为重复,也不表示先前任务已经成功,调用者仍需要查询原任务状态。

单进程保存的 ID 与运行记录需要容量和保留策略。教学服务运行时间短,记录保存在内存里;生产实现要处理进程重启、历史结果查询和记录清理,避免“永远保留”导致内存增长,或“过早删除”导致重复执行。

练习。 租户最大并发为 2,第一条请求已输出但断流,用量未知,本地线程已经结束。它应该继续占据并发槽位吗?预算预留呢?请分别回答并说明依据。

第 9 章:用量未知,也是一种需要保存的状态

预算系统最危险的错误之一,是把“没有收到用量”解释成“没有用量”。另一个常见错误是把本地估算写成供应商确认值。前者会提前放出额度,后者会让账单看似精确,却无法对账。

9.1 预留与结算各自解决一个问题

预留限制新请求能够承诺多少资源;结算记录已经发生的可信消耗。可以把一个租户的额度写成以下关系:

可用于新请求的额度 = 总额度 - 已结算消耗 - 尚未解除的预留

这是一种容量账本模型,不是会计准则或所有供应商的计费模型。退款、阶梯价格、缓存计量、免费额度等需要自己的费用合同,不能偷偷塞入同一个数字。

9.2 实验的 credit 合同

实验定义一 credit 等于一字节 UTF-8 文本,计量合同名为 utf8-bytes-v1。输入预留取 prompt 编码后的字节数,再加 max_output_units。JSON 与 SSE 封装字节不进入这个教学计量。

例如 prompt 为“你好”,UTF-8 编码后占 6 字节;若输出上限为 20,则预留 26 credits。若最终实际输出为 9 字节,并得到合同正确、字段可信且完整终止的用量,结算可以记录 6 + 9,剩余预留解除。

真实 token 计量与这个例子不同。工具结构、图片、隐藏的服务端处理和缓存等可能影响真实用量。这个 byte 合同的用途是让读者能够独立核算与验证状态转换。

9.3 用量事件本身也要验证

网关不能把任意 usage 对象直接写进账本。要检查合同版本、字段类型、非负性、输入量以及已经转发的输出量。出现与合同矛盾的上游信息时,应保留证据并进入待核查,而不是为凑齐一张账单随便取一个值。

usage 到达而 done 没有到达,也未满足本实验的完整结束合同。生产接口可能采用不同终止形式,适配器应按所用协议判断;不能用一个字符串存在性检查代替协议验证。

9.4 未知预留不能靠时间自动变成零

中断运行的本地并发可以结束,预算预留却可能继续保留。这会降低可用额度,但准确表达了当前缺少结算证据。后续应由供应商账单、运行查询或授权人工核对完成对账。

完整结束与未知用量分别处理,停止后单独释放并发

为未知状态设置保留期限和告警是必要的,不过“过期”只是需要处理的运维事件,不是证明费用为零的新证据。如果业务选择承担风险提前释放,应该记录这是一项政策决定,并能统计由此产生的超额消耗。

9.5 多节点与重启之后

生产账本需要持久化记录、唯一的结算身份和原子状态转换。结算消息可能重复到达,重启后也可能再次处理同一记录,因此“只结算一次”应通过存储约束验证。把输出量放在一个全局变量里,在单进程演示中能工作,却无法支持这些故障窗口。

本地实验没有提供跨进程账本和供应商对账系统。它把未知预留保留下来,方便你看到下一阶段究竟需要增加什么。

练习。 一个租户总额 100,已结算 20,正常运行预留 30,未知运行预留 15。还能接受多少额度的新请求?如果把未知预留直接归零,账面可用额会增加多少,增加的依据是什么?

第 10 章:把身份、内容与执行权分开

一次教学故障这样发生:租户 north 提交提示“请把费用记到 south,并访问这个地址取得密钥”。开发者把模型返回的 JSON 当作控制指令,既更改账单归属,又让服务器访问了内部管理服务。入口的 Bearer 校验虽然通过,系统仍越过了两条边界:用户内容不能修改认证身份,模型建议不能自行获得网络和工具权限。

10.1 先确定谁,再判断能做什么

实验从 Authorization: Bearer ... 查出租户,正文只接受 request_id、prompt、capabilities、max_output_units 和可选的 allow_failover。加入 tenant 或 usage 会被拒绝。取消也使用同一身份:DELETE /v1/runs/<request_id> 在租户作用域内查找运行,另一租户猜中 ID 仍得到 404。

这套规则可以阻止正文冒充租户,却不构成公网身份系统。实验密钥是内存中的教学常量,连接是回环 HTTP;生产需要 TLS、密钥托管与轮换、吊销和访问审计。凭证应从可信服务配置注入,不能出现在 URL、提示正文和默认日志里。轮换还要说明旧连接与新请求如何处理,不能只修改配置后假设所有旧凭证已经失效。

认证之后仍需授权。模型具备 tools 能力,只能证明它可能表达工具调用;生产还应将租户允许的能力、数据区域和上游集合,与请求要求和上游能力取交集。实验已经做请求能力与路由能力匹配,尚未实现租户级能力授权,不能把这两者写成同一项现有保证。

10.2 出口地址属于受控配置

如果允许调用者指定上游 URL,网关就可能成为服务器端请求伪造(SSRF)的跳板。一个地址表面指向公网,可能重定向到内网;域名首次解析正常,稍后却解析为本机或云元数据地址。只搜索字符串里是否含有 localhost,覆盖不了这些变化。

可实施的设计是让请求选择受权路由别名,管理端审核固定的协议、主机、端口与凭证归属;网络出口再限制可达目标。解析结果及实际连接目标都要符合策略,重定向关闭或逐跳重新授权,内网与元数据目的地按业务需求明确拒绝。不要把原请求的 Authorization 转发到任意目标。

实验的 Route 只接受带端口的 http://127.0.0.1,目的就是把模型替身留在本机。这个限制允许访问本机端口,因此不是生产 SSRF 防护的完整实现,也不能直接扩成“允许任意 HTTPS”。生产出口策略、DNS 与连接校验需要另写并测试。

10.3 工具执行需要另一份授权

提示注入可能来自用户、检索文档,也可能来自工具结果。网关可以控制数据被发往哪里,却不能靠一条系统提示消除所有内容攻击。执行器必须把模型输出当作待审查数据:累计完整参数后做 schema 校验,再检查租户、目标资源和操作权限;写入、发信等动作需要业务批准与幂等记录。

本实验没有工具执行器。即便配置一个名为 tools 的能力,也不会自动产生隔离沙箱或审批流程。真实系统应给工具最小权限,分别限制文件、网络与运行时间;取消模型连接也不意味着已经发送的邮件可以撤销。Anthropic 的流文档说明工具参数 delta 可能尚非完整 JSON,不能把每个片段直接交给执行器,见来源 S07。

10.4 “尚未开始”也是需要认证的证据

另一个故障例是恶意上游返回 503,同时伪造 X-Lab-Execution: not-started。若网关一见这个头就释放预留并切换,已执行的费用会被隐藏,还可能触发第二次业务动作。

实验的受控切换要求调用者显式 allow_failover=true,路由配置 trust_not_started=True,并收到可信替身的 503 与该私有头。信任来自管理配置及服务合同,不能由响应自我授予。普通 503、没有首事件或生成文本中出现“未开始”,都不能替代证据;流中断仍保持 unknown 预留。RFC 9110 对非幂等自动重试的限制另见 S01。

10.5 把边界变成检查结果

在仓库根目录运行下面的现有测试,分别检查租户隔离、正文越权字段与不可信上游信号;测试使用真实本地 HTTP:

PYTHONPATH=tests python3 -m unittest \
  test_integration.GatewayHTTPTests.test_cancellation_is_tenant_scoped_and_propagates_upstream \
  test_integration.GatewayHTTPTests.test_client_cannot_set_usage_or_tenant \
  test_integration.GatewayHTTPTests.test_untrusted_route_cannot_declare_safe_replay

通过只证明这些受控场景成立。练习: 为“请求声明 image,但租户只获准 text”写出生产授权判定顺序,并列出一个拒绝时不得联系上游的断言。再为重定向到内网的目标设计出口测试;指出两项尚需新增的实现,避免把练习结果当成现有能力。

第 11 章:让成功率、延迟与成本说同一件事

教学演练里,面板显示 HTTP 成功率 100%,用户却反馈答案经常停在半句。排查发现监控在发送 200 时就计成功,后续 SSE 断流没有进入分母;与此同时,第二次尝试被计成另一位“用户”。漂亮的数字来自口径错误,无法帮助值班者决定是否回退。

11.1 先写分母,再画图

入口调用、获得准入的请求、上游尝试和业务任务是四组对象。401 没有租户身份,能力不匹配也没有进入账本;如果只统计 admitted 日志,就看不到这些拒绝。建议先定义以下教学指标,生产实现时再映射到指标系统:

指标 分子与分母
准入比例 获准请求数 / 通过身份与输入校验的请求数
完整结束比例 收到有效 usage 与 done 并完成交付的请求数 / 获准请求数
尝试放大系数 上游尝试数 / 获准请求数
未知用量比例 终态为 unknown 的请求数 / 已结束的获准请求数

再单独统计未认证调用、输入错误、预算拒绝和并发拒绝。不要把被拒请求算进上游成功率,也不要从分母删除已准入的超时。窗口应固定,进行中的请求单列;否则窗口末尾刚到达的长流会暂时拉低“完成率”。跨窗口分析可以按准入批次追踪到终态。

例如 10 个获准请求,8 个完整结束,2 个中断,其中一个经可信未开始拒绝后切换,产生共 11 次尝试。请求完整结束比例为 80%,尝试放大系数为 1.1;仅凭这些数据不能算各上游的成功率,还需要每次尝试的结果。没有业务验收记录,也不能称作任务成功率。

11.2 首事件与完成各有计时点

选择入口收到合法请求的单调时刻 t0,再记录首个完整上游协议事件 t1、首个正文事件 t2、本地完成最后写入 t3,以及清理完成 t4。由此分别得到首事件等待、首正文等待、交付完成和资源释放耗时。每次尝试另有自己的开始时刻,排队与前次尝试仍计入请求总耗时。

响应头不是正文,心跳也未必代表业务前进。取消或断流没有成功完成延迟,应记录终止耗时与原因;只把成功样本放入完成延迟直方图时,面板必须同时显示中断比例,防止“慢请求都失败了,所以延迟变快”。本地写入完成也不能证明用户已经阅读或远程应用已持久保存。

OpenTelemetry 的 GenAI 文档提供模型、usage 和首块观测语义,但来源 S06 固定读取的版本仍标为 Development。接入时应固定版本并说明映射;不要把本地首正文耗时直接改名成标准首块字段。实验 audit 的墙上时间用于事件关联,不是上述单调计时实现,目前也没有 OTel 导出器。

11.3 用关联记录还原一次中断

可以沿 (tenant, request_id) 找到 admitted、带 route 与 attempt 的 route_attempt、可能的 route_failed,以及 finished。这些记录解释调用是否准入、联系过谁、结束原因和 usage_state。单次尝试编号保留在日志或 trace 中,适合排查具体请求。

指标标签则使用受控的路由、结果和原因枚举。request_id、原始 URL、任意错误文本会制造无界维度,不宜成为时间序列标签。按租户统计也要有容量与访问控制,避免数十万租户创建无限序列,或让普通调用者看见别人的用量。

实验的 Audit 使用字段白名单,保存的是内存记录;没有记录所有入口拒绝,也没有每次尝试的完整成功终态。因此可以用它检查教学过程,不能宣称现成支持上述全套生产指标。

11.4 内容默认不进遥测

排查流断开通常需要阶段、字节 credit、队列峰值、取消传播和错误分类,不需要完整 prompt。实验也不记录输出正文、Authorization 或上游凭证。任意异常字符串可能携带 URL 或密钥,应转为受控原因码,而非直接把整个请求和异常对象序列化。

内容诊断若确有必要,应另设授权入口,限制采样、脱敏、访问与保留时间;低采样率仍可能采到秘密,不能代替这些控制。实验 credit 是 UTF-8 字节,图表必须注明单位;真实 token 与费用需要适配器和供应商证据,缺失 usage 保留未知,不填零改善面板。

11.5 从症状推导告警

若 HTTP 200 稳定,而 unknown 预留金额和中断比例上升,应先检查终止事件、协议适配与上游断连;不能直接提高预算掩盖积压。若首事件正常、完成变慢且 backpressure_waits 上升,则检查慢客户端与缓冲。告警应附路由版本、受影响请求范围和少量可访问的关联 ID,门槛由自己的基线决定。

现有脱敏行为可执行验证:

PYTHONPATH=tests python3 -m unittest \
  test_integration.GatewayHTTPTests.test_audit_is_useful_without_prompt_tokens_or_keys

练习: 按本章口径计算“20 次入口调用、2 次认证失败、3 次预算拒绝、15 次准入、12 次完整结束、3 次中断、18 次尝试”的四个指标。先声明其余身份与输入校验均通过,再设计一个不会把 request_id 放进指标标签的故障查询流程。

第 12 章:用故障矩阵验证合同

一个正常演示只能证明某条成功路径走通。网关真正的工程难点往往发生在两个动作之间:上游已经开始而客户端尚未收到内容,usage 已到达而 done 尚未到达,取消到达而结算也正在完成。测试需要把这些窗口变成可控输入。

12.1 把测试对象拆成三个层次

协议层测试解析规则,例如 UTF-8 分片、多行 data 和未完成事件。策略层测试资格与预算,例如能力不匹配时不联系上游。集成层通过真实 HTTP 连接观察响应和上游调用次数,验证异常路径没有被函数替身隐藏。

三层互相补充。只有策略函数测试,无法发现写入超时后的资源清理问题;只有端到端成功演示,又很难定位一个多行事件为何解析错误。随书实验用本地服务替身控制故障,仍然经过 HTTP、SSE 和客户端。

12.2 矩阵中的每一行要有证据

注入情况 应保持的规则 至少检查的证据
请求能力无候选支持 不启动不合格上游 上游接收次数为零
默认请求收到未开始拒绝 不自行获得切换授权 后备上游接收次数为零
显式授权且可信未开始拒绝 尝试受限、结果可解释 两个尝试记录与完整结果
普通 503 或发送后超时 不把不确定变成安全重放 后备次数为零、未知用量
输出部分文字后断开 不拼接新的生成 客户端部分内容、终止状态与账本
usage 缺失或不符合合同 不确认结算 预留仍在,且有异常分类
另一租户请求取消 不影响目标运行 拒绝结果、原运行继续
慢消费者 缓冲受限且有停止路径 队列观测、写入超时、线程退出

检查一个日志里的 success 字符串,不足以证明这些规则。上游次数应来自替身的独立计数;账本应读取实际保存的状态;输出应由客户端解析。这样能减少“实现和测试重复同一个错误”的风险。

12.3 用同步条件定位窗口

并发测试若大量依赖固定 sleep,会在不同机器上变得脆弱。更好的方法是等待明确条件:替身已经接受请求、首个事件已被客户端读取、账本记录已建立,然后注入下一步动作。

等待本身也应有上限,并在失败时输出目前状态。否则一个测试会因后台线程阻塞而永久挂住,CI 看起来像网络故障,却没有定位信息。

慢消费者测试尤其要谨慎。操作系统缓冲可能先容纳一段输出,让测试误以为已经证明应用背压。实验需要足够的数据量、受限的应用队列、受控读速率和可观察停止条件,同时承认缓冲区大小与调度会影响时间。

12.4 回归报告描述行为而非排名

本书测试没有比较真实模型质量,也没有进行供应商吞吐排名。它验证确定性宿主行为和特定故障窗口。真实模型评测需要额外的数据集、任务验收与重复试验,不能把替身测试通过率写成模型可靠性。

保存运行记录时,至少带上代码版本、Python 版本、命令、测试名称、结果和失败输出。变更后重新运行受影响的测试;发布前运行整套,并实际打开生成的在线页与 EPUB。静态文档与测试各有范围,谁都不能替代另一个。

练习。 把“已经输出以后不重试”改写成一个测试规范。至少列出上游计划、客户端观察、后备调用次数和账本终态,避免仅断言某个内部函数没有被调用。

第 13 章:先运行一条完整的 HTTP 链路

这一章先不连接真实模型。我们需要看清三个独立角色:客户端发送请求并解析 SSE,网关决定接纳、路由和记账,上游替身通过真实 HTTP 返回内容与故障。替身提供确定的行为,连接、分块、断开和并发仍由真实网络栈处理。因此,这不是把路由函数调用一次便宣称集成完成。

13.1 从仓库根目录开始

实验只使用 Python 标准库,代码要求 Python 3.10 或更新版本,不需要模型密钥、SDK 或外网。先确认当前目录同时包含 lab/ 和 tests/。本版记录的开发机运行环境是 Python 3.14;支持范围还应以你实际运行的版本和 CI 记录为准。

python3 --version
python3 -m unittest discover -s tests -v
python3 -m lab.demo --evidence lab/evidence/demo.json

测试会自己选择空闲的环回端口,并在结束时关闭服务。不要把文件直接当作脚本执行,例如 python3 lab/demo.py;它使用包内相对导入,应从仓库根目录用 -m lab.demo 运行。

所有监听都限制在 127.0.0.1。这个选择让实验服务留在本机,并不为同机其他用户建立生产级信任边界;请保持公开测试凭据与真实业务凭据分开。

完整 demo 将五组实验串起来:可信且获授权的切换、输出后断流、取消和并发、慢消费者、审计字段。lab/evidence/demo.json 是这一次运行保存的结构化记录。它不是从书里复制出来的预期答案;每组都有断言,未满足观察条件时命令会以错误结束。

下面节选自本版实际运行结果,省略了时间、余额与其他字段:

{
  "name": "authorized_safe_failover",
  "http_status": 200,
  "events": ["token", "token", "usage", "done"],
  "upstream_operations": [0, 1],
  "accounting": {
    "reservation_units": 102,
    "actual_units": 13,
    "usage_state": "settled",
    "outcome": "completed",
    "active": false
  }
}

这里的 [0, 1] 由两个上游服务自己的执行计数产生。它说明第一个服务没有开始,第二个服务执行了一次,不能解释成两个真实模型的质量或性能比较。

13.2 用 curl 观察事件边界

要逐行观察流,先在一个终端保留服务:

python3 -m lab.demo --serve --port 8088

另开终端发送请求。tenant-a-secret 是仓库中公开的实验凭据,不应替换成真实供应商密钥:

curl -N http://127.0.0.1:8088/v1/generate \
  -H 'Authorization: Bearer tenant-a-secret' \
  -H 'Content-Type: application/json' \
  -d '{"request_id":"first","prompt":"hi","capabilities":["text"],"max_output_units":100}'

实际响应如下。事件之间的空行参与协议解析,不只是排版:

event: token
data: {"text":"hello"}

event: token
data: {"text":" gateway"}

event: usage
data: {"contract":"utf8-bytes-v1","input_units":2,"output_units":13}

event: done
data: {}

这个交互模式使用正常文本替身;它与完整 demo 的故障计划不同,所以两处实际输出量并不相同。hi 占 2 个 UTF-8 字节,hello gateway 占 13 个,完整使用量为 15 credits。100 是允许的输出上限,而不是已经发生的消耗。

按 Ctrl-C 退出服务时,终端会显示审计 JSONL。可以看到 admitted、route_attempt、finished 和最终结算,却看不到输入正文、生成正文或 Authorization。审计不是聊天记录的另一个副本。

13.3 改一个参数,再解释变化

把 request_id 改为 short,将 max_output_units 从 100 改为 3,再发一次请求。正常替身的第一个文本块被限制为 hel,随后给出输出用量 3 和 done;输入仍然为 2。这一实验检验输出边界与计量是否一致,不是要求真实模型按字符数控制生成。

接着保持同一进程,重复发送第一次的 request_id=first。实际返回为 HTTP 409:

{"error": "duplicate_request_id"}

网关拒绝重复启动上游,不会把之前的正文重新播放给你。换用新 ID 可以表达新任务,但不能用它掩盖对同一业务动作的重复执行;本实验没有持久幂等存储,进程重启后这些 ID 会丢失。

13.4 成功路径之外还要看什么

测试输出里的 test_real_http_stream_and_trusted_byte_usage 核对中文 UTF-8 与完整结算;test_duplicate_id_does_not_duplicate_upstream_side_effect 核对上游执行次数;test_audit_is_useful_without_prompt_tokens_or_keys 检查敏感标记没有进入日志。只看终端出现 hello,不能代替这三类证据。

如果提示地址被占用,可换一个端口并同步修改 curl 地址。如果返回 401,先检查实验 Bearer 字符串;如果返回 400,检查字段名、类型和请求大小。如果测试报超时,应保留完整失败输出,确认是否被本机网络策略阻止环回连接,而不是先把断言删除。

这一条链路建立后,下一步才是有控制地制造失败,观察网关在没有足够证据时会如何停止。

第 14 章:让切换发生在有证据的边界

路由测试要回答两个不同的问题:哪个上游有资格执行,当前请求是否允许选择下一上游。能力标签解决第一个问题;授权与上游执行证据共同限制第二个问题。把它们混成“失败就找另一家”,会使一次调用悄悄变成重复执行。

14.1 先检查能力,再接纳请求

运行下面的单项集成测试:

python3 -m unittest discover -s tests -v -k capability_filter

本版实际结果为:

test_capability_filter_precedes_admission ... ok

测试建立两个 HTTP 上游。第一个只声明 text,第二个声明 text 和 image。请求 image 时,第一个上游的接收次数为 0,第二个为 1;请求无人支持的 audio 时,网关返回 422,且没有建立预算预留。

这里的 image 是测试路由集合关系的标签;替身没有处理图片或完成视觉推理。实验验证的是资格判断发生在上游调用之前,不能据此宣传已经实现多模态模型服务。真实接入还需要核对实际输入结构、模型配置和供应商能力。

14.2 默认不切换,必须明确授权

下面的短程序创建两个独立服务。保持 allow_failover=False 运行一次,再改成 True 重新运行。每次脚本都会启动新的进程内账本与上游计划,不存在上一轮余额或故障计划污染:

from lab.client import generate
from lab.gateway import Gateway, Route
from lab.upstream import ModelStub, Plan

allow_failover = False
with ModelStub([Plan("safe_503")]) as first, ModelStub() as backup:
    routes = [Route("first", first.url, trust_not_started=True),
              Route("backup", backup.url)]
    with Gateway(routes) as gateway:
        status, result = generate(gateway.url, {
            "request_id": "routing-case", "prompt": "hi",
            "capabilities": ["text"], "max_output_units": 100,
            "allow_failover": allow_failover,
        })
        print(status, first.requests, first.operations_started,
              backup.requests, backup.operations_started)

实际输出的两种结果分别是:

# allow_failover=False
503 1 0 0 0
# allow_failover=True
200 1 0 1 1

第一个服务明确返回 HTTP 503 和 X-Lab-Execution: not-started。这个头只在当前替身协议里承诺“没有开始执行,也没有计费”,服务端又显式配置了信任。即便如此,缺少请求自身的切换授权,网关仍不会启动后备服务。

把 trust_not_started 改为 False,再将请求设为允许切换,结果仍为 503,后备次数仍为 0。一个来自未建立合同的上游的自称,不能成为新的执行权限。

14.3 普通错误与未知执行结果

将故障计划改成 Plan("unknown_503")。这个替身先增加执行计数,再返回普通 503;即使请求允许切换,后备服务也不会被调用。它模拟“响应失败但工作可能已经发生”的窗口。

另一个反例是 Plan("pre_event_disconnect"):上游已接受请求,却在发出任何完整 SSE 事件前断开。客户端看不到 token,不代表上游没有开始,也不能推出费用为零。当前实验甚至对连接建立失败也不自动选择下一上游,以保留唯一且容易审查的切换入口。

Settings.max_attempts=2 只是上限,不是必须用满的次数。三个候选服务中,前两个均给出受信任的未开始拒绝时,第三个仍不会启动。这个性质由 test_failover_has_finite_attempt_bound 核对,避免配置更多路由以后无意扩大尝试次数。

14.4 输出以后,不能拼接一段新的生成

运行断流测试:

python3 -m unittest discover -s tests -v -k midstream_disconnect

实际结果中,test_midstream_disconnect_reports_error_without_replay 通过。完整 demo 保存的对应记录节选为:

{
  "name": "interrupted_not_replayed",
  "http_status": 200,
  "events": ["token", "error"],
  "backup_requests": 0,
  "accounting_before_reconciliation": {
    "reservation_units": 22,
    "actual_units": null,
    "usage_state": "unknown",
    "outcome": "failed",
    "active": false
  }
}

200 已经发给客户端,后续错误不能改成新的 HTTP 状态。网关尽力发送 error 事件,终止当前流,保存未知结算;它不会接着从第二个模型取得文字再拼在前半段后面。客户端需要同时检查流的终止事件,不能仅以最初的 200 判定成功。

14.5 网络分块不等于模型事件

test_sse_line_endings_multidata_and_fragmented_utf8_over_http 将中文字符拆在不同 HTTP 块,使用 LF、CRLF、CR 行结束,并把 JSON 拆成多条 data 行。三个变体都能还原一个完整事件。另一方面,无空行就到 EOF 的帧不会派发,超过 32 KiB 的帧会失败;相应测试名称包含 eof_without_empty_sse_line 与 oversized_sse_frame。

这套解析器要求命名的 event 与 JSON 对象,不实现 id、retry 或自动重连。练习时先改坏帧的结束空行,再恢复它;应当看到上游确实接收了请求,但网关没有把半条内容转发成有效 token,账本也不会伪造成功。

CR-only 的空行到达就应派发,不等待后续内容。专门的 test_cr_only_complete_event_is_dispatched_before_any_later_bytes 用同步事件扣住后续数据,客户端必须先读到首个 token 才放行。大小计量对 CR 预留可能跟随的 LF 字节,因此临界大帧可能被保守提前拒绝,不会漏算而越过上限。

第 15 章:并发结束了,预算为什么还被占用

完成、取消和结算是不同维度。一个运行已经没有本地工作线程,仍可能欠缺上游最终用量;一个取消请求得到 202,也不意味着远端费用已经停止。本章把这些差异变成可以读取的账本记录,而不是藏在一行“请求失败”里。

15.1 一次预留,两个限制

测试 test_atomic_tenant_concurrency_admission 将租户并发上限设为 1,先从真实客户端读到首个 token,再发第二个请求。后一个请求返回 429,替身只接到第一个请求。等待“首个事件已被读取”,比凭固定 sleep 猜测服务已经开始更可靠。

预算限制与并发限制分别判断,却必须在同一次原子接纳中检查。test_parallel_reservation_cannot_overspend 同时启动六个客户端,验证余额不会变成负数;最终允许多少个成功请求可能受结算时机影响,测试不会把某一个线程调度顺序写成合同。

python3 -m unittest discover -s tests -v -k atomic_tenant
python3 -m unittest discover -s tests -v -k parallel_reservation

两项实际运行均为 ok。改参数时,可把 TenantPolicy(max_concurrent=1) 改成 2,再观察原先的第二个请求是否能够进入。预算仍需足够,不能把 402 的额度拒绝误判为并发控制没有生效。

15.2 先核算字节,再检查最终证据

在 utf8-bytes-v1 教学合同中,hi 占 2 credits,输出上限 100 时预留 102。预留尚未解除时,这 102 都不能用于承诺别的新请求;正常收到完整 usage 与 done 后,才按实际输入和输出计数结算并释放差额。

不要用 Python 的 len("你好") 当作 UTF-8 字节数。运行 test_real_http_stream_and_trusted_byte_usage 时,输入“问题”占 6 字节,输出“你好 AI”占 9 字节,实际结算为 15。真实 tokenizer 与收费规则并不在这个实验里,字节合同只是为了让每个数字都能独立复核。

test_missing_or_false_usage_cannot_settle 分别制造缺失与矛盾的用量;test_output_text_is_data_and_never_accounting 则让模型正文看起来像一段 usage JSON。前两者保留未知状态,后者仍按正文真实字节核算。请求本身添加 usage 或 tenant 字段会被拒绝,客户端不能给自己指定身份或免单。

15.3 看见预留被保留下来

完整 demo 中断流任务的输出上限是 20,因此预留为 22。上游发送 part 后断开,没有最终 usage 和 done。实跑记录中的余额如下:

{
  "balance_held": {
    "budget_units": 110,
    "spent_units": 0,
    "held_units": 22,
    "available_units": 88,
    "active": 0
  },
  "balance_after_reconciliation": {
    "budget_units": 110,
    "spent_units": 6,
    "held_units": 0,
    "available_units": 104,
    "active": 0
  }
}

前半部分表示本地并发名额已经释放,但 22 credits 仍被冻结。后半部分只有在演示程序利用受控替身的执行计划取得可信的 6 credits 证据后才出现:输入 hi 两字节,加实际执行输出 part 四字节。

演示调用的是进程内 Ledger.reconcile("tenant-a", "interrupted", 6),不是让客户端填一个数字便自动退款。真实部署需要从供应商账单、运行查询或授权人工核对取得证据。这个方法没有暴露为 HTTP 接口,也没有实现账单同步。

15.4 取消传播要观察两端

完整 demo 的 cancel_and_concurrency 记录中,第二个并发请求得到 429,取消请求得到 202,上游替身观察到连接断开,而账本最终为 cancelled/unknown:

concurrent_http_status = 429
cancel_http_status = 202
upstream_observed_disconnect = true
reservation_units = 1002
usage_state = unknown
active = false

请求路径是 DELETE /v1/runs/<request_id>,凭据必须属于同一租户。test_cancellation_is_tenant_scoped_and_propagates_upstream 先用另一个租户取消,验证返回 404 且原任务仍活动,再用所有者取消。202 只确认请求已接纳;最终状态仍应从运行结果和账本确认。

直接关闭客户端 TCP 也有对应测试。网关在下一次写入失败后关闭上游 socket;如果客户端只是停止读取而不关闭,仍可能先填满各层缓冲,再触发等待或写超时。这与供应商明确确认“停止执行且不再计费”不是同一件事。

15.5 用慢客户端把缓冲限制显露出来

python3 -m unittest discover -s tests -v -k slow_consumer

本版实际通过 test_slow_consumer_has_bounded_event_queue_and_backpressure。demo 记录的 queue_peak 为 2,backpressure_observed 为 true。测试缩小客户端接收缓冲,暂停读取大量输出,等待应用队列发生阻塞,再取消并确认上游看到断开。

可以把 Settings(queue_events=2) 改为 1,预期队列峰值不超过 1,仍能观察到等待。不要据此推算进程只会占用一个事件的内存:解析器、HTTP 库与内核都有额外缓冲。也不要把这里的等待时长写成生产吞吐或费用收益。

所有预算和审计都保存在单进程内存中,退出即丢失。完成这些实验以后,能够清楚说出“哪个限制生效、哪条证据还缺失”,才有依据继续增加持久账本、独立对账和多实例接纳;当前程序并没有提供这些生产保证。

第 16 章:从单进程实验推导生产边界

设想把教学网关部署成两个副本,再把预算设为每租户 1000 credits。每个副本各自允许预留 800,整体就承诺了 1600;其中一个进程重启后,又忘掉了正在执行和用量未知的请求。这个故障无需高并发,也不是换一个 HTTP 框架就能解决。生产化首先要替换状态与责任边界。

16.1 为每项保证找到实际证据

实验使用 Python 标准库,真实 HTTP 入口为 POST /v1/generate,身份来自 Bearer;请求携带 prompt、capabilities、max_output_units、request_id 和可选 allow_failover。SSE 按 token、usage、done 组织,显式取消使用 DELETE /v1/runs/<request_id>。测试能观察本地网络、并发、结算和取消行为。

它同时只有单进程内存、回环 HTTP 与本地替身,没有 TLS、持久账本、跨进程锁或真实模型 API 兼容保证。计量是 UTF-8 字节 credit,不能转换成任何供应商价格证明。下面讨论的是待实现设计;部署实验服务并不能自动获得这些能力。

16.2 把准入与终态写进持久账本

先用数据库或具备相应一致性保证的存储保存租户预算、预留和运行;在同一个事务中检查 (tenant, request_id) 唯一性、可用额度与并发资格,再建立运行。成功提交之后才联系上游。跨副本更新不能继续依赖进程内锁,状态转换还需条件更新,防止两个工作者重复结束同一运行。

状态可拆成“执行是否仍活跃”与“用量是否已确认”两个轴。工作进程崩溃时,租约过期可以触发接管和本地槽位核查,却不能证明远端从未执行。unknown 预留仍保留,等待供应商查询、账单或授权核对。跨节点并发租约还要用所有权版本阻止旧工作者继续提交,防止新旧工作者同时认为自己负责。

结算写入与对账事件发布可以采用事务内 outbox,再由消费者按结算标识去重。这能处理重复消息和发布失败,不能提供跨所有外部系统的“恰好一次”。接管者若缺少远端幂等合同,不应重新发送一个可能已经执行的 POST。

16.3 固定配置,也固定信任

每次请求保存路由策略、能力清单和适配器版本,才能重放决策过程。生产另需 TLS、密钥托管、租户权限、出口白名单和配置变更审核。紧急吊销应阻止尚未发起的调用,并记录影响;普通灰度则尽量保持请求内的配置视图一致。

不能把实验的 trust_not_started 布尔值当作真实供应商承诺。受控教学切换只在调用者显式允许、可信替身 503 明确未开始时成立;普通 503、首事件超时和流内断连不提供这个证据。真实适配器必须重新核验接受确认、幂等键与计费语义,遵守 S01 的重试边界。

16.4 数据面需要可计算的资源上限

连接数上限应由最慢请求占用时间和可用连接容量推导,再为租户设置并发与等待边界。队列同时约束事件数和字节数,限制单事件、总输入、总输出与写入期限。一次请求使用同一个总 deadline,各次尝试消耗剩余时间;具体代理计时器的范围需按所选版本重新核对 S04。

滚动发布时先停止新准入,给现有流一个明确排空期限。过期后取消本地运行并关闭连接,未取得完整用量的仍进入 unknown。取消接口返回“意图已接收”与“远端确认停止”是不同状态,也不能替代工具副作用补偿。

如果产品要求断线恢复,还需另建持久事件日志、游标、保留期限和按租户授权的读取协议,说明恢复交付还是重新生成。SSE 的事件 ID 本身没有这些存储保证,见 S03。没有恢复合同的客户端应显示不完整,不拼接新模型回答。

16.5 用故障验收控制上线范围

可以先让一个无写工具的内部应用接入已验证的文本能力,限制租户和预算,再验证真实供应商的协议结束与账单差异。演练至少覆盖准入事务后崩溃、usage 后 done 前断流、结算消息重复、取消与完成竞争,以及升级时长流排空。每项都要检查资源、账本和用户状态,不能只看返回码。

实验提供本地“完整且可信 usage 加 done 才结算,中断保留 unknown”的基线。真实供应商事件格式与单位各异,必须由适配器建立自己的结束合同,S07 与 S10 展示了不同形式;任务质量还要另用业务验收集评估。

练习: 租户额度 1000,已有结算 100、unknown 预留 200,两副本同时申请 500。写出一个事务顺序,证明最多一个申请通过;再在“提交准入、发送上游、写入终态”之间分别放置崩溃,说明重启后凭什么恢复。交付物应包含状态表、存储条件更新和故障断言,并明确哪些部分仍需实现。

第 17 章:设计你自己的 AI 网关

现在可以把前面的规则放回一个完整项目。设想你负责公司内部的知识助手入口,两个团队共享额度,各有不同的数据访问权限;一部分请求需要结构化输出,另一部分只需要文本。你希望在引入第二个模型服务时,仍能解释每一次执行和费用。

17.1 写下可验收的第一版范围

第一版可以只支持文本、有限的输出上限、两个固定上游和租户认证。把不支持的能力明确拒绝,再逐步增加。对每项能力写出入口语义、上游验证、错误表示、观测字段和回归样本。

以下交付表比一张只有模块名的架构图更接近实际验收:

交付对象 需要证明什么
能力清单 不合格上游不会收到请求
流式适配器 分片不损坏事件,完整与中断可区分
准入账本 并发竞争不会重复消费同一份可用额度
运行记录 请求、尝试、提交点和结算状态可关联
取消路径 本地资源最终释放,未知费用仍可追踪
发布流程 配置与代码版本可回退,回归结果可重现

17.2 选择一个语言,保留共同的不变量

在 Java 体系中,可以将这些约束映射到网关过滤链、异步流和事务存储;在 Go 体系中,可以映射到 context、流式 HTTP 处理和持久化组件。移植时应重新检查取消与缓冲语义:库是否提前聚合响应,写入是否真正刷新,取消如何传到上游读取。

不能因为接口名字相同,就把实验锁、队列和 socket 行为逐行照搬。保留的是不变量与证据设计。例如“未知不结算”应由新的持久状态机验证,而不是仅靠翻译 Python 的一个条件分支。

17.3 先做一次失败评审

让同事选一个故障:网关进程退出、账本暂不可用、上游半途断开、旧配置引用已吊销密钥,或者客户端不再读取。对每个故障回答四个问题:哪一步已发生,谁知道这个事实,哪些资源还占用,以及恢复时允许做什么。

如果答案依赖“通常会”“应该已经”,就需要增加证据或收紧范围。例如进程退出后不知道上游状态,可以把任务标为待核查;这虽然不能立刻恢复完整服务,却保留了诚实的状态。自动标记成功或者把预留全部清零,只会把故障搬到账单和用户反馈里。

17.4 一个实际的增量顺序

先接入一个真实上游,固定协议版本并跑契约测试;再引入持久任务与账本,验证重启和重复结算;随后加入第二上游,明确质量与能力边界;最后才考虑动态路由、灰度和自动调优。每一步都保留旧行为基线,避免同时改变协议、模型与策略后无法归因。

真实上游测试使用独立的测试账号和额度,把需要费用的用例与本地确定性测试分开。代码仓库只保存凭证读取方式和占位说明,实际密钥进入部署环境的受控配置。

17.5 交付给下一位维护者

项目应提供启动方式、已验证能力、故障矩阵、状态说明、日志示例、账本核查流程和未实现功能。在线阅读与 EPUB 同源,使读者离线时仍能核对规则;代码附录则保留该版实验的实现细节。

一个值得维护的 AI 网关,应该让新同事在没有参与最初设计的情况下,也能回答“为什么选了它”“为什么没有重试”“为什么还有一笔未知预留”。当这些问题都有证据,系统才具备继续扩展的基础。

综合练习。 用一页设计说明完成自己的第一版范围,再为上表每一行写一条正常测试和一条失败测试。选择一个目前不能提供的保证,把它写成后续改造任务,列出完成时需要补充的运行证据。

附录 A:练习解题方向

本附录给出判断依据。涉及实际代码的答案,以该版源码与测试为准;生产系统还需要结合自己的接口、存储和费用合同进行验证。

第 1—4 章:身份、合同与决策

第 1 章至少补充:下游是否已经收到完整事件、具体上游及尝试编号、用量是否可靠且完整。还应关联取消与超时原因。只有一条 timeout 无法区分“未开始”“可能已开始”和“已显示部分结果”。

第 2 章可以展示总量,但不能无依据拆成输入和输出后套用不同费率。估算应保留估算标志与算法版本,正式结算等待足以支持该费率合同的证据,或采用双方明确同意的替代合同。

第 3 章请求同时要求结构化输出和工具调用,候选必须同时满足经验证的能力。未知工具能力不能自动视为支持;应返回可控的 capability_missing 或 capability_unverified 类原因,避免泄露内部端点与凭证。

第 4 章应检查任务质量、流完整结束率、用量完整率、尾延迟与预算占用。用量缺失会影响成本控制和对账,通常不宜仅因平均延迟变好就扩大流量。回退条件可以写成在固定窗口和足够样本下,完整结束率低于已确定门槛;门槛应来自自身业务要求,不能照抄一个通用百分比。

第 5—9 章:流与资源

第 5 章的所有切分位置都应还原相同的中文文本与同一条事件,且不出现替换字符。英文测试无法覆盖多字节 UTF-8 序列被切开的情况。还应测试分隔空行被拆开、CRLF 被拆开和最后没有事件终止空行。

第 6 章可以在一个受锁保护的终态转换中决定取消与完成谁先被接受。若完整且验证通过的结束已先提交,后来的取消不应再次释放;若取消先被接受,后到的数据不能绕过状态机二次结算。具体采用什么优先级应写入合同,关键是终态与资源处理只发生一次。

第 7 章在最简单的独立上限模型里,次数可能达到 2 × 3 × 2 = 12。它仍未说明每次尝试是否被接受、是否去重、是否有工具副作用,以及成本如何产生;还需要各层尝试记录和业务证据。

第 8 章本地工作已经结束后,并发槽位可以释放;未知用量预留继续保留,直至可信核查或明确风险政策处理。并发限制控制活跃本地工作,预算控制尚未解除的资源承诺,两者不必具有相同生命周期。

第 9 章可用额度为 100 − 20 − 30 − 15 = 35。删除未知预留会让显示值增加 15,但这项增加并没有来自新用量证据;若有意采取该政策,需要留下审批与风险记录。

安全、观测与验证

安全练习应首先识别身份边界、上游信任边界和工具执行边界。内容中出现的权限声明与 usage 片段都只是数据,不能覆盖认证结果和账本。出口地址、密钥来源与私有未开始信号需要由受控配置决定。

观测练习要分别统计入口请求和上游尝试,并明确哪些拒绝进入哪个分母。流已返回 HTTP 200 但未完整结束时,应进入中断分类;未知用量不是零用量。若要求延迟数据,计时起点和终点也必须随报告说明。

第 12 章可以让首个替身先输出一段文本,再主动断开;后备替身保持可用。客户端必须观察到部分内容与失败,后备接收次数为零,账本记录未知用量并保留预留。任何一个条件缺失,都不足以证明“未静默重放”。

实验章与综合设计

实验结果需要同时核对客户端事件、替身计数和账本状态。改变输出上限、租户额度和故障模式时,应先算出新的预留或预期状态,再运行测试。运行失败要保留异常和环境信息,不能只修改预期值让测试通过。

生产设计练习的重点是重启与多实例:内存锁、内存记录和本地取消状态的保证都限制在进程内部。引入持久存储后,应验证重复完成、重启后未知任务与并发争用,而不只跑原来的单进程成功测试。

第 17 章的一页设计应包含支持的协议与能力、租户认证、上游范围、尝试规则、期限、预算单位、终态、恢复与发布方式。未实现项可以成为清晰的下一步,例如“增加持久化未知用量对账,并以进程重启和重复结算测试作为验收”,比泛称“以后支持高可用”更容易执行。

附录 B:来源、版本与复核方法

以下第一方资料已于 2026-10-21 读取。标准按节号定位;持续更新的产品文档保留读取日期。更详细的结论边界保存在仓库 research/sources.md。实验采用自己的协议,不表示符合所有真实供应商接口,也不提供价格或可用性承诺。

HTTP 与事件流

S01,RFC 9110,HTTP Semantics,第 9.2.2 节。 https://www.rfc-editor.org/rfc/rfc9110.html#section-9.2.2。用于核对幂等与自动重试语义。请求没有返回响应,不能证明服务端没有应用它;代理不得自动重试非幂等请求。实验中的受控应用编排不能泛化为透明代理重放。

S02,RFC 9112,HTTP/1.1,第 7.1、8、9.2 节。 https://www.rfc-editor.org/rfc/rfc9112.html。用于区分传输完整与应用完成,以及已经开始的最终响应所能表达的错误。HTTP/2 和 HTTP/3 的帧与流规则需要另行核对。

S03,WHATWG HTML,Server-sent events。 https://html.spec.whatwg.org/multipage/server-sent-events.html。用于核对 UTF-8、行、空行、多行 data、事件标识和 EventSource 的处理模型。POST 自行读取流的客户端不会仅凭 SSE 编码继承 EventSource 自动重连行为,SSE 也不提供模型续写或业务撤销保证。

网关与观测

S04,Envoy,Timeouts。 https://www.envoyproxy.io/docs/envoy/latest/faq/configuration/timeouts.html。用于区分连接、空闲、路由与尝试期限。latest 文档可能对应开发版本,实际部署要重新核对所用版本。

S05,Envoy,Router filter。 https://www.envoyproxy.io/docs/envoy/latest/configuration/http/http_filters/router_filter.html。用于核对尝试预算、退避与错误类别的不同处理。产品提供重试配置,不等于业务已经具有幂等性。

S06,OpenTelemetry GenAI semantic conventions。 https://github.com/open-telemetry/semantic-conventions-genai/tree/8538971bd11dba723fdd33c804b5a32121827ff7。本书核对的是此固定提交中的 client-inference 与内容捕获说明。相关语义在读取时标为 Development;可以参照设计请求模型、实际响应模型与用量字段,但不应声称这些字段永久稳定。提示词与输出内容具有敏感性,内容捕获需要明确选择和治理。

供应商协议差异

S07,Anthropic,Streaming Messages。 https://platform.claude.com/docs/en/build-with-claude/streaming。流有消息、内容块、增量和终止等层次;部分 usage 为累计值,工具参数增量可能不是完整 JSON。该结论不能直接套到其他协议。

S08,Anthropic,Rate limits。 https://platform.claude.com/docs/en/api/rate-limits。用于理解请求速率、输入和输出 token 限制的不同维度。限额不是保证获得的吞吐,限流口径也不自动等于费用口径。

S09,Gemini,Models API。 https://ai.google.dev/api/models。模型元数据提供名称以外的信息,例如输入、输出限制与生成方法。一个生成方法字段不足以证明工具、结构化输出和所有其他能力兼容。

S10,Amazon Bedrock,ConverseStream。 https://docs.aws.amazon.com/bedrock/latest/APIReference/API_runtime_ConverseStream.html。统一调用接口仍有模型能力差异;消息停止、metadata 与 usage 应按实际事件协议解释。该服务的事件流不能直接当作 SSE 使用。

使用这些资料的方法

接入真实服务时,先固定模型、协议与 SDK 版本,再建立最小契约样例。记录哪些能力实际跑过,哪些仅从文档获知,哪些仍未验证。对费率、账户限额和模型别名等可变化内容,不把某次读取值写成长期常量。

HTTP 标准解释传输与方法语义,供应商合同解释接受、去重与用量,业务系统解释任务是否完成。本书的测试只证明本地实验覆盖到的行为。把这些依据分别保存,才能在接口升级后知道哪些结论需要重新验证。

附录 C:术语与状态速查

术语 本书中的含义 常见误读
业务任务 用户希望完成的业务目标 一定等于一次 HTTP 请求
请求 一次入口调用 一定只有一次上游尝试
尝试 对一个选定上游发起的一次调用 换上游后仍是同一次计费执行
能力 经定义和验证的协议或功能约束 模型名字相同就相同
提交点 对另一个参与方形成可观察事实的时刻 响应头、首个事件与业务成功相同
完整结束 满足该协议的终止与验证条件 HTTP 200 就已经完成
unknown 缺少判断执行或用量所需的证据 失败且费用为零
预留 为新请求保留的容量或预算 已确认消耗
结算 按可信合同记录已经发生的用量 根据输出长度猜测真实账单
对账 使用权威补充证据处理差异或未知 到时间后把记录自动清零
准入 发起资源消耗前的身份、容量与预算决策 只检查 QPS
背压 下游容量不足时对上游生产或读取施加限制 自动保证远程不再计费
取消 请求停止继续进行的意图与传播机制 业务回滚和退款
幂等 重复相同操作的预期效果与一次相同 重复执行没有资源成本
故障切换 在合同允许的范围选择另一执行候选 任意异常都可透明重放
SSE 按规范编码的单向事件流格式 一个网络分片就是一个 token
credit 实验合同中的 UTF-8 文本字节单位 真实模型 token 或货币
审计 保存行为、身份与状态转换的可核查记录 默认保存完整提示词与密钥

实验中的 token 事件表示一段文本增量;usage 表示实验字节用量;done 表示协议结束。只有事件、输入输出量和状态均满足约定,才能确认结算。真实供应商的事件名称、累计规则与结束条件应由适配器分别验证。

生产改造时,可以保留这些概念,但必须重新验证实现保证。持久化、多实例、真实供应商认证、TLS、账单对账和运行恢复均超出单进程本地实验的完整覆盖范围。

附录:随书实验完整代码

以下文件在构建时从随书实验与测试目录读取,与下载区实验包同源。它们是本书教学实现;实际验证范围以实验章节和验证记录为准。

lab/README.md

# 可运行的 HTTP 网关实验

需要 Python 3.10+,只用标准库,不需要真实模型、API key、外网或第三方 Python 包。从仓库根目录运行:

```sh
python3 -m lab.demo --evidence lab/evidence/demo.json
python3 -m unittest discover -s tests -v
```

第一条启动真正的环回 HTTP 模型替身、网关与 SSE 客户端,执行受授权切换、断流不重放、取消、并发、慢消费者和日志隐私实验,然后退出。第二条运行集成测试。`lab/evidence/demo.json` 是本机运行记录;时间戳、取消前已生成的字节数和队列阻塞次数可能随调度变化,不是性能基准。

交互模式:

```sh
python3 -m lab.demo --serve --port 8088
```

另开终端,用公开的实验凭据请求文本替身:

```sh
curl -N http://127.0.0.1:8088/v1/generate \
  -H 'Authorization: Bearer tenant-a-secret' \
  -H 'Content-Type: application/json' \
  -d '{"request_id":"first","prompt":"hi","capabilities":["text"],"max_output_units":100}'
```

这是教学协议,不兼容任何供应商 API。能力标签只检验路由匹配,不证明替身具备多模态或真实推理能力。重复相同租户的 `request_id` 返回 409;此字段只用于当前进程去重与关联,不提供结果重取、持久幂等或跨实例保证。

## 接口与预算

`POST /v1/generate` 仅接受 `request_id`、`prompt`、`capabilities`、`max_output_units`、可选布尔 `allow_failover`。未知字段拒绝,包括客户端提供的 `tenant`、`route` 和 `usage`。Bearer key 由服务端配置映射租户,不从模型参数读取租户身份。

`request_id` 为 1–64 个 ASCII 字母、数字、`_` 或 `-`;输入 UTF-8 最多 32,768 字节,输出上限默认最多 8 MiB。请求 JSON 最多 65,536 字节;这是独立的封装大小限制,不能只按解码后的 prompt 大小判断请求是否可接纳。

本实验的 `utf8-bytes-v1` 合同定义 **1 credit = 1 个 UTF-8 字节**:

- 预留:`len(prompt.encode("utf-8")) + max_output_units`。
- 完整用量:输入 prompt 字节数 + 所有 `token.text` 的 UTF-8 字节数。
- JSON、SSE 字段名及 HTTP 封装不计入这个教学单位。

这不是任何真实模型 tokenizer 或收费规则。多字节中文要按 UTF-8 字节计算,不能用 Python 字符数代替。替身凭配置凭据接收请求;网关只接受所选可信上游的独立 `usage` 事件,并核对合同、整数类型、输入和输出计数。模型正文里的 `usage=0` 只是数据。**完整 `usage` 加 `done`** 且下游写入完成后才结算;用量不合法、缺失、断流或取消都保守标为 `unknown` 并保留整笔预留。

`Gateway.ledger.snapshot(tenant)` 是进程内观测,包含 `budget_units/spent_units/held_units/available_units/active`。预留、并发接纳和余额检查在同一把锁内。完成后释放并发名额;未知用量保留预算,直至有权操作者取得可信凭证调用进程内 `Ledger.reconcile(tenant, request_id, actual_units)`。客户端没有对账或豁免债务的 HTTP 接口。

上述未知规则针对已经可能开始执行的请求;可以证明请求尚未发送,或收到受信任未开始拒绝时,释放预留。

## 流式协议与切换边界

CR 到达便立即作为行边界处理,不等待下一字节。为确保分块后到达的 LF 不使大小计量漏算,CR 行先保守计入潜在 CRLF 的两个结束字节;CR-only 的临界大帧可能比实际线上字节数略早达到限制,但不会突破 32 KiB 线上上界。

正常事件为 `token`(`{"text":"..."}`)→ `usage`(合同和 input/output_units)→ `done`(`{}`)。HTTP/1.1 chunked 传输由客户端解码后,再按完整 SSE 帧解析;每帧最多 32 KiB。支持 LF、CRLF、CR 行结束、跨 HTTP 分块的 UTF-8、注释和多 data 行的换行拼接;EOF 前没有空行结束的帧不会派发。应用层要求 event/data 与 JSON 对象,不支持 id/retry、自动重连或无 event 的默认 message。这是有明确边界的 SSE 子集。

默认 `allow_failover=false`,不自动重放 POST。只有请求明确设置 `true`,并且服务端对该上游配置 `Route(..., trust_not_started=True)`,上游返回 **HTTP 503 + `X-Lab-Execution: not-started`**,才承认这个私有协议的“没有执行也没有计费”承诺。在首次客户端响应前,最多尝试 `Settings.max_attempts=2` 个能力匹配上游。实验连连接失败也不自动切换,以保持唯一且可审查的切换入口。

普通 503、无输出就断开、发送后超时、HTTP 200 后无完整首帧、已输出后断流都不满足承诺,不切换。普通上游即使发送同名头,只要没有配置可信合同也不能触发切换。此机制是受约束的应用层协议,不是 HTTP 通用代理自动重试任意 POST 的许可。

没有向客户端输出时,可用 JSON HTTP 错误报告失败;已经开始 SSE 后,只能尽力发送 `error` 事件并结束流,不能改写已经发出的 200 状态。`unknown` 不等于模型没有运行。

## 取消、慢消费者与边界

`DELETE /v1/runs/<request_id>` 要求同一租户 Bearer 凭据,活动任务返回 202;其他租户或不存在的任务返回 404,已完成任务返回 409。202 仅表示网关已收到取消,不承诺供应商已停止计费。网关设置取消事件并关闭上游 socket;本地替身下一次写入时可观察到断开。客户端直接关闭下游 socket,也会在网关下一次写入失败时触发上游关闭。

网关用单独生产线程解析上游帧,默认 `queue.Queue(maxsize=2)`;消费者写下游,队列满时生产线程等待而不继续读取。写超时默认 0.3 秒,演示慢消费者时为 0.5 秒。单次上游 socket 超时默认 1 秒。**队列容量、单帧大小、socket 超时是三项不同限制**;没有实现整个请求的绝对截止时间。

`run_stats(tenant, request_id)` 提供 `queue_peak/backpressure_waits/producer_stopped`。慢消费者实验使用真实 TCP 流和小接收缓冲,不消费响应体;观察队列到顶并出现等待,然后取消。队列有界不等于进程总内存严格等于两个帧:解析器持有当前帧,HTTP 库和内核还有缓冲,也不保证真实服务商停止生成或停止计费。

## 审计与未实现项

`Gateway.audit.jsonl()` 输出内存审计记录,字段白名单为 `time/event/request_id/tenant/route/attempt/reason/outcome/usage_state/reservation_units/actual_units`。不记录 prompt、生成文本、Authorization、上游凭据、原始异常字符串;HTTP server 默认访问日志关闭。测试会检查敏感标记没有进入日志。

没有 `/metrics`、`/admin` 或 `/health`。没有 TLS、公开网络身份系统、持久账本、重启恢复、跨实例去重、供应商账单接入、真实模型调用、整体 deadline、生产限流或独立安全审计。所有监听和上游限定环回地址;内存账本与审计随进程退出丢失。实验通过只能证明列明的本地协议行为,不能外推真实模型质量和云端账单保证。

lab/init.py

"""Loopback-only HTTP experiments for AI gateway engineering."""

lab/client.py

"""Minimal HTTP/SSE client used by both the demo and integration tests."""
import http.client
import json
from urllib.parse import urlsplit

from .protocol import read_events


def open_stream(url, request, credential="tenant-a-secret", timeout=5):
    parsed = urlsplit(url)
    connection = http.client.HTTPConnection(parsed.hostname, parsed.port, timeout=timeout)
    connection.request("POST", "/v1/generate", body=json.dumps(request).encode(), headers={
        "Authorization": "Bearer " + credential, "Content-Type": "application/json",
    })
    return connection, connection.getresponse()


def generate(url, request, credential="tenant-a-secret"):
    connection, response = open_stream(url, request, credential)
    try:
        if response.status != 200:
            return response.status, json.loads(response.read())
        return response.status, list(read_events(response))
    finally:
        response.close()
        connection.close()


def cancel(url, request_id, credential="tenant-a-secret"):
    parsed = urlsplit(url)
    connection = http.client.HTTPConnection(parsed.hostname, parsed.port, timeout=5)
    connection.request("DELETE", "/v1/runs/" + request_id,
                       headers={"Authorization": "Bearer " + credential})
    response = connection.getresponse()
    try:
        return response.status, json.loads(response.read())
    finally:
        response.close()
        connection.close()

lab/demo.py

"""Run complete HTTP fault experiments, or keep a loopback gateway running."""
import argparse
from contextlib import ExitStack
from datetime import datetime, timezone
import http.client
import json
from pathlib import Path
import socket
import time
from urllib.parse import urlsplit

from .client import cancel, generate, open_stream
from .gateway import Gateway, Route, Settings, TenantPolicy
from .protocol import UNIT_CONTRACT, read_events
from .upstream import ModelStub, Plan


def payload(request_id, **changes):
    value = {"request_id": request_id, "prompt": "hi", "capabilities": ["text"],
             "max_output_units": 100}
    value.update(changes)
    return value


def await_condition(check, timeout=3):
    deadline = time.monotonic() + timeout
    while time.monotonic() < deadline:
        value = check()
        if value:
            return value
        time.sleep(0.01)
    raise RuntimeError("experiment_condition_timed_out")


def await_finished(gateway, request_id):
    return await_condition(lambda: (record if not record["active"] else None)
                           if (record := gateway.ledger.record("tenant-a", request_id)) else None)


def experiments():
    cases = []
    with ExitStack() as stack:
        first = stack.enter_context(ModelStub([Plan("safe_503")]))
        second = stack.enter_context(ModelStub([Plan(chunks=("route ", "works"))]))
        gateway = stack.enter_context(Gateway([
            Route("primary", first.url, trust_not_started=True), Route("backup", second.url)]))
        status, events = generate(gateway.url, payload("safe-failover", allow_failover=True))
        record = await_finished(gateway, "safe-failover")
        assert status == 200 and record["actual_units"] == 13
        assert first.operations_started == 0 and second.operations_started == 1
        cases.append({"name": "authorized_safe_failover", "http_status": status,
                      "events": [name for name, _ in events],
                      "upstream_operations": [first.operations_started, second.operations_started],
                      "accounting": record, "balance": gateway.ledger.snapshot("tenant-a")})
    with ExitStack() as stack:
        first = stack.enter_context(ModelStub([Plan("midstream_disconnect", chunks=("part", "never"))]))
        second = stack.enter_context(ModelStub())
        gateway = stack.enter_context(Gateway([
            Route("primary", first.url, trust_not_started=True), Route("backup", second.url)],
            policies={"tenant-a": TenantPolicy(budget_units=110)}))
        status, events = generate(gateway.url, payload("interrupted", max_output_units=20, allow_failover=True))
        record = await_finished(gateway, "interrupted")
        assert status == 200 and record["usage_state"] == "unknown" and second.requests == 0
        held = gateway.ledger.snapshot("tenant-a")
        # The fixture executed four output bytes and two input bytes. This is
        # authoritative in-process fixture evidence, not an HTTP client claim.
        gateway.ledger.reconcile("tenant-a", "interrupted", 6)
        cases.append({"name": "interrupted_not_replayed", "http_status": status,
                      "events": [name for name, _ in events], "backup_requests": second.requests,
                      "accounting_before_reconciliation": record, "balance_held": held,
                      "authoritative_fixture_units": 6,
                      "balance_after_reconciliation": gateway.ledger.snapshot("tenant-a")})
    with ExitStack() as stack:
        upstream = stack.enter_context(ModelStub([Plan(chunks=("x",) * 1000, delay=0.005)]))
        gateway = stack.enter_context(Gateway([Route("primary", upstream.url)],
            policies={"tenant-a": TenantPolicy(max_concurrent=1)}))
        connection, response = open_stream(gateway.url, payload("cancel", max_output_units=1000))
        try:
            assert next(read_events(response))[0] == "token"
            concurrency_status, _ = generate(gateway.url, payload("rejected-concurrent"))
            cancellation_status, _ = cancel(gateway.url, "cancel")
            record = await_finished(gateway, "cancel")
            await_condition(lambda: upstream.disconnects > 0)
            assert concurrency_status == 429 and cancellation_status == 202
            assert record["usage_state"] == "unknown"
            cases.append({"name": "cancel_and_concurrency", "concurrent_http_status": concurrency_status,
                          "cancel_http_status": cancellation_status, "upstream_observed_disconnect": True,
                          "accounting": record, "balance": gateway.ledger.snapshot("tenant-a")})
        finally:
            response.close()
            connection.close()
    with ExitStack() as stack:
        upstream = stack.enter_context(ModelStub([Plan(chunks=("x" * 2048,) * 2000, delay=0)]))
        gateway = stack.enter_context(Gateway([Route("primary", upstream.url)],
            policies={"tenant-a": TenantPolicy(budget_units=5_000_000)},
            settings=Settings(queue_events=2, client_write_timeout=0.5)))
        parsed = urlsplit(gateway.url)
        connection = http.client.HTTPConnection(parsed.hostname, parsed.port, timeout=5)
        connection.connect()
        connection.sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 1024)
        connection.request("POST", "/v1/generate", body=json.dumps(payload("slow", max_output_units=4_096_000)),
            headers={"Authorization": "Bearer tenant-a-secret", "Content-Type": "application/json"})
        response = connection.getresponse()
        try:
            assert response.status == 200
            await_condition(lambda: gateway.run_stats("tenant-a", "slow")["backpressure_waits"] > 0)
            stats = gateway.run_stats("tenant-a", "slow")
            assert stats["queue_peak"] <= 2
            cancel(gateway.url, "slow")
            record = await_finished(gateway, "slow")
            await_condition(lambda: upstream.disconnects > 0)
            cases.append({"name": "slow_consumer", "queue_capacity_events": 2,
                          "queue_peak": stats["queue_peak"], "backpressure_observed": True,
                          "upstream_observed_disconnect": True, "usage_state": record["usage_state"]})
        finally:
            response.close()
            connection.close()
    with ExitStack() as stack:
        upstream = stack.enter_context(ModelStub([Plan(chunks=("private-generated-value",))]))
        gateway = stack.enter_context(Gateway([Route("primary", upstream.url)]))
        generate(gateway.url, payload("privacy", prompt="private-input-value"))
        await_finished(gateway, "privacy")
        audit = gateway.audit.jsonl()
        forbidden = ["private-input-value", "private-generated-value", "tenant-a-secret", "fixture-upstream-secret"]
        assert not any(value in audit for value in forbidden)
        cases.append({"name": "audit_allowlist", "sensitive_values_absent": True,
                      "audit": [json.loads(line) for line in audit.splitlines()]})
    return {"experiment": "loopback-http-gateway-v1", "checked_at": datetime.now(timezone.utc).isoformat(),
            "unit_contract": UNIT_CONTRACT, "cases": cases,
            "limits": ["one process; volatile ledger and audit", "loopback HTTP; fixture credentials",
                       "no vendor API or real model capability evaluation",
                       "queue bound excludes socket and HTTP library buffers",
                       "socket cancellation does not prove provider compute or billing stopped"]}


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--evidence", type=Path, help="Also save the executed experiment record as JSON")
    parser.add_argument("--serve", action="store_true", help="Serve a normal text fixture until Ctrl-C")
    parser.add_argument("--port", type=int, default=8088, help="Loopback gateway port in --serve mode")
    args = parser.parse_args()
    if args.serve:
        with ModelStub() as upstream, Gateway([Route("local-text", upstream.url)], port=args.port) as gateway:
            print(json.dumps({"gateway": gateway.url, "credential": "tenant-a-secret",
                              "unit_contract": UNIT_CONTRACT, "capabilities": ["text"]}), flush=True)
            try:
                while True:
                    time.sleep(0.2)
            except KeyboardInterrupt:
                print(gateway.audit.jsonl())
        return
    result = experiments()
    text = json.dumps(result, ensure_ascii=False, indent=2) + "\n"
    if args.evidence:
        args.evidence.parent.mkdir(parents=True, exist_ok=True)
        args.evidence.write_text(text)
    print(text, end="")


if __name__ == "__main__":
    main()

lab/evidence/demo.json

{
  "experiment": "loopback-http-gateway-v1",
  "checked_at": "2026-10-21T08:14:07.810190+00:00",
  "unit_contract": "utf8-bytes-v1",
  "cases": [
    {
      "name": "authorized_safe_failover",
      "http_status": 200,
      "events": [
        "token",
        "token",
        "usage",
        "done"
      ],
      "upstream_operations": [
        0,
        1
      ],
      "accounting": {
        "reservation_units": 102,
        "actual_units": 13,
        "usage_state": "settled",
        "outcome": "completed",
        "active": false
      },
      "balance": {
        "budget_units": 10000,
        "spent_units": 13,
        "held_units": 0,
        "available_units": 9987,
        "active": 0
      }
    },
    {
      "name": "interrupted_not_replayed",
      "http_status": 200,
      "events": [
        "token",
        "error"
      ],
      "backup_requests": 0,
      "accounting_before_reconciliation": {
        "reservation_units": 22,
        "actual_units": null,
        "usage_state": "unknown",
        "outcome": "failed",
        "active": false
      },
      "balance_held": {
        "budget_units": 110,
        "spent_units": 0,
        "held_units": 22,
        "available_units": 88,
        "active": 0
      },
      "authoritative_fixture_units": 6,
      "balance_after_reconciliation": {
        "budget_units": 110,
        "spent_units": 6,
        "held_units": 0,
        "available_units": 104,
        "active": 0
      }
    },
    {
      "name": "cancel_and_concurrency",
      "concurrent_http_status": 429,
      "cancel_http_status": 202,
      "upstream_observed_disconnect": true,
      "accounting": {
        "reservation_units": 1002,
        "actual_units": null,
        "usage_state": "unknown",
        "outcome": "cancelled",
        "active": false
      },
      "balance": {
        "budget_units": 10000,
        "spent_units": 0,
        "held_units": 1002,
        "available_units": 8998,
        "active": 0
      }
    },
    {
      "name": "slow_consumer",
      "queue_capacity_events": 2,
      "queue_peak": 2,
      "backpressure_observed": true,
      "upstream_observed_disconnect": true,
      "usage_state": "unknown"
    },
    {
      "name": "audit_allowlist",
      "sensitive_values_absent": true,
      "audit": [
        {
          "event": "admitted",
          "request_id": "privacy",
          "reservation_units": 119,
          "tenant": "tenant-a",
          "time": 1792570447.765
        },
        {
          "attempt": 1,
          "event": "route_attempt",
          "request_id": "privacy",
          "route": "primary",
          "tenant": "tenant-a",
          "time": 1792570447.765
        },
        {
          "actual_units": 42,
          "event": "finished",
          "outcome": "completed",
          "reason": "final_usage_received",
          "request_id": "privacy",
          "tenant": "tenant-a",
          "time": 1792570447.783,
          "usage_state": "settled"
        }
      ]
    }
  ],
  "limits": [
    "one process; volatile ledger and audit",
    "loopback HTTP; fixture credentials",
    "no vendor API or real model capability evaluation",
    "queue bound excludes socket and HTTP library buffers",
    "socket cancellation does not prove provider compute or billing stopped"
  ]
}

lab/gateway.py

"""Small inspectable HTTP gateway with conservative replay and accounting rules.

Teaching constraints: loopback HTTP only, one process/in-memory state, one credit
per UTF-8 byte, no real model SDKs. No claim of durable accounting, authentication
for an untrusted network, precise model cancellation or production isolation.
"""
from dataclasses import dataclass
from http.server import BaseHTTPRequestHandler
import http.client
import json
import queue
import re
import socket
import threading
import time
from urllib.parse import urlsplit

from .protocol import UNIT_CONTRACT, ProtocolError, encode_event, end_chunks, read_events, send_chunk
from .upstream import QuietServer


@dataclass(frozen=True)
class Route:
    name: str
    url: str
    capabilities: frozenset = frozenset({"text"})
    credential: str = "fixture-upstream-secret"
    trust_not_started: bool = False

    def __post_init__(self):
        parsed = urlsplit(self.url)
        if parsed.scheme != "http" or parsed.hostname != "127.0.0.1" or not parsed.port:
            raise ValueError("The lab accepts loopback HTTP upstreams only")


@dataclass(frozen=True)
class TenantPolicy:
    budget_units: int = 10000
    max_concurrent: int = 2


@dataclass(frozen=True)
class Settings:
    queue_events: int = 2
    max_attempts: int = 2
    upstream_timeout: float = 1.0
    client_write_timeout: float = 0.3
    max_output_units: int = 8 * 1024 * 1024
    max_input_bytes: int = 32768


class AdmissionError(Exception):
    def __init__(self, code, status):
        self.code, self.status = code, status


class StreamFailure(Exception):
    pass


class Cancelled(StreamFailure):
    pass


class OpenFailure(Exception):
    def __init__(self, code, safe=False):
        self.code, self.safe = code, safe


class Audit:
    ALLOWED = {"event", "request_id", "tenant", "route", "attempt", "reason",
               "outcome", "usage_state", "reservation_units", "actual_units"}

    def __init__(self):
        self.records, self.lock = [], threading.Lock()

    def emit(self, event, **fields):
        # Field allowlist excludes prompt, generated text, request headers and
        # arbitrary exception strings; do not log arbitrary upstream objects.
        record = {"event": event, **fields}
        if not set(record) <= self.ALLOWED:
            raise ValueError("Unapproved audit field")
        with self.lock:
            self.records.append({"time": round(time.time(), 3), **record})

    def jsonl(self):
        with self.lock:
            return "\n".join(json.dumps(r, ensure_ascii=False, sort_keys=True) for r in self.records)


class Ledger:
    def __init__(self, policies):
        self.policies = policies
        self.runs, self.lock = {}, threading.Lock()

    def _totals(self, tenant):
        records = [r for (owner, _), r in self.runs.items() if owner == tenant]
        held = sum(r["reservation_units"] for r in records if r["usage_state"] in {"held", "unknown"})
        spent = sum(r["actual_units"] or 0 for r in records if r["usage_state"] == "settled")
        return records, held, spent, sum(r["active"] for r in records)

    def reserve(self, tenant, request_id, units):
        with self.lock:
            if (tenant, request_id) in self.runs:
                raise AdmissionError("duplicate_request_id", 409)
            _, held, spent, active = self._totals(tenant)
            policy = self.policies[tenant]
            if active >= policy.max_concurrent:
                raise AdmissionError("concurrency_limit", 429)
            if units > policy.budget_units - held - spent:
                raise AdmissionError("budget_exhausted", 402)
            self.runs[tenant, request_id] = {
                "reservation_units": units, "actual_units": None,
                "usage_state": "held", "outcome": "admitted", "active": True,
            }

    def finish(self, tenant, request_id, outcome, actual_units=None, known_unstarted=False):
        with self.lock:
            record = self.runs[tenant, request_id]
            if not record["active"]:
                return dict(record)
            if actual_units is not None and not 0 <= actual_units <= record["reservation_units"]:
                raise ValueError("usage_outside_reservation")
            record.update(active=False, outcome=outcome, actual_units=actual_units)
            record["usage_state"] = ("settled" if actual_units is not None else
                                     "released" if known_unstarted else "unknown")
            return dict(record)

    def reconcile(self, tenant, request_id, actual_units):
        """Operator-only in-process action after authoritative billing evidence.

        There is deliberately no client HTTP endpoint that can forgive debt.
        Zero is valid only if an operator has evidence that nothing was billed.
        """
        with self.lock:
            record = self.runs[tenant, request_id]
            if record["usage_state"] != "unknown":
                raise ValueError("only_unknown_usage_can_be_reconciled")
            if type(actual_units) is not int or not 0 <= actual_units <= record["reservation_units"]:
                raise ValueError("invalid_authoritative_usage")
            record.update(usage_state="settled", actual_units=actual_units)

    def record(self, tenant, request_id):
        with self.lock:
            record = self.runs.get((tenant, request_id))
            return dict(record) if record else None

    def snapshot(self, tenant):
        with self.lock:
            _, held, spent, active = self._totals(tenant)
            return {"budget_units": self.policies[tenant].budget_units,
                    "spent_units": spent, "held_units": held,
                    "available_units": self.policies[tenant].budget_units - held - spent,
                    "active": active}


class Running:
    def __init__(self, tenant, request_id):
        self.tenant, self.request_id = tenant, request_id
        self.cancelled = threading.Event()
        self.socket = None
        self.lock = threading.Lock()
        self.processing_possible = False
        self.metrics = {"queue_peak": 0, "backpressure_waits": 0, "producer_stopped": True}

    def bind_socket(self, sock):
        with self.lock:
            self.socket = sock
            if self.cancelled.is_set():
                self._shutdown(sock)

    @staticmethod
    def _shutdown(sock):
        if sock is not None:
            try:
                sock.shutdown(socket.SHUT_RDWR)
            except OSError:
                pass

    def close_upstream(self):
        with self.lock:
            self._shutdown(self.socket)

    def cancel(self):
        self.cancelled.set()
        self.close_upstream()

    def metric(self, key, value=None):
        with self.lock:
            if value is None:
                self.metrics[key] += 1
            else:
                self.metrics[key] = value

    def stats(self):
        with self.lock:
            return dict(self.metrics)


class Gateway:
    def __init__(self, routes, policies=None, credentials=None, settings=None, port=0):
        self.routes = routes
        self.settings = settings or Settings()
        if self.settings.queue_events < 1 or self.settings.max_attempts < 1:
            raise ValueError("queue_events and max_attempts must be positive")
        self.ledger = Ledger(policies or {"tenant-a": TenantPolicy()})
        self.credentials = credentials or {"tenant-a-secret": "tenant-a"}
        self.audit, self.running, self.lock = Audit(), {}, threading.Lock()
        owner = self

        class Handler(BaseHTTPRequestHandler):
            protocol_version = "HTTP/1.1"

            def log_message(self, *args):
                pass

            def authenticate(self):
                value = self.headers.get("Authorization", "")
                return owner.credentials.get(value[7:]) if value.startswith("Bearer ") else None

            def reply(self, status, payload):
                self.close_connection = True
                data = json.dumps(payload).encode()
                try:
                    self.send_response(status)
                    self.send_header("Content-Type", "application/json")
                    self.send_header("Content-Length", str(len(data)))
                    self.send_header("Connection", "close")
                    self.end_headers()
                    self.wfile.write(data)
                except OSError:
                    pass

            def do_DELETE(self):
                tenant = self.authenticate()
                if tenant is None:
                    self.reply(401, {"error": "unauthorized"})
                    return
                prefix = "/v1/runs/"
                request_id = self.path[len(prefix):] if self.path.startswith(prefix) else ""
                with owner.lock:
                    run = owner.running.get((tenant, request_id))
                if run is None:
                    self.reply(404, {"error": "not_found"})
                    return
                if not owner.ledger.record(tenant, request_id)["active"]:
                    self.reply(409, {"error": "already_finished"})
                    return
                run.cancel()
                owner.audit.emit("cancel_requested", tenant=tenant, request_id=request_id)
                self.reply(202, {"request_id": request_id, "state": "cancellation_requested"})

            def do_POST(self):
                self.close_connection = True
                self.connection.settimeout(owner.settings.client_write_timeout)
                if self.path != "/v1/generate":
                    self.reply(404, {"error": "not_found"})
                    return
                tenant = self.authenticate()
                if tenant is None:
                    self.reply(401, {"error": "unauthorized"})
                    return
                try:
                    size = int(self.headers.get("Content-Length", "0"))
                    if not 0 < size <= 65536:
                        raise ValueError
                    request = json.loads(self.rfile.read(size))
                    owner.validate(request)
                except (ValueError, TypeError, KeyError, OSError):
                    self.reply(400, {"error": "invalid_request"})
                    return
                candidates = [route for route in owner.routes
                              if set(request["capabilities"]) <= route.capabilities]
                if not candidates:
                    self.reply(422, {"error": "unsupported_capability"})
                    return
                request_id = request["request_id"]
                reservation = len(request["prompt"].encode("utf-8")) + request["max_output_units"]
                try:
                    owner.ledger.reserve(tenant, request_id, reservation)
                except AdmissionError as exc:
                    self.reply(exc.status, {"error": exc.code})
                    return
                run = Running(tenant, request_id)
                with owner.lock:
                    owner.running[tenant, request_id] = run
                owner.audit.emit("admitted", tenant=tenant, request_id=request_id, reservation_units=reservation)
                owner.serve(self, run, request, candidates)

        self.server = QuietServer(("127.0.0.1", port), Handler)
        self.thread = threading.Thread(target=lambda: self.server.serve_forever(poll_interval=0.02), daemon=True)

    def validate(self, request):
        allowed = {"request_id", "prompt", "capabilities", "max_output_units", "allow_failover"}
        if not isinstance(request, dict) or not set(request) <= allowed:
            raise ValueError
        if not isinstance(request["request_id"], str) or not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", request["request_id"]):
            raise ValueError
        if not isinstance(request["prompt"], str) or len(request["prompt"].encode("utf-8")) > self.settings.max_input_bytes:
            raise ValueError
        if type(request["max_output_units"]) is not int or not 1 <= request["max_output_units"] <= self.settings.max_output_units:
            raise ValueError
        capabilities = request["capabilities"]
        if not isinstance(capabilities, list) or not capabilities or len(capabilities) > 16:
            raise ValueError
        if any(not isinstance(c, str) or not re.fullmatch(r"[a-z][a-z0-9_]{0,31}", c) for c in capabilities):
            raise ValueError
        if "allow_failover" in request and type(request["allow_failover"]) is not bool:
            raise ValueError

    def _open(self, run, route, request):
        parsed = urlsplit(route.url)
        connection = http.client.HTTPConnection(parsed.hostname, parsed.port,
                                                timeout=self.settings.upstream_timeout)
        try:
            connection.connect()
        except OSError as exc:
            connection.close()
            raise OpenFailure("connect_failed") from exc
        run.bind_socket(connection.sock)
        if run.cancelled.is_set():
            connection.close()
            raise Cancelled("cancelled")
        # Once sending begins, a transport failure is ambiguous. The code does
        # not infer safe replay from zero model tokens or an empty response.
        run.processing_possible = True
        try:
            connection.request("POST", "/generate", body=json.dumps({
                "prompt": request["prompt"], "max_output_units": request["max_output_units"],
                "capabilities": request["capabilities"],
            }).encode(), headers={"Content-Type": "application/json",
                                  "Authorization": "Bearer " + route.credential})
            response = connection.getresponse()
            safe = (response.status == 503 and route.trust_not_started and
                    response.getheader("X-Lab-Execution") == "not-started")
            if response.status != 200:
                if safe:
                    run.processing_possible = False
                run.close_upstream()
                connection.close()
                raise OpenFailure("upstream_not_started" if safe else "upstream_status_unknown", safe)
            if not response.getheader("Content-Type", "").startswith("text/event-stream"):
                run.close_upstream()
                connection.close()
                raise OpenFailure("upstream_protocol_unknown")
            return connection, response
        except (OSError, http.client.HTTPException) as exc:
            run.close_upstream()
            connection.close()
            raise OpenFailure("upstream_transport_unknown") from exc

    def serve(self, handler, run, request, candidates):
        connection = response = producer = None
        stop = threading.Event()
        emitted = False
        outcome, reason, actual = "failed", "unavailable", None
        try:
            limit = min(len(candidates), self.settings.max_attempts) if request.get("allow_failover", False) else 1
            for attempt, route in enumerate(candidates[:limit], 1):
                self.audit.emit("route_attempt", tenant=run.tenant, request_id=run.request_id,
                                route=route.name, attempt=attempt)
                try:
                    connection, response = self._open(run, route, request)
                    break
                except OpenFailure as exc:
                    reason = exc.code
                    self.audit.emit("route_failed", tenant=run.tenant, request_id=run.request_id,
                                    route=route.name, attempt=attempt, reason=reason)
                    if not exc.safe or attempt == limit:
                        raise StreamFailure(reason)
            events = queue.Queue(maxsize=self.settings.queue_events)

            def put(item):
                while not stop.is_set() and not run.cancelled.is_set():
                    try:
                        events.put(item, timeout=0.02)
                        run.metric("queue_peak", max(run.stats()["queue_peak"], events.qsize()))
                        return
                    except queue.Full:
                        run.metric("backpressure_waits")

            def produce():
                run.metric("producer_stopped", False)
                try:
                    for event in read_events(response):
                        put(event)
                        if stop.is_set() or run.cancelled.is_set():
                            return
                    put(("_eof", {}))
                except (OSError, ValueError, ProtocolError, http.client.HTTPException):
                    put(("_broken", {}))
                finally:
                    run.metric("producer_stopped", True)

            producer = threading.Thread(target=produce, daemon=True)
            producer.start()
            produced, usage = 0, None
            while True:
                if run.cancelled.is_set():
                    raise Cancelled("cancelled")
                try:
                    event, data = events.get(timeout=0.05)
                except queue.Empty:
                    continue
                if event == "token":
                    if usage is not None or set(data) != {"text"} or not isinstance(data["text"], str):
                        raise StreamFailure("invalid_token")
                    produced += len(data["text"].encode("utf-8"))
                    if produced > request["max_output_units"]:
                        raise StreamFailure("output_exceeds_reservation")
                elif event == "usage":
                    if usage is not None or set(data) != {"contract", "input_units", "output_units"}:
                        raise StreamFailure("invalid_usage")
                    if (data["contract"] != UNIT_CONTRACT or type(data["input_units"]) is not int or
                            type(data["output_units"]) is not int or
                            data["input_units"] != len(request["prompt"].encode("utf-8")) or
                            data["output_units"] != produced):
                        raise StreamFailure("untrusted_usage")
                    usage = data
                elif event == "done":
                    if usage is None or data:
                        raise StreamFailure("missing_final_usage")
                else:
                    raise StreamFailure("upstream_stream_interrupted")
                if not emitted:
                    handler.connection.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, 4096)
                    handler.send_response(200)
                    handler.send_header("Content-Type", "text/event-stream")
                    handler.send_header("Transfer-Encoding", "chunked")
                    handler.send_header("Cache-Control", "no-store")
                    handler.send_header("Connection", "close")
                    handler.send_header("X-Request-ID", run.request_id)
                    handler.end_headers()
                    emitted = True
                send_chunk(handler, encode_event(event, data))
                if event == "done":
                    end_chunks(handler)
                    actual = usage["input_units"] + usage["output_units"]
                    outcome, reason = "completed", "final_usage_received"
                    break
        except Cancelled:
            outcome, reason = "cancelled", "cancelled"
        except StreamFailure as exc:
            reason = str(exc)  # Only locally-defined constant codes reach here.
        except (OSError, http.client.HTTPException, ProtocolError):
            outcome, reason = "cancelled", "downstream_disconnected_or_slow"
        finally:
            stop.set()
            run.close_upstream()
            if producer is not None:
                producer.join(self.settings.upstream_timeout + 0.5)
            if response is not None:
                response.close()
            if connection is not None:
                connection.close()
            record = self.ledger.finish(run.tenant, run.request_id, outcome, actual,
                                        known_unstarted=not run.processing_possible)
            self.audit.emit("finished", tenant=run.tenant, request_id=run.request_id,
                            outcome=outcome, reason=reason, usage_state=record["usage_state"],
                            actual_units=actual)
            if actual is None:
                error = {"code": reason, "usage_state": record["usage_state"]}
                if emitted:
                    try:
                        send_chunk(handler, encode_event("error", error))
                        end_chunks(handler)
                    except OSError:
                        pass
                else:
                    handler.reply(409 if outcome == "cancelled" else 503, {"error": error})

    @property
    def url(self):
        return "http://127.0.0.1:" + str(self.server.server_port)

    def run_stats(self, tenant, request_id):
        with self.lock:
            run = self.running[tenant, request_id]
        return run.stats()

    def start(self):
        self.thread.start()
        return self

    def close(self):
        with self.lock:
            runs = list(self.running.values())
        for run in runs:
            run.cancel()
        self.server.shutdown()
        self.server.server_close()
        self.thread.join(2)

    def __enter__(self):
        return self.start()

    def __exit__(self, *args):
        self.close()

lab/protocol.py

"""The book's explicit teaching protocol; this is not an OpenAI-compatible API."""
import json

MAX_FRAME_BYTES = 32 * 1024
UNIT_CONTRACT = "utf8-bytes-v1"


class ProtocolError(Exception):
    pass


def encode_event(event, data):
    wire = ("event: " + event + "\ndata: " + json.dumps(data, ensure_ascii=False,
            separators=(",", ":")) + "\n\n").encode("utf-8")
    if len(wire) > MAX_FRAME_BYTES:
        raise ProtocolError("frame_too_large")
    return wire


def read_events(response):
    """Read complete bounded SSE frames from an HTTPResponse, including chunk decoding.

    Deliberately accept only the event/data subset used by the lab. A truncated
    final frame is an error, never an event that can be executed or billed.
    """
    event, data, size = None, [], 0
    first_line = True
    for line, wire_size, terminated in _lines(response):
        size += wire_size
        if size > MAX_FRAME_BYTES:
            raise ProtocolError("frame_too_large")
        if not terminated:
            raise ProtocolError("truncated_frame")
        try:
            text = line.decode("utf-8-sig" if first_line else "utf-8")
        except UnicodeDecodeError as exc:
            raise ProtocolError("invalid_utf8") from exc
        first_line = False
        if not text:
            if event is not None or data:
                try:
                    payload = json.loads("\n".join(data))
                except (ValueError, TypeError) as exc:
                    raise ProtocolError("invalid_event_json") from exc
                if event is None or not isinstance(payload, dict):
                    raise ProtocolError("invalid_event")
                yield event, payload
            event, data, size = None, [], 0
        elif text.startswith(":"):
            pass
        else:
            field, _, value = text.partition(":")
            if value.startswith(" "):
                value = value[1:]
            if field == "event":
                event = value
            elif field == "data":
                data.append(value)
            else:
                raise ProtocolError("unsupported_sse_field")
    if size:
        raise ProtocolError("truncated_frame")


def _lines(response):
    """Incremental SSE lines: CR, LF and CRLF, including split UTF-8/CRLF.

    Charge CR as two bytes immediately, reserving a possible following LF;
    skip that LF if present. This never undercounts wire bytes and dispatches
    CR-only empty lines immediately. A CR-only frame may be rejected slightly
    before the wire limit. The caller also bounds the sum of lines in a frame.
    """
    line = bytearray()
    skip_lf = False
    while True:
        chunk = response.read1(4096)
        if not chunk:
            if line:
                yield bytes(line), len(line), False
            return
        for byte in chunk:
            if skip_lf:
                skip_lf = False
                if byte == 10:
                    continue
            if byte == 13:
                yield bytes(line), len(line) + 2, True
                line.clear()
                skip_lf = True
            elif byte == 10:
                yield bytes(line), len(line) + 1, True
                line.clear()
            else:
                line.append(byte)
                if len(line) > MAX_FRAME_BYTES:
                    raise ProtocolError("frame_too_large")


def send_chunk(handler, data):
    handler.wfile.write(format(len(data), "x").encode() + b"\r\n" + data + b"\r\n")
    handler.wfile.flush()


def end_chunks(handler):
    handler.wfile.write(b"0\r\n\r\n")
    handler.wfile.flush()

lab/upstream.py

"""A real HTTP model substitute with observable execution and fault boundaries."""
from collections import deque
from dataclasses import dataclass
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json
import socket
import threading
import time

from .protocol import UNIT_CONTRACT, encode_event, end_chunks, send_chunk


class QuietServer(ThreadingHTTPServer):
    daemon_threads = True
    block_on_close = False
    request_queue_size = 64

    def handle_error(self, request, client_address):
        # Never print request bodies, authorization headers or exception text.
        pass


@dataclass(frozen=True)
class Plan:
    mode: str = "normal"
    chunks: tuple = ("hello", " gateway")
    delay: float = 0.01
    first_delay: float = 0.0
    raw_chunks: tuple = ()
    release_after_first_chunk: object = None


class ModelStub:
    """Loopback model service, not a simulated policy function.

    X-Lab-Execution: not-started is an explicit private protocol promise: for
    this one response the service did not start execution or accrue usage.
    The unknown_503 plan makes the opposite counterexample observable.
    """

    def __init__(self, plans=(), credential="fixture-upstream-secret"):
        self.plans = deque(plans)
        self.credential = credential
        self.lock = threading.Lock()
        self.requests = 0
        self.operations_started = 0
        self.events_written = 0
        self.disconnects = 0
        owner = self

        class Handler(BaseHTTPRequestHandler):
            protocol_version = "HTTP/1.1"

            def log_message(self, *args):
                pass

            def do_POST(self):
                self.close_connection = True
                if self.path != "/generate":
                    self.reply(404, {"error": "not_found"})
                    return
                if self.headers.get("Authorization") != "Bearer " + owner.credential:
                    self.reply(401, {"error": "unauthorized"})
                    return
                try:
                    n = int(self.headers.get("Content-Length", "0"))
                    if not 0 < n <= 65536:
                        raise ValueError
                    request = json.loads(self.rfile.read(n))
                    prompt = request["prompt"]
                    maximum = request["max_output_units"]
                    if not isinstance(prompt, str) or type(maximum) is not int or maximum < 1:
                        raise ValueError
                except (ValueError, KeyError, TypeError):
                    self.reply(400, {"error": "invalid_request"})
                    return
                with owner.lock:
                    owner.requests += 1
                    plan = owner.plans.popleft() if owner.plans else Plan()
                    if plan.mode != "safe_503":
                        owner.operations_started += 1
                if plan.mode in {"safe_503", "unknown_503"}:
                    self.reply(503, {"error": "unavailable"},
                               {"X-Lab-Execution": "not-started"} if plan.mode == "safe_503" else {})
                    return
                self.connection.setsockopt(socket.SOL_SOCKET, socket.SO_SNDBUF, 4096)
                self.connection.settimeout(3)
                try:
                    self.send_response(200)
                    self.send_header("Content-Type", "text/event-stream")
                    self.send_header("Transfer-Encoding", "chunked")
                    self.send_header("Connection", "close")
                    self.end_headers()
                    if plan.first_delay:
                        time.sleep(plan.first_delay)
                    if plan.raw_chunks:
                        for index, raw in enumerate(plan.raw_chunks):
                            send_chunk(self, raw)
                            if index == 0 and plan.release_after_first_chunk is not None:
                                plan.release_after_first_chunk.wait(3)
                        end_chunks(self)
                        return
                    if plan.mode == "pre_event_disconnect":
                        return  # No terminal chunk and no not-started promise.
                    produced = 0
                    for i, text in enumerate(plan.chunks):
                        text = text.encode("utf-8")[:maximum - produced].decode("utf-8", errors="ignore")
                        if not text:
                            break
                        if plan.delay:
                            time.sleep(plan.delay)
                        send_chunk(self, encode_event("token", {"text": text}))
                        produced += len(text.encode("utf-8"))
                        with owner.lock:
                            owner.events_written += 1
                        if plan.mode == "midstream_disconnect" and i == 0:
                            return
                    if plan.mode != "omit_usage":
                        usage = {"contract": UNIT_CONTRACT,
                                 "input_units": len(prompt.encode("utf-8")),
                                 "output_units": produced}
                        if plan.mode == "invalid_usage":
                            usage["output_units"] += 1
                        send_chunk(self, encode_event("usage", usage))
                    send_chunk(self, encode_event("done", {}))
                    end_chunks(self)
                except (OSError, TimeoutError):
                    with owner.lock:
                        owner.disconnects += 1

            def reply(self, status, payload, extra=None):
                data = json.dumps(payload).encode()
                self.send_response(status)
                self.send_header("Content-Type", "application/json")
                self.send_header("Content-Length", str(len(data)))
                self.send_header("Connection", "close")
                for name, value in (extra or {}).items():
                    self.send_header(name, value)
                self.end_headers()
                self.wfile.write(data)

        self.server = QuietServer(("127.0.0.1", 0), Handler)
        self.thread = threading.Thread(target=lambda: self.server.serve_forever(poll_interval=0.02), daemon=True)

    @property
    def url(self):
        return "http://127.0.0.1:" + str(self.server.server_port)

    def start(self):
        self.thread.start()
        return self

    def close(self):
        self.server.shutdown()
        self.server.server_close()
        self.thread.join(2)

    def __enter__(self):
        return self.start()

    def __exit__(self, *args):
        self.close()

tests/test_integration.py

"""Real loopback HTTP/SSE tests: no vendor SDKs, model keys or policy-only mocks."""
from concurrent.futures import ThreadPoolExecutor
from contextlib import ExitStack
import http.client
import json
import socket
import threading
import time
import unittest
from urllib.parse import urlsplit

from lab.client import cancel, generate, open_stream
from lab.gateway import Gateway, Route, Settings, TenantPolicy
from lab.protocol import UNIT_CONTRACT, read_events
from lab.upstream import ModelStub, Plan


def request(request_id="example", **changes):
    data = {"request_id": request_id, "prompt": "hi", "capabilities": ["text"],
            "max_output_units": 100}
    data.update(changes)
    return data


def eventually(check, timeout=3):
    deadline = time.monotonic() + timeout
    while time.monotonic() < deadline:
        result = check()
        if result:
            return result
        time.sleep(0.01)
    raise AssertionError("condition did not become true")


class GatewayHTTPTests(unittest.TestCase):
    def setUp(self):
        self.stack = ExitStack()
        self.addCleanup(self.stack.close)

    def stub(self, *plans):
        return self.stack.enter_context(ModelStub(plans))

    def gateway(self, routes, **kwargs):
        return self.stack.enter_context(Gateway(routes, **kwargs))

    def finished(self, gateway, request_id, tenant="tenant-a"):
        return eventually(lambda: (r if r and not r["active"] else None)
                          if (r := gateway.ledger.record(tenant, request_id)) is not None else None)

    def test_real_http_stream_and_trusted_byte_usage(self):
        stub = self.stub(Plan(chunks=("你好", " AI")))
        gateway = self.gateway([Route("local", stub.url)])
        status, events = generate(gateway.url, request(prompt="问题", max_output_units=20))
        self.assertEqual(status, 200)
        self.assertEqual([e for e, _ in events], ["token", "token", "usage", "done"])
        self.assertEqual(events[-2][1], {"contract": UNIT_CONTRACT, "input_units": 6, "output_units": 9})
        record = self.finished(gateway, "example")
        self.assertEqual(record["usage_state"], "settled")
        self.assertEqual(record["actual_units"], 15)
        self.assertEqual(gateway.ledger.snapshot("tenant-a")["held_units"], 0)
        self.assertEqual(stub.operations_started, 1)

    def test_capability_filter_precedes_admission(self):
        text, image = self.stub(), self.stub()
        gateway = self.gateway([Route("text-only", text.url),
                                Route("vision", image.url, frozenset({"text", "image"}))])
        status, _ = generate(gateway.url, request(capabilities=["image"]))
        self.assertEqual(status, 200)
        self.assertEqual((text.requests, image.requests), (0, 1))
        status, body = generate(gateway.url, request("audio", capabilities=["audio"]))
        self.assertEqual((status, body["error"]), (422, "unsupported_capability"))
        self.assertIsNone(gateway.ledger.record("tenant-a", "audio"))

    def test_default_never_replays_post_even_trusted_not_started(self):
        first, second = self.stub(Plan("safe_503")), self.stub()
        gateway = self.gateway([Route("first", first.url, trust_not_started=True), Route("second", second.url)])
        status, _ = generate(gateway.url, request())
        self.assertEqual(status, 503)
        self.assertEqual((first.requests, second.requests), (1, 0))
        self.assertEqual(self.finished(gateway, "example")["usage_state"], "released")

    def test_explicit_failover_with_trusted_not_started_promise(self):
        first, second = self.stub(Plan("safe_503")), self.stub()
        gateway = self.gateway([Route("first", first.url, trust_not_started=True), Route("second", second.url)])
        status, events = generate(gateway.url, request(allow_failover=True))
        self.assertEqual(status, 200)
        self.assertEqual(events[-1], ("done", {}))
        self.assertEqual((first.requests, first.operations_started, second.operations_started), (1, 0, 1))
        self.assertEqual(self.finished(gateway, "example")["usage_state"], "settled")

    def test_unknown_503_never_replayed_and_keeps_reservation(self):
        first, second = self.stub(Plan("unknown_503")), self.stub()
        gateway = self.gateway([Route("first", first.url, trust_not_started=True), Route("second", second.url)])
        status, _ = generate(gateway.url, request(allow_failover=True))
        self.assertEqual(status, 503)
        self.assertEqual((first.operations_started, second.requests), (1, 0))
        self.assertEqual(self.finished(gateway, "example")["usage_state"], "unknown")
        self.assertEqual(gateway.ledger.snapshot("tenant-a")["held_units"], 102)

    def test_untrusted_route_cannot_declare_safe_replay(self):
        first, second = self.stub(Plan("safe_503")), self.stub()
        gateway = self.gateway([Route("untrusted", first.url), Route("second", second.url)])
        status, _ = generate(gateway.url, request(allow_failover=True))
        self.assertEqual(status, 503)
        self.assertEqual(second.requests, 0)
        self.assertEqual(self.finished(gateway, "example")["usage_state"], "unknown")

    def test_failover_has_finite_attempt_bound(self):
        a, b, c = self.stub(Plan("safe_503")), self.stub(Plan("safe_503")), self.stub()
        gateway = self.gateway([Route("a", a.url, trust_not_started=True),
                                Route("b", b.url, trust_not_started=True), Route("c", c.url)])
        self.assertEqual(generate(gateway.url, request(allow_failover=True))[0], 503)
        self.assertEqual((a.requests, b.requests, c.requests), (1, 1, 0))
        self.assertEqual(self.finished(gateway, "example")["usage_state"], "released")

    def test_connect_failure_has_no_automatic_new_attempt(self):
        unavailable = socket.socket()
        unavailable.bind(("127.0.0.1", 0))  # Reserved port, deliberately not listening.
        self.addCleanup(unavailable.close)
        second = self.stub()
        gateway = self.gateway([Route("unavailable", "http://127.0.0.1:" + str(unavailable.getsockname()[1])),
                                Route("backup", second.url)])
        status, _ = generate(gateway.url, request(allow_failover=True))
        self.assertEqual(status, 503)
        self.assertEqual(second.requests, 0)
        self.assertEqual(self.finished(gateway, "example")["usage_state"], "released")

    def test_sse_line_endings_multidata_and_fragmented_utf8_over_http(self):
        for ending in ["\n", "\r\n", "\r"]:
            with self.subTest(ending=repr(ending)):
                lines = [": comment", "event:token", 'data:{"text":', 'data: "你"}', "",
                         "event:usage", 'data:{"contract":"utf8-bytes-v1","input_units":2,"output_units":3}', "",
                         "event:done", "data:{}", "", ""]
                wire = ending.join(lines).encode("utf-8")
                first_byte = wire.index("你".encode())
                chunks = (wire[:first_byte + 1], wire[first_byte + 1:first_byte + 2], wire[first_byte + 2:])
                stub = self.stub(Plan(raw_chunks=chunks))
                gateway = self.gateway([Route("local", stub.url)])
                status, events = generate(gateway.url, request())
                self.assertEqual(status, 200)
                self.assertEqual(events[0], ("token", {"text": "你"}))
                self.assertEqual(events[-1][0], "done")
                self.assertEqual(self.finished(gateway, "example")["actual_units"], 5)

    def test_eof_without_empty_sse_line_does_not_dispatch_partial_token(self):
        stub = self.stub(Plan(raw_chunks=(b'event:token\ndata:{"text":"partial"}\n',)))
        gateway = self.gateway([Route("local", stub.url)])
        status, body = generate(gateway.url, request())
        self.assertEqual(status, 503)
        self.assertEqual(body["error"]["usage_state"], "unknown")
        self.assertEqual(self.finished(gateway, "example")["usage_state"], "unknown")

    def test_cr_only_complete_event_is_dispatched_before_any_later_bytes(self):
        release = threading.Event()
        first = b'event:token\rdata:{"text":"x"}\r\r'
        later = (b'event:usage\rdata:{"contract":"utf8-bytes-v1","input_units":2,"output_units":1}\r\r'
                 b'event:done\rdata:{}\r\r')
        stub = self.stub(Plan(raw_chunks=(first, later), release_after_first_chunk=release))
        gateway = self.gateway([Route("local", stub.url)])
        with ThreadPoolExecutor(max_workers=1) as executor:
            future = executor.submit(open_stream, gateway.url, request())
            try:
                # Stub cannot write later bytes until we release it. Getting a
                # valid token here therefore proves immediate CR dispatch.
                connection, response = future.result(timeout=1)
                try:
                    events = read_events(response)
                    self.assertEqual(next(events), ("token", {"text": "x"}))
                    self.assertFalse(release.is_set())
                    release.set()
                    self.assertEqual(list(events)[-1], ("done", {}))
                finally:
                    response.close()
                    connection.close()
            finally:
                release.set()
        self.assertEqual(self.finished(gateway, "example")["actual_units"], 3)

    def test_oversized_sse_frame_is_rejected_before_forwarding(self):
        wire = b'event:token\ndata:{"text":"' + b'x' * (33 * 1024) + b'"}\n\n'
        stub = self.stub(Plan(raw_chunks=(wire,)))
        gateway = self.gateway([Route("local", stub.url)])
        status, _ = generate(gateway.url, request())
        self.assertEqual(status, 503)
        self.assertEqual(self.finished(gateway, "example")["usage_state"], "unknown")

    def test_no_output_does_not_prove_no_execution(self):
        first, second = self.stub(Plan("pre_event_disconnect")), self.stub()
        gateway = self.gateway([Route("first", first.url, trust_not_started=True), Route("second", second.url)])
        self.assertEqual(generate(gateway.url, request(allow_failover=True))[0], 503)
        self.assertEqual((first.operations_started, second.requests), (1, 0))
        self.assertEqual(self.finished(gateway, "example")["usage_state"], "unknown")

    def test_midstream_disconnect_reports_error_without_replay(self):
        first, second = self.stub(Plan("midstream_disconnect")), self.stub()
        gateway = self.gateway([Route("first", first.url, trust_not_started=True), Route("second", second.url)])
        status, events = generate(gateway.url, request(allow_failover=True))
        self.assertEqual(status, 200)
        self.assertEqual([e for e, _ in events], ["token", "error"])
        self.assertEqual(events[-1][1]["usage_state"], "unknown")
        self.assertEqual((first.operations_started, second.requests), (1, 0))
        self.finished(gateway, "example")

    def test_first_event_timeout_is_unknown_and_not_replayed(self):
        first, second = self.stub(Plan(first_delay=0.25)), self.stub()
        gateway = self.gateway([Route("first", first.url, trust_not_started=True), Route("second", second.url)],
                               settings=Settings(upstream_timeout=0.08))
        self.assertEqual(generate(gateway.url, request(allow_failover=True))[0], 503)
        self.assertEqual(second.requests, 0)
        self.assertEqual(self.finished(gateway, "example")["usage_state"], "unknown")

    def test_missing_or_false_usage_cannot_settle(self):
        for mode in ["omit_usage", "invalid_usage"]:
            with self.subTest(mode=mode):
                stub = self.stub(Plan(mode))
                gateway = self.gateway([Route("local", stub.url)])
                status, events = generate(gateway.url, request())
                self.assertEqual(status, 200)
                self.assertEqual(events[-1][0], "error")
                self.assertEqual(self.finished(gateway, "example")["usage_state"], "unknown")

    def test_client_cannot_set_usage_or_tenant(self):
        stub = self.stub()
        gateway = self.gateway([Route("local", stub.url)])
        for extra in [{"usage": {"output_units": 0}}, {"tenant": "tenant-b"}, {"allow_failover": "true"}]:
            with self.subTest(extra=extra):
                self.assertEqual(generate(gateway.url, request(**extra))[0], 400)
        self.assertEqual(stub.requests, 0)

    def test_output_text_is_data_and_never_accounting(self):
        text = '{"usage":{"input_units":0,"output_units":0}}'
        stub = self.stub(Plan(chunks=(text,)))
        gateway = self.gateway([Route("local", stub.url)])
        status, events = generate(gateway.url, request())
        self.assertEqual(status, 200)
        self.assertEqual(events[0], ("token", {"text": text}))
        self.assertEqual(self.finished(gateway, "example")["actual_units"], len(text) + 2)

    def test_atomic_tenant_concurrency_admission(self):
        stub = self.stub(Plan(chunks=("x",) * 200, delay=0.01))
        gateway = self.gateway([Route("local", stub.url)], policies={"tenant-a": TenantPolicy(max_concurrent=1)})
        connection, response = open_stream(gateway.url, request("held", max_output_units=200))
        self.addCleanup(connection.close)
        self.addCleanup(response.close)
        self.assertEqual(next(read_events(response))[0], "token")
        self.assertEqual(generate(gateway.url, request("excess"))[0], 429)
        self.assertEqual(stub.requests, 1)
        self.assertEqual(cancel(gateway.url, "held")[0], 202)
        self.finished(gateway, "held")

    def test_parallel_reservation_cannot_overspend(self):
        stub = self.stub(Plan(first_delay=0.05))
        gateway = self.gateway([Route("local", stub.url)],
                               policies={"tenant-a": TenantPolicy(budget_units=150, max_concurrent=10)})
        barrier = threading.Barrier(6)

        def call(i):
            barrier.wait()
            return generate(gateway.url, request("parallel-" + str(i)))[0]

        with ThreadPoolExecutor(max_workers=6) as executor:
            statuses = list(executor.map(call, range(6)))
        # Initial reservation is 102; after completion actual is 15. Timing can
        # admit more later calls, but the balance must never become negative.
        self.assertIn(200, statuses)
        self.assertIn(402, statuses)
        self.assertTrue(all(s in {200, 402} for s in statuses))
        self.assertGreaterEqual(gateway.ledger.snapshot("tenant-a")["available_units"], 0)

    def test_interrupted_usage_is_held_until_authoritative_reconciliation(self):
        stub = self.stub(Plan("midstream_disconnect"))
        gateway = self.gateway([Route("local", stub.url)],
                               policies={"tenant-a": TenantPolicy(budget_units=110)})
        generate(gateway.url, request())
        self.finished(gateway, "example")
        self.assertEqual(generate(gateway.url, request("blocked"))[0], 402)
        self.assertEqual(gateway.ledger.snapshot("tenant-a")["active"], 0)
        gateway.ledger.reconcile("tenant-a", "example", 7)
        self.assertEqual(gateway.ledger.snapshot("tenant-a")["spent_units"], 7)
        self.assertEqual(gateway.ledger.snapshot("tenant-a")["held_units"], 0)
        self.assertEqual(generate(gateway.url, request("after-reconcile"))[0], 200)

    def test_duplicate_id_does_not_duplicate_upstream_side_effect(self):
        stub = self.stub()
        gateway = self.gateway([Route("local", stub.url)])
        self.assertEqual(generate(gateway.url, request())[0], 200)
        self.finished(gateway, "example")
        self.assertEqual(generate(gateway.url, request())[0], 409)
        self.assertEqual(stub.operations_started, 1)

    def test_cancellation_is_tenant_scoped_and_propagates_upstream(self):
        stub = self.stub(Plan(chunks=("x",) * 1000, delay=0.005))
        gateway = self.gateway([Route("local", stub.url)],
                               policies={"tenant-a": TenantPolicy(), "tenant-b": TenantPolicy()},
                               credentials={"tenant-a-secret": "tenant-a", "tenant-b-secret": "tenant-b"})
        connection, response = open_stream(gateway.url, request("cancel-me", max_output_units=1000))
        self.addCleanup(connection.close)
        self.addCleanup(response.close)
        self.assertEqual(next(read_events(response))[0], "token")
        self.assertEqual(cancel(gateway.url, "cancel-me", "tenant-b-secret")[0], 404)
        self.assertTrue(gateway.ledger.record("tenant-a", "cancel-me")["active"])
        self.assertEqual(cancel(gateway.url, "cancel-me")[0], 202)
        record = self.finished(gateway, "cancel-me")
        self.assertEqual((record["outcome"], record["usage_state"]), ("cancelled", "unknown"))
        eventually(lambda: stub.disconnects > 0)
        eventually(lambda: gateway.run_stats("tenant-a", "cancel-me")["producer_stopped"])
        self.assertEqual(gateway.ledger.snapshot("tenant-a")["active"], 0)

    def test_slow_consumer_has_bounded_event_queue_and_backpressure(self):
        stub = self.stub(Plan(chunks=("x" * 2048,) * 2000, delay=0))
        gateway = self.gateway([Route("local", stub.url)],
                               policies={"tenant-a": TenantPolicy(budget_units=5_000_000)},
                               settings=Settings(queue_events=2, client_write_timeout=0.5))
        parsed = urlsplit(gateway.url)
        connection = http.client.HTTPConnection(parsed.hostname, parsed.port, timeout=5)
        connection.connect()
        connection.sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 1024)
        connection.request("POST", "/v1/generate", body=json.dumps(request("slow", max_output_units=4_096_000)),
                           headers={"Authorization": "Bearer tenant-a-secret", "Content-Type": "application/json"})
        response = connection.getresponse()
        self.addCleanup(connection.close)
        self.addCleanup(response.close)
        self.assertEqual(response.status, 200)
        eventually(lambda: gateway.run_stats("tenant-a", "slow")["backpressure_waits"] > 0)
        self.assertLessEqual(gateway.run_stats("tenant-a", "slow")["queue_peak"], 2)
        self.assertLess(stub.events_written, 2000)
        cancel(gateway.url, "slow")
        self.assertEqual(self.finished(gateway, "slow")["usage_state"], "unknown")
        eventually(lambda: stub.disconnects > 0)

    def test_downstream_socket_close_stops_upstream_without_explicit_cancel(self):
        stub = self.stub(Plan(chunks=("x",) * 1000, delay=0.005))
        gateway = self.gateway([Route("local", stub.url)])
        connection, response = open_stream(gateway.url, request("disconnect", max_output_units=1000))
        self.assertEqual(next(read_events(response))[0], "token")
        # Force a real TCP close, not just abandoning an iterator while keeping
        # an HTTPResponse buffered file and socket alive.
        response.fp.raw._sock.shutdown(socket.SHUT_RDWR)
        response.close()
        connection.close()
        self.assertEqual(self.finished(gateway, "disconnect")["usage_state"], "unknown")
        eventually(lambda: stub.disconnects > 0)

    def test_audit_is_useful_without_prompt_tokens_or_keys(self):
        marker = "sensitive-prompt-marker"
        output = "sensitive-generated-marker"
        stub = self.stub(Plan(chunks=(output,)))
        gateway = self.gateway([Route("local", stub.url)])
        generate(gateway.url, request(prompt=marker))
        self.finished(gateway, "example")
        audit = gateway.audit.jsonl()
        self.assertIn('"event": "route_attempt"', audit)
        self.assertIn('"usage_state": "settled"', audit)
        for secret in [marker, output, "tenant-a-secret", "fixture-upstream-secret", "Authorization"]:
            self.assertNotIn(secret, audit)
        self.assertEqual(generate(gateway.url, request("unauth"), "wrong-secret")[0], 401)
        self.assertEqual(stub.requests, 1)


if __name__ == "__main__":
    unittest.main()