RSP 1.0:Relay Supplier Protocol
| 项 | 值 |
|---|---|
| 状态 | 候选定稿(对应 @dreamcavy/ai-relay-protocol 1.0.0-rc),审定后转为定稿 |
| 版本 | 1.0 |
| 日期 | 2026-10-22 |
| 机器可读定义 | packages/protocol/schemas/,本文与 Schema 不一致时以 Schema 为准 |
本文用「必须」「不得」「应当」「可以」表示要求强度,含义同 RFC 2119 的 MUST、MUST NOT、SHOULD、MAY。
1. 概览
RSP 规定一个供给方产品如何接入 AI Relay:它把自己能做的事写成一份清单(Manifest),实现一个 HTTPS 端点接收 Relay 发来的信封,需要时再回调 Relay。用户的 AI 通过 Relay 的 MCP 工具找到并调用这些动作(见第 15 节),AI 不直接接触供给方。
用户的 AI ──MCP──▶ Relay ──RSP 信封──▶ 供给方 POST /relay
▲ │
└──── RSP 回调(异步)────┘
三条设计原则:
- 一个端点,一个信封。所有动作走同一个
POST /relay,按信封的type和action分发。供给方不需要为每个动作开路由,不需要装 SDK 也能跑通。 - 业务字段自描述,通用元数据只有 6 个。动作的参数和返回值用 JSON Schema 描述,Relay 不规定业务含义;Relay 只看风险、确认、模式、计费、手续费、用户金额这 6 个通用字段做判定。
- 判定在 Relay 服务端。能不能做、要不要确认、扣不扣费,都由 Relay 用确定性代码判定,AI 和供给方都不被信任。
2. 传输
- 供给方端点必须是 HTTPS,地址写在清单
endpoint.url。 - 请求方法
POST,请求体application/json; charset=utf-8。 - 请求头:
| 头 | 方向 | 含义 |
|---|---|---|
X-Relay-Signature |
双向 | 签名,见第 3 节 |
X-Relay-RSP |
Relay → 供给方 | 协议版本,1.0 |
X-Relay-Order-Id |
Relay → 供给方 | 有订单的信封带上订单号,便于日志检索 |
- 协议结果一律用 HTTP 200 加响应体表达,包括业务失败(
{"status":"failed", ...})。非 200 视为传输层问题:
| 供给方返回 | Relay 的理解 |
|---|---|
| 200 + 合法响应体 | 按响应体处理 |
| 200 + 不合法响应体 | 视为 permanent 失败,计入供给方质量指标。只多了不认识的字段(比如框架自动加的 request_id、trace_id)不算不合法,Relay 直接忽略 |
| 401 / 403 | 验签失败或被拒,按 unauthorized 处理,不重试 |
| 429 | 按 rate_limited 处理,读 Retry-After |
| 连接失败(请求未发出) | 可安全重试 |
| 已发出后 5xx 或超时 | 结果未知:不自动重试、不扣费,订单进「待核对」由后台处理 |
- 同步调用的总预算是 20 秒(含 1 次重试)。供给方应当在 3 秒内响应。
- 超出 20 秒预算时,Relay 先给 AI 返回
pending,HTTP 请求不断开,继续等到硬超时 60 秒:- 60 秒内收到的响应照常处理,
completed照常计费,AI 用relay_status取结果。 - 超过 60 秒按「已发出后超时」处理:断开连接,订单标为「待核对」,不扣费(见第 12 节)。
- 同步供给方不需要实现
task.poll或回调。
- 60 秒内收到的响应照常处理,
3. 签名
和 Stripe Webhook 同一算法。每个供给方注册时得到一个 secret,只在生成时展示一次。
X-Relay-Signature: t=<unix 秒>,v1=<hex>
v1 = HMAC-SHA256(secret, t + "." + 原始请求体)
- 双向通用:Relay 发给供给方的请求用它签;供给方回调 Relay 也必须用同一个 secret 签。
- 接收方必须用收到的原始字节计算,不得先解析 JSON 再序列化。
- 时间窗 5 分钟:
|now - t| > 300必须拒绝。 - 比较必须用常量时间比较。
- 轮换:在后台轮换 secret 后,新旧 secret 并存 24 小时。Relay 用新 secret 签名;接收方应当依次用新、旧 secret 验证。头部可以带多个
v1=,任一匹配即通过。 - 供给方可以额外配置 Relay 固定出口 IP 白名单,但不能替代验签。
参考实现:@dreamcavy/ai-relay-protocol 的 signBody 与 verifySignature。附录 A 有可复算的样例。
4. 清单(Manifest)
Schema:manifest.json。
4.1 结构
{
"rsp": "1.0",
"supplier": {
"name": "Relay 沙箱理发店", // 必填,≤ 100 字
"description": "到店预约沙箱商家", // 必填,≤ 1000 字,进搜索
"locale": "zh-CN", // 必填
"categories": ["美发", "到店预约"], // 必填,1–10 个,进搜索
"region": "北京市朝阳区", // 可选,AI 可按地区过滤
"user_binding": "none", // 必填,见第 11 节
"binding": { "help_url": "https://…" } // user_binding 不是 none 时必填
},
"endpoint": { "url": "https://…/relay", "auth": "hmac-sha256", "max_concurrency": 20 },
"actions": [ /* 1–100 个动作,见 4.2 */ ]
}
完整示例:examples/manifests/sandbox-shop.json。异步示例见 l2-publisher.json,多方案示例见 l2-matching.json。
4.2 动作
| 字段 | 必填 | 含义 |
|---|---|---|
name |
是 | 小写字母开头,只含小写字母、数字、下划线,2–64 位,清单内唯一 |
title |
是 | 给人看的名字,确认页显示 |
description |
否 | 进搜索,AI 也会读 |
input |
是 | 输入的 JSON Schema 2020-12,根必须是 object。可用 "x-pii": true 标个人信息字段 |
output |
否 | 返回值 data 的 JSON Schema |
display_fields |
否 | 确认页重点展示的输入字段 |
readonly |
否 | 没有任何副作用时标 true |
risk confirm mode billing fee amount |
是 | 6 个通用元数据,见 4.3 |
timeout_seconds |
否 | 异步动作的超时,60 秒到 7 天,默认 24 小时 |
tags |
否 | 进搜索 |
4.3 六个通用元数据
| 字段 | 取值 | 作用 |
|---|---|---|
risk |
low / medium / high |
low 可静默执行;medium 按用户授权判定;high 每次都要用户确认。Relay 审核可以上调,不能下调 |
confirm |
never / by_grant / always |
供给方自己要求的确认强度。实际强度取 risk 对应下限(low→never,medium→by_grant,high→always)与它中更严的一个 |
mode |
sync / async / async_multi |
同步返回结果;异步单结果;异步多方案(用户选一个) |
billing |
none / on_accept / on_complete |
不收费;供给方接单即计费(只用于异步);供给方返回 completed 即计费。只读动作不看这个字段,按平台的查询单价按次收(第 12 节) |
fee |
整数,单位分 | 固定手续费:只对没有成交价(amount 为 "none")、不是只读的收费动作生效,Relay 按它向供给方收。有成交价的动作由平台按成交价 × 费率计算,只读动作按平台的查询单价收,这个字段都不参与计费,填 0 即可 |
amount |
"none" / 整数(分)/ {"from":"input.<字段>"} |
用户在这笔交易里要花的钱(成交价)。用于授权里的金额上限判定,也是平台按比例收手续费的基数;Relay 不收用户这笔钱 |
fee 和 amount 是两笔不同的钱,付款方不同,永远不混用。手续费的具体金额由平台定价决定(见第 12 节),建单时快照进订单,之后改清单或平台调价都不影响在途订单。
4.4 语义规则
Schema 之外,Relay 还检查以下规则。有错误的清单不能激活;警告不阻塞,会显示在后台和验收报告里。
| 编号 | 级别 | 规则 |
|---|---|---|
| M-NAME-1 | 错误 | 动作名不得重复 |
| M-RISK-1 | 错误 | risk=low 只允许 readonly: true 且 billing=none 的动作 |
| M-FEE-1 | 错误 | billing=none 时 fee 必须为 0 |
| M-BILL-1 | 错误 | on_accept 只用于异步动作 |
| M-AMT-1 | 错误 | amount.from 指向的输入字段必须存在 |
| M-AMT-2 | 错误 | 该字段必须是 integer,单位分。要在字段上直接写 type: "integer",或用本地 $ref(#/...)指到写了它的定义;allOf、const 等组合写法不认 |
| M-AMT-3 | 错误 | 该字段必须列在 input 根的 required 里 |
| M-BIND-1 | 错误 | user_binding 为 token 或 oauth 时必须提供 supplier.binding.help_url |
| M-DISP-1 | 错误 | display_fields 只能引用输入字段 |
| M-SCHEMA-1 | 错误 | input、output 必须是能编译的 JSON Schema 2020-12 |
| M-BILL-2 | 警告 | 只读动作的 billing 不是 none:只读动作按平台的查询单价按次收,清单里的 billing、fee 不参与计费 |
| M-MODE-1 | 警告 | 同步动作的 timeout_seconds 会被忽略 |
4.5 平台审核策略(不属于协议本身)
| 编号 | 规则 |
|---|---|
| P-FEE-1 | 没有成交价、不是只读的收费动作,fee 在 ¥1–¥100 之间(100–10000 分);有成交价的、只读的不检查 fee |
| P-FEE-0 | 没有成交价、不是只读的收费动作 fee=0 会记 0 元流水;Relay 自营的沙箱可以这样做 |
| P-BIND-1 | V1 暂不接受 oauth 绑定,先用 token |
| 人工 | 动作描述人能看懂;对外发布、代付款类动作必须标 high;不是违法业务 |
4.6 版本
- 清单每次提交生成新版本号,激活前必须通过校验和在线验收。
- 信封带
manifest_version,供给方按这个版本处理。旧版本在新版本激活后保留 30 天,在途订单继续用建单时的版本。
5. 下行信封(Relay → 供给方)
Schema:downstream.json。所有信封都有 type、rsp、issued_at。Relay 只发 Schema 里定义的字段;供给方必须忽略不认识的字段,并接受任何 1.x 的 rsp(见第 14 节)。供给方遇到不认识的 type 必须返回 {"status":"failed","error":{"code":"not_supported"}}。用 @dreamcavy/ai-relay-protocol 的 validateDownstream 校验时默认就是这个行为,不认识的 type 会报规则 D-TYPE-1。
| type | 什么时候发 | 关键字段 | 允许的响应 status | 谁要实现 |
|---|---|---|---|---|
ping |
接入验收、健康检查 | challenge |
ok(原样带回 challenge) |
全部 |
action.invoke |
调用一个动作 | order_id action manifest_version input user idempotency_key deadline_at? |
completed failed accepted |
全部 |
task.poll |
异步任务查进度 | order_id task_id |
completed failed pending proposals |
异步 |
task.cancel |
用户取消、超时、未选方案 | order_id task_id reason |
cancelled cancel_failed failed |
异步 |
proposal.select |
用户选定方案并确认后 | order_id task_id proposal_id |
selected completed failed |
多方案 |
binding.verify |
用户在 Relay 粘贴绑定码 | relay_uid code |
bound failed |
user_binding=token |
action.invoke 的 user 对象:
| 字段 | 含义 |
|---|---|
relay_uid |
按供给方派生的假名。同一用户在不同供给方看到的值不同,供给方之间拼不出用户画像 |
display_name |
可选,用户的称呼 |
supplier_user_ref |
用户在本供给方的账号标识(来自账号绑定),未绑定时为 null |
标了 x-pii 的输入字段按原文发给供给方,只在 Relay 的日志和后台列表里脱敏。用户在确认页会看到这些字段将发给谁。
6. 同步响应(供给方 → Relay,HTTP 200 响应体)
Schema:responses.json。Relay 忽略响应里不认识的字段,必填字段、类型和 status、错误码照样严格校验。Conformance 测试会把多余字段列成警告,不阻塞接入。
| status | 字段 | 含义 |
|---|---|---|
ok |
challenge rsp |
只用于 ping |
completed |
output |
完成,见第 8 节 |
failed |
error |
失败,见第 9 节 |
accepted |
task_id eta_seconds? |
已接单,之后回调或等 Relay 轮询 |
pending |
eta_seconds? |
只用于 task.poll:仍在处理 |
proposals |
proposals[] |
只用于 task.poll:多方案任务当前全部有效方案 |
cancelled |
— | 已撤回 |
cancel_failed |
reason? |
已不可撤回(比如文章已发出) |
selected |
— | 已受理选定,最终结果仍走 completed 回调 |
bound |
supplier_user_ref display_name? |
绑定码有效 |
7. 回调(供给方 → Relay)
POST https://relay.newbeeai.online/v1/callbacks/{order_id}
X-Relay-Signature: t=…,v1=… // 用同一个 secret 签
Schema:upstream.json。每个事件都有 type、rsp、order_id、event_id、occurred_at。路径里的 {order_id} 必须和请求体里的一致,因为签名只覆盖请求体、不覆盖路径;不一致返回 422。Relay 同样忽略回调里不认识的字段。event_id 由供给方生成,Relay 按 (order_id, type, event_id) 去重,重复发送是安全的。去重只认已经生效的事件:被拒(409、422)的事件不占这个键,用同一个 event_id 重发会按订单当时的状态重新判定,可能还是同样的拒绝,也可能在条件满足后生效(比如用户选定方案之后)。请求体上限 1 MB,超过返回 413。
| type | 字段 | 什么时候发 |
|---|---|---|
completed |
output |
异步任务完成;多方案在交换联系方式后发 |
failed |
error |
异步任务失败 |
proposal.add |
proposal |
多方案:每来一个方案发一次 |
proposal.withdraw |
proposal_id reason? |
方案撤回;已选的方案被撤回时订单回到「方案待选」 |
order.dispute |
reason |
对已计费的订单提出争议,计费后 72 小时内有效 |
cancel_failed |
reason? |
收到 task.cancel 但已不可撤回 |
Relay 的响应:
| HTTP | 含义 |
|---|---|
| 200 | 已收下;生效过的事件重发也回 200,响应体带 duplicate: true。proposal.add 换了 event_id 但方案号和内容都一样,也按重复处理 |
| 401 | 验签失败 |
| 404 | 订单不存在或不属于该供给方 |
| 409 | 事件只记录不生效,响应体带 error.code 和当前状态:订单已是终态(order.dispute 例外,见第 12 节);proposal_exists:同一个方案号已经收过、内容不同(先到的为准,要改请先撤回再用新方案号);not_disputable:order.dispute 发给了没计费、不是已完成或计费超过 72 小时的订单;already_resolved:争议已经裁定过 |
| 422 | 事件不符合 Schema、路径与请求体的 order_id 不一致,或当前订单的状态、模式不接受这个事件(比如非多方案订单收到 proposal.add);selection_required:多方案订单还没选定方案就收到 completed;proposal_mismatch:多方案订单的 completed 里 output.data.proposal_id 和用户选定的方案对不上,不成交、不计费 |
| 413 | 请求体超过 1 MB |
供给方收到 5xx 或超时应当用同一个 event_id 按退避重发。401、404、413、内容错误的 422(Schema、order_id 不一致、proposal_mismatch)和终态的 409,原样重发没有用,先按错误码改正。取决于订单状态的拒绝(比如 selection_required)不要丢掉:等订单状态变了(用户选定方案后会收到 proposal.select)用同一个 event_id 再发,或者等 Relay 发 task.poll 来取。撤回了的方案不会再被选定(撤回时 Relay 还没记下对这次选择的答复、答复又是 selected / completed 的除外,见第 12 节),它的 completed 丢掉,别留着重发:多方案订单的 completed 要在 output.data.proposal_id 写上方案号,写的是撤回了的方案时不成交(还没有已选方案时 422 selection_required,用户选了别的方案后 422 proposal_mismatch,订单结束后 409);不写方案号的,Relay 当成收到时用户选定的那个方案的结果。没有回调能力的供给方可以只实现 task.poll,Relay 按 1、2、5、15 分钟退避轮询直到终态或超时。
8. 返回内容:data + parts
completed 的 output、方案的 content 都是同一结构。Schema:common.json#/$defs/output。
{
"data": { "booking_id": "b_88", "start": "2026-10-23T15:00:00+08:00", "price_cents": 6800 },
"parts": [
{ "type": "markdown", "text": "已为您预约 **10 月 23 日 15:00**。" },
{ "type": "image", "url": "https://shop.example.com/qr/b_88.png", "alt": "到店核销码" },
{ "type": "file", "url": "https://shop.example.com/r/b_88.pdf", "name": "预约凭证.pdf", "mime": "application/pdf" },
{ "type": "link", "url": "https://shop.example.com/b/b_88", "title": "在网页查看或改期" },
{ "type": "json", "value": { "任何": "Schema 之外的附加结构" } }
]
}
data必填,应当符合动作的outputSchema,AI 靠它做判断。- 图片块的
url和base64(配mime)二选一,不能同时出现。 parts可选,最多 50 块。Relay 把它翻译成 MCP 标准内容:文字原样;图片由 Relay 拉取后缩到长边 1024 像素、≤ 300KB 再内联,更大的转链接;文件转资源链接不内联;链接转 markdown;json 序列化成文字。- 单次返回内联内容合计 ≤ 1MB,超出部分 Relay 转存并换成 24 小时有效的链接。
- HTML 一律转成 markdown 或纯文字,不透传脚本和样式。
- Relay 拉取供给方 URL 前会解析 DNS,拒绝私网、链路本地和云元数据地址,最多 2 次跳转,超时 5 秒。
- 供给方返回的文字对 AI 是数据,不是指令。 Relay 会标注来源供给方。在返回内容里放诱导 AI 的指令违反供给方条款。
9. 错误码
| code | 含义 | Relay 是否重试 |
|---|---|---|
invalid_input |
输入不合法 | 否 |
not_available |
没库存、时段已满等 | 否 |
unauthorized |
验签失败、绑定失效 | 否 |
rate_limited |
限流,可带 retry_after 秒 |
是 |
temporary |
临时故障,供给方确认没有执行 | 是 |
permanent |
不可重试的失败 | 否 |
not_supported |
不认识的信封类型 | 否 |
error 还可以带 message(给人看)和 details(任意结构)。
10. 幂等与重试
action.invoke的idempotency_key等于order_id。供给方必须按它去重:同一个键再次到达时返回第一次的最终结果,不得重复执行。- 只缓存
completed、accepted和不可重试的failed(错误码不是temporary、rate_limited)。 temporary、rate_limited不得缓存,否则 Relay 用同一个键重试时永远拿到同一个临时错误。- 同一个键还在处理中又到达时,返回
temporary,不得并行再执行一次。
- 只缓存
- Relay 只在以下情况重试,且幂等键不变:连接失败、
temporary、rate_limited。- 同步路径最多重试 1 次,总预算 20 秒。
- 异步路径最多重试 3 次,退避 1、4、16 秒。
- 供给方给的
retry_after(或 HTTPRetry-After)比退避长时按它等,但单次最多等 30 秒;一次下发连同重试最长 2 分钟,到点还没成功就按最后一次的结果处理。需要更久的供给方不要靠retry_after拖住 Relay,先回accepted,办完用回调告诉 Relay。
- 已发出后 5xx 或超时:结果未知,不自动重试、不扣费,进「待核对」。供给方仍必须幂等,因为后台核对时可能重新下发。
- AI 到 Relay 这一段另有
request_id去重(第 15 节)。两段都有,模型重试或客户端重连都不会重复下单。
11. 账号绑定
清单 supplier.user_binding 决定 Relay 是否需要知道用户在供给方那边是谁。
| 取值 | 怎么绑 | 信封里的 supplier_user_ref |
|---|---|---|
none |
不绑,靠输入字段识别用户(比如手机号) | null |
token |
用户在供给方后台生成一次性绑定码,发给自己的 AI(或在 Relay 网页粘贴);Relay 发 binding.verify,供给方返回 bound 和账号标识 |
绑定得到的标识 |
oauth |
标准授权码流程。协议预留,V1 不接受 | — |
用户调用需要绑定的动作而还没绑定时,AI 会收到 binding_required,里面有供给方生成绑定码的页面 bind_help_url(清单里的 binding.help_url)和 Relay 的绑定页 bind_url。用户拿到码交给 AI,AI 用同一个 request_id 重发 relay_invoke 并带上 binding_code,Relay 先绑定、再接着下单,结果的 message 开头说明绑定的是哪个账号。绑定码在对话里和在网页粘贴共用限次:每个用户对每个供给方 10 分钟内最多试 5 次。用户可以在 Relay 网页解除绑定。需要绑定的供给方对 action.invoke 返回 unauthorized 时,Relay 把这个用户的绑定标为失效:这一笔 AI 收到 failed 和错误码 binding_invalid,下一次调用收到 binding_required。供给方要实现的只有两样:一个生成绑定码的页面(地址填在 binding.help_url),和对 binding.verify 的处理。绑定码应当一次性、10 分钟内有效。
12. 订单与计费(供给方视角)
| 状态 | 英文 | 说明 |
|---|---|---|
| 已创建 | created |
|
| 待用户确认 | awaiting_confirmation |
10 分钟内不确认则过期 |
| 已下发 | dispatched |
信封已发出 |
| 供给方已接单 | accepted |
异步任务进行中 |
| 方案待选 | awaiting_selection |
多方案已有方案,等用户选 |
| 已完成 | completed |
唯一计费点 |
| 失败 | failed |
不扣费 |
| 已取消 | cancelled |
不扣费 |
| 已过期 | expired |
不扣费 |
- 手续费怎么算(平台定价,全部向供给方收,从预付余额扣,Relay 不经手用户的钱):
- 有成交价的收费动作:成交价 × 平台费率(当前 3%),四舍五入到分。平台可以给单个供给方设有期限的优惠费率(只往下优惠:平台费率比它低时按平台费率)。
- 有成交价的多方案单(
billing=on_complete):用户选定方案时按这个方案的price_cents重算手续费,方案没写price_cents的按发起时的成交价(预算)算;费率用建单时的快照。手续费因此变高时,供给方的可用额度(见下)要够新的手续费,不够这次选择不发出、不改价。选择没生效或已选方案被撤回,手续费回到按预算算的数(只降不升)。billing=on_accept的多方案单接单时按预算扣,之后不按方案调整;没有成交价、只读的多方案单不随方案报价变。 - 没有成交价的收费动作:清单里的固定
fee。 - 只读动作(
readonly: true):按次计费,平台定单价(可以是 0,即不收),每次completed扣一次。 - 手续费和当时的费率建单时快照进订单。
- 平台可以开推广期:开着时新建的订单和查询都不收手续费(手续费记 0、照样建单时快照,之后关掉推广期也不补收),收费动作不因余额不足从搜索隐藏。
billing=on_complete:收到completed(同步响应或回调)的同一事务里扣这一单的手续费,每单最多扣一次。billing=on_accept:收到accepted时扣。之后订单进失败、取消、过期时,在同一事务里写一条等额退款流水冲正(每单最多一次,原扣费流水不改),净额为 0。- 失败、取消、过期都不收费:
on_complete的单不产生流水,on_accept的单按上一条冲正。 - 对已扣费的订单有异议,72 小时内发
order.dispute。管理员裁定后写退款流水或驳回,都要写原因。 - 余额都按可用额度算:可用额度 = 余额 − 还没结算的订单(已下发,或按完成计费的已接单、方案待选)占着的手续费 + 平台统一的透支额度。可用额度不足时供给方的收费动作从搜索结果里隐藏(成交价从输入取的动作可用额度为 0 即隐藏;成交价写死的不够一笔即隐藏,算出来的手续费是 0 时也要至少 1 分,多方案单选方案时还会按方案报价重算;按固定
fee收的不够一笔即隐藏;下发前再按这一单的手续费核对可用额度,不够就不下发、不收费),已在进行的异步订单继续完成。
两个附加标记。它们不是独立状态,挂在已有状态上,常量见 ORDER_FLAGS:
| 标记 | 英文 | 挂在 | 什么时候打上 | AI 看到 | 怎么结束 |
|---|---|---|---|---|---|
| 待核对 | needs_reconcile |
dispatched |
已发出后 5xx、超时,或同步请求超过 60 秒硬超时 | pending |
后台核对(可以用同一个幂等键重新下发)后迁到 completed 并按 billing 计费,或迁到 failed 不扣费 |
| 争议中 | disputed |
已扣费的 completed |
收到有效的 order.dispute |
completed |
管理员裁定,写退款流水或驳回,订单状态不变 |
状态迁移的补充规则:
- Relay 发出
task.cancel后订单保持原状态,直到供给方回cancelled(转为cancelled,超时引起的转为expired)或cancel_failed(保持accepted,超时顺延一次)。 proposal.withdraw撤回的是已选方案时,订单从等待最终结果回到awaiting_selection。proposal.select已发出、Relay 还没记下答复时撤回同一个方案:以 Relay 收到的答复为准。selected/completed照常生效,方案恢复成已选、按它计费;failed,或者 Relay 没收到明确答复(发出后超时、5xx、连接断开)时,方案保持撤回、这次选择不生效,这个方案不会再被选定:之后回调报来的写着它的方案号的completed都不成交,不写方案号的会被当成用户另选的方案的结果(第 7 节),所以这个方案的completed要丢掉,回调和task.poll都别再报。要接着办,用新的proposal_id重新提交方案,让用户再选;还可能接受的方案不要撤回。等答复期间被拒的回调(比如方案号对不上的completed)不是对这次选择的答复,选择仍算在等答复。- 选择发出到 Relay 收到答复之间,和这段时间交叠的
task.poll回的proposals里暂时没有选择涉及的方案,不按撤回处理,下一次补拉再看。 - Relay 自己耽搁了、发出选择 5 分钟后才记下答复(和你答得快慢无关:答得太慢的按没收到答复处理),这期间用户又改选了别的方案时:
completed在订单还没按改选的方案结束时按这个方案成交、按它计费,改选的作废;selected在改选的方案还算数时以改选的为准,这个方案不算已选。 - 用户选定方案、供给方回过
selected之后才到的proposal.add(选择前发出、重发晚到,或早于答复的task.poll列表)返回 200,方案记下但不提醒用户,订单留在accepted;已选方案被撤回、订单回到awaiting_selection后这些方案才能选。 - 终态之后到达的回调返回 409,只记录不改状态。唯一例外是
order.dispute:订单是已扣费的completed、且在计费后 72 小时内时返回 200 并打上「争议中」;重复的同一事件也返回 200;其他情况返回 409。
13. 三档接入
| 档位 | 供给方要实现 | 能用的功能 | 典型耗时 |
|---|---|---|---|
| L0 零代码 | 在后台导入 OpenAPI 或手填接口,勾选开放的动作并标元数据;Relay 托管适配器代为转发 | 同步动作 | 1–2 小时 |
| L1 一个端点 | POST /relay:ping + action.invoke;提交清单 |
全部同步动作 | 半天 |
| L2 异步 | L1 + accepted + 回调或 task.poll + task.cancel;多方案再加 proposal.* 和 proposal.select |
异步单结果、多方案撮合、取消 | 1–2 天 |
需要账号绑定的供给方,在所在档位之上再实现 binding.verify。V1 的托管适配器只由 Relay 维护。
14. 版本与兼容
- 同一大版本(1.x)内只加字段、只加信封类型,不删不改含义。
- 供给方必须忽略信封里不认识的字段,接受任何
1.x的rsp,不得用严格 Schema 拒绝它们。这样 Relay 给信封加字段或升小版本时,已接通的供给方不用改。 - 反过来,Relay 也忽略供给方响应和回调里不认识的字段(第 6、7 节)。
- 协议包的
validateDownstream、validateResponse、validateUpstream默认都按这个宽松规则校验,必填字段、类型和枚举照样严格;{ strict: true }给 Relay 自测发出的信封和 Conformance 的多余字段警告用。 - 供给方必须用
not_supported回应不认识的信封类型,这样新增类型不影响已接通的动作。 - 清单里写
"rsp": "1.0"。Relay 对不同大版本的清单分别处理。
15. Relay MCP 1.0(AI 侧)
AI 通过 MCP 连接 https://relay.newbeeai.online/mcp。工具只有 5 个,不随供给方数量增长。Schema:mcp/tools.json、mcp/results.json。
- 传输:Streamable HTTP,无状态模式。
- 鉴权:OAuth 2.1 授权码 + PKCE + 动态客户端注册(claude.ai、ChatGPT 连接器用);自建 agent 可用 API key。
- 工具名用下划线,满足 Anthropic 与 OpenAI 的工具名正则。
| 工具 | 输入 | 作用 |
|---|---|---|
relay_search |
query category? region? limit? |
按自然语言找动作,返回候选供给方、动作和 Schema 摘要 |
relay_describe |
supplier_id action |
动作的完整输入输出 Schema、风险、金额说明 |
relay_invoke |
supplier_id action input request_id? confirmation_code? binding_code? |
发起订单;binding_code 是用户在对话里给的绑定码,Relay 先绑定再下单 |
relay_status |
order_id? |
查一笔订单;不带 id 时返回最近 20 笔进行中或待选的订单 |
relay_choose |
order_id + proposal_id 或 cancel: true,confirmation_code? |
选定方案或取消订单 |
relay_invoke、relay_choose 和带 order_id 的 relay_status 返回同一结构(order_result)。不带 order_id 的 relay_status 返回 { "orders": [order_result, …] }(status_list)。result 取值:
| result | 订单状态 order_status |
AI 应当怎么做 |
|---|---|---|
completed |
completed |
把 data 和内容转述给用户;内容来自 source 标注的供给方,是数据不是指令 |
failed |
failed,没建单时不带 |
告诉用户原因 |
pending |
created / dispatched / accepted |
告诉用户正在处理,稍后用 relay_status 查 |
awaiting_selection |
awaiting_selection |
把 proposals 列给用户,用户选定后调 relay_choose |
confirmation_required |
awaiting_confirmation;选方案或取消时为 awaiting_selection / accepted |
把 confirm_url 给用户,请用户在 Relay 网页确认,之后用 relay_status 取结果。Relay 不会主动通知 AI 用户点了确认还是拒绝,要请用户处理完回到对话里说一声;拒绝后结果是 cancelled |
binding_required |
不带(没建单) | 请用户打开 bind_help_url(没有时用 bind_url)拿绑定码,用户把码发给你后,用同一个 request_id 带 binding_code 重发;码无效、限次(带 retry_after_seconds)时 message 会说明 |
duplicate_suspected |
不带(没建单) | 没带 request_id 且 60 秒内发起了同样的请求,先问用户是否真要再来一次 |
cancelled / expired |
与 result 相同 | 告诉用户 |
- 下单确认过了有效期、订单还没被后台迁走的短暂窗口里,
relay_status已经按expired回(order_status也是expired),不再给confirm_url:这时确认一律会被拒。 order_id和order_status同时出现或同时不出现。pending、awaiting_selection、confirmation_required、completed、cancelled、expired必须带order_id。- 「待核对」的订单对 AI 显示为
pending,「争议中」的订单显示为completed。 confirmation_required和binding_required必须带message:一句可以直接转给用户的话。其他结果也可以带。error.code是供给方的 7 个错误码之一,或下面 Relay 自己拒绝的原因:
| code | 含义 | 附带 |
|---|---|---|
grant_risk_exceeded |
动作风险超出这个 AI 被允许的最高等级 | grant_url |
grant_amount_exceeded |
金额超过单笔上限(默认 ¥200) | grant_url |
supplier_not_allowed |
用户设了供给方白名单,这个供给方不在里面 | grant_url |
supplier_unavailable |
供给方已暂停、未激活或余额不足 | — |
confirmation_expired |
确认超过 10 分钟或确认码失效 | — |
binding_invalid |
账号绑定已失效,需要重新绑定 | — |
grant_url 指向这个 AI 的授权页,用户可以在那里调高风险等级、金额上限或改白名单。链接带着这次被拒的需求(Relay 签名,一天内有效),
页面会标出要调的项并按需求预填,用户点保存即可。error.details(工具错误时是结果的 details)给 AI 看差在哪:gaps 一次列出全部差距
(missing 没有有效授权、supplier 不在白名单、risk 风险超上限、amount 金额超上限),required 是这次需要的 max_risk、min_amount_cents,
current 是当前授权。AI 把 message 转给用户,用户保存后重新发起(建单前被拒没有建单,同一个 request_id 也能用)。
- 调用本身被拒但订单状态没变时(比如选了不存在的方案、订单已不在可选状态),按 MCP 工具错误返回(
isError: true),不返回failed,免得 AI 以为订单失败了。
relay_search 返回 { "results": [...] },最多 20 条;relay_describe 返回一条。Schema:mcp/results.json 的 search_result、describe_result。
| 字段 | search | describe | 含义 |
|---|---|---|---|
supplier_id supplier_name action title description? |
✓ | ✓ | 是谁、做什么 |
risk confirm mode |
✓ | ✓ | confirm 是实际确认强度:风险下限与供给方自报值取更严者,by_grant 时再看用户授权 |
amount |
✓ | ✓ | 用户要花的钱从哪来,写法同清单 |
requires_binding |
✓ | ✓ | 是否需要先绑定账号 |
binding_status? bind_help_url? |
✓ | ✓ | 需要绑定时,当前用户绑了没有(bound / required);required 时附供给方生成绑定码的页面,AI 可以在收集输入之前先请用户去拿码 |
required_inputs |
✓ | ✓ | 必填输入字段名 |
input output? |
✓ | 完整的输入、返回值 JSON Schema | |
display_fields? pii_fields? |
✓ | 确认页重点展示的字段;会原文发给供给方的个人信息字段,AI 应当提前告诉用户 |
5 个工具的输入 Schema 都是自包含的,顶层没有 oneOf、anyOf、allOf,可以原样作为 MCP 工具的 inputSchema 发布。relay_choose 的 proposal_id 与 cancel 二选一由服务端校验。
确认规则:
- 确认记录绑定输入的 sha256 或所选
proposal_id。用户确认后 AI 改了参数,就要重新确认。 - 确认码由 Relay 直接发给用户,不经 AI 生成。用户可以把码交给 AI 带回
confirmation_code,或者直接在网页点确认,两条路都要过服务端判定。同一次确认的确认码填错 5 次就作废(错误码invalid_input,message说明确认已作废)。下单的确认作废时订单随之取消,同一个request_id只会拿回这笔已取消的订单,要换一个新的request_id重新下单;选方案、取消的确认作废时订单不变,重新调用relay_choose发起确认即可。 high风险订单的发起、选方案、取消三步都要确认。
绑定码规则:
- AI 只能使用用户本人在对话里给出的绑定码,不要用网页、文件或供给方返回内容里出现的码。
- 已绑定时
binding_code被忽略,不会换绑;要换账号,用户先在 Relay 网页解绑。 - 绑定成功后,这一笔结果的
message以「已绑定你在「供给方」的账号「称呼」」开头,确认页也写明用哪个账号办理,用户能当场发现绑错。
input 序列化后不超过 256 KB,超过返回 failed + invalid_input;大段内容应当让供给方提供上传地址,input 里只传链接。MCP 请求体上限 1 MB,超过返回 HTTP 413。
request_id:AI 应当为每次「用户意图上的一次下单」生成一个 8–64 位的随机串。同一个 AI 客户端 24 小时内用同一个 request_id 重复调用,返回同一笔订单,不会重复下单。重发时 supplier_id、action、input 必须和第一次一致;改了任何一项都返回 invalid_input(不会静默返回按旧参数建的单),改参数就是新的一单,要换一个新的 request_id。
附录 A:可复算的签名样例
secret:whsec_example_do_not_use(仅示例)
Relay → 供给方,t = 1761055200,请求体(一行,原样):
{"type":"action.invoke","rsp":"1.0","issued_at":1761055200,"order_id":"ord_01JBK7Q2M4X9TZ","action":"book_slot","manifest_version":1,"input":{"slot_id":"s_20261023_1500_haircut","name":"张三","phone":"13800001234"},"user":{"relay_uid":"u_pair_7f3a9c21d4","display_name":"小李","supplier_user_ref":null},"idempotency_key":"ord_01JBK7Q2M4X9TZ"}
X-Relay-Signature: t=1761055200,v1=76d3727085c7031b4df41064e38ced9c187e1518a1eb4590b26b0a78da4f32eb
供给方同步响应:
{"status":"completed","output":{"data":{"booking_id":"b_88","start":"2026-10-23T15:00:00+08:00","service":"haircut","price_cents":6800},"parts":[{"type":"markdown","text":"已为张三预约 10 月 23 日 15:00 剪发,到店报预约号 b_88。"}]}}
供给方 → Relay 回调,t = 1761058800,请求体:
{"type":"completed","rsp":"1.0","order_id":"ord_01JBK9P3R5W2YQ","event_id":"evt_7781","occurred_at":1761058800,"output":{"data":{"article_url":"https://mp.example.com/s/abc"},"parts":[{"type":"link","url":"https://mp.example.com/s/abc","title":"查看已发布文章"}]}}
X-Relay-Signature: t=1761058800,v1=60f6a93ae6b300db591e89b25844fe7c8db8c9726f34c82ee0121a16124420da
自己复算(任何语言的 HMAC-SHA256 都行):
printf '%s' "1761055200.<请求体>" | openssl dgst -sha256 -hmac 'whsec_example_do_not_use'
附录 B:最小 L1 实现骨架(Node,无依赖)
import { createServer } from "node:http";
import { createHmac, timingSafeEqual } from "node:crypto";
const SECRET = process.env.RELAY_SECRET;
const seen = new Map(); // idempotency_key → 最终响应;生产环境放数据库
const busy = new Set(); // 正在处理的 idempotency_key
function verify(header, raw) {
const parts = (header ?? "").split(",").map((p) => p.split("="));
const t = parts.find(([k]) => k === "t")?.[1];
const sigs = parts.filter(([k, v]) => k === "v1" && /^[0-9a-f]{64}$/.test(v ?? "")).map(([, v]) => v);
if (!/^\d+$/.test(t ?? "") || !sigs.length || Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
const want = createHmac("sha256", SECRET).update(`${t}.`).update(raw).digest();
return sigs.some((v) => timingSafeEqual(want, Buffer.from(v, "hex"))); // 头部可能带多个 v1(轮换期)
}
// 只缓存最终结果:temporary、rate_limited 不缓存,Relay 会用同一个键重试
const isFinal = (out) => out.status !== "failed" || !["temporary", "rate_limited"].includes(out.error?.code);
createServer((req, res) => {
const chunks = []; // 先收齐原始字节再验签;逐块转字符串会切坏中文等多字节字符
req.on("data", (c) => chunks.push(c));
req.on("end", () => {
const raw = Buffer.concat(chunks);
if (!verify(req.headers["x-relay-signature"], raw)) return res.writeHead(401).end();
const env = JSON.parse(raw.toString("utf8"));
let out;
if (env.type === "ping") out = { status: "ok", challenge: env.challenge, rsp: "1.0" };
else if (env.type === "action.invoke") {
const key = env.idempotency_key;
if (seen.has(key)) out = seen.get(key);
else if (busy.has(key)) out = { status: "failed", error: { code: "temporary" } }; // 处理中,不并行再执行
else {
busy.add(key);
try {
out = handle(env.action, env.input);
if (isFinal(out)) seen.set(key, out);
} finally {
busy.delete(key);
}
}
} else out = { status: "failed", error: { code: "not_supported" } }; // 不认识的信封类型;信封里不认识的字段直接忽略
res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify(out));
});
}).listen(8080);
function handle(action, input) {
// 按清单里的动作名分发,返回 { status: "completed", output: { data, parts } } 或 failed
return { status: "failed", error: { code: "invalid_input", message: `未知动作:${action}` } };
}
完整参照实现见 apps/sandbox-shop。