AnyAIS

DASP/1.0 · NORMATIVE DOCUMENT

DASP/1.0 客户端接入规范

1. 模块定位

本文是 DASP/1.0 的客户端和 SDK 独立接入模块。完整的逐场景接口入参、出参、错误和注意事项见 dasp-api-reference-v1.md;服务端协议、存储、执行和容灾约束见 stateful-session-protocol-v1.md。

客户端只负责:

保存 sid
提交本次新增 input 和 model config
消费七种流事件
把 Item 投影为界面
掉线后从头恢复当前回答
按明确顺序停止、中途补充、编辑或删除
按需分页查询单会话或自动合并的多会话可见历史

客户端不得自行拼接完整模型上下文、计算压缩边界、伪造 Usage、重复提交恢复请求,或把 SID 当成身份凭证。

2. 接口速查

业务 方法与路径 SID 请求体
首次提交 POST /v1/session/responses 不发送 Input + Model Config
继续会话 POST /v1/session/responses 必须 Input + Model Config
恢复当前回答 GET /v1/session/responses 必须 无
停止当前回答 POST /v1/session/responses/cancel 必须 无
中途打断并补充 POST /v1/session/responses/interrupt 必须 补充 Input + Model Config
编辑最近用户消息 PUT /v1/session/responses 必须 替换 Input + Model Config
查询或合并历史 GET /v1/session/history?limit=&order=&cursor= 1~100 个逗号分隔 SID 无
更新 System Prompt PATCH /v1/session 必须 system_prompt
删除会话 DELETE /v1/session 必须 无

所有请求仍需正常携带平台认证信息。SID 只定位会话,不能代替 API Key、JWT 或租户认证。

3. 最小数据类型

export type SessionItem =
  | { id?: string; type: "text"; status?: ItemStatus; data: string }
  | { id: string; type: "reasoning"; status: ItemStatus; output?: string }
  | { id: string; type: "compaction"; status: ItemStatus; output?: string; usage?: Usage }
  | { type: "input_file"; file_url: string; filename?: string }
  | { id: string; type: "file_analysis"; status: ItemStatus; file_url: string; data?: string; source?: string; usage?: Usage }
  | { id: string; type: "function_call"; status: ItemStatus; call_id: string; name: string; arguments: unknown }
  | { type: "function_call_output"; call_id: string; output: unknown }
  | { id?: string; type: string; [key: string]: unknown };

export type ItemStatus = "running" | "completed" | "failed";

export interface Usage {
  input_tokens: number;
  output_tokens: number;
  total_tokens: number;
  cached_tokens: number;
  reasoning_tokens: number;
}

export type StreamEvent =
  | { type: "start"; id: string; model: string }
  | { type: "item"; item: SessionItem }
  | { type: "delta"; id: string; data: string }
  | ({ type: "usage" } & Usage)
  | { type: "done"; id: string; status: "completed" | "canceled" }
  | { type: "error"; code: string; message: string }
  | { type: "interrupt"; id: string; status: "canceling" | "canceled" | "outcome_unknown"; usage?: Usage }
  | { type: string; [key: string]: unknown };

export interface HistoryExchange {
  sid: string;
  status: "completed" | "canceled";
  question_at: string;
  question: SessionItem[];
  answer_at: string;
  answer: SessionItem[];
}

客户端必须保留 Unknown Event/Item 的忽略能力,不能因服务端增加可选 Item 而终止整个流。

4. SID 生命周期

4.1 首次请求

首次 POST 不发送 sid Header。成功响应始终通过响应头返回:

sid: 20d82b37-a78e-4f91-8ed1-66f44e0c847f

浏览器通过 response.headers.get("sid") 读取。服务端会公开 CORS Header。

4.2 本地保存

  • SID 应与当前登录主体、环境和 API Base URL 一起保存;
  • 用户退出登录、切换租户或切换 API Key 时,不得自动复用旧 SID;
  • SID 可以保存在应用数据库、系统安全存储或受控浏览器存储;
  • 不把 SID 放入公开 URL、埋点、Referer 或第三方日志;
  • 客户端埋点只记录操作类型、耗时、状态码和事件计数,不记录 input、工具输出或完整历史;
  • SID 泄露不应造成越权,但仍应视为会话隐私标识处理。

4.3 无效 SID

  • 400 invalid_sid:本地值损坏,应停止重试;
  • 404 session_not_found:会话不存在、已删除或不属于当前主体,应清理本地 SID;
  • 不要收到 404 后自动用原消息创建新 SID,除非产品明确要求用户确认。

除 History 外,每个请求只能携带一个 SID。History 可以在同一个 sid Header 中传入 1~100 个 逗号分隔的 SID;这只是读取合并视图,不会把这些会话的 Context 或生命周期合并。

5. 提交与非流式响应

async function submitOnce(baseUrl: string, apiKey: string, sid: string | null, input: string) {
  const headers: Record<string, string> = {
    "content-type": "application/json",
    "x-api-key": apiKey,
  };
  if (sid) headers.sid = sid;

  const response = await fetch(`${baseUrl}/v1/session/responses`, {
    method: "POST",
    headers,
    body: JSON.stringify({ model: "gpt-5.6-sol", input, stream: false }),
  });
  const nextSid = response.headers.get("sid") ?? sid;
  const body = await response.json();
  if (!response.ok) throw protocolError(response.status, body);
  return { sid: nextSid, response: body };
}

非流请求的浏览器连接断开不取消服务端任务。发生网络异常时,不要重复 POST;应使用同一 SID 调用 恢复 GET,以免把业务上的“继续会话”误当成网络重试。

6. Fetch SSE 解析

浏览器原生 EventSource 不能稳定附带自定义 SID 和认证 Header,因此推荐使用 Fetch 读取 SSE:

async function* readSse(response: Response): AsyncGenerator<StreamEvent> {
  if (!response.ok) {
    const body = await response.json().catch(() => null);
    throw protocolError(response.status, body);
  }
  if (!response.body) throw new Error("missing_response_body");

  const reader = response.body.getReader();
  const decoder = new TextDecoder();
  let buffer = "";
  while (true) {
    const { value, done } = await reader.read();
    buffer += decoder.decode(value, { stream: !done });
    let boundary: number;
    while ((boundary = buffer.indexOf("\n\n")) >= 0) {
      const frame = buffer.slice(0, boundary).replace(/\r/g, "");
      buffer = buffer.slice(boundary + 2);
      const data = frame
        .split("\n")
        .filter(line => line.startsWith("data:"))
        .map(line => line.slice(5).trimStart())
        .join("\n");
      if (data) yield JSON.parse(data) as StreamEvent;
      // `: ping` 没有 data 行,会自然忽略。
    }
    if (done) break;
  }
}

生产 SDK 还应限制单帧、缓冲区和完整响应最大字节数,防止异常代理或服务端导致无界内存增长。

7. 客户端投影状态机

客户端按 Item ID Upsert,不按事件数量创建重复气泡:

interface ActiveProjection {
  responseId: string | null;
  model: string | null;
  order: string[];
  items: Map<string, SessionItem>;
  usage: Usage | null;
  interrupted: { id: string; status: string; usage?: Usage } | null;
  status: "idle" | "running" | "interrupting" | "completed" | "canceled" | "failed";
}

function applyEvent(state: ActiveProjection, event: StreamEvent) {
  switch (event.type) {
    case "start":
      state.responseId = event.id;
      state.model = event.model;
      state.status = "running";
      return;
    case "item": {
      const id = event.item.id;
      if (!id) return;
      if (!state.items.has(id)) state.order.push(id);
      state.items.set(id, structuredClone(event.item));
      return;
    }
    case "delta": {
      const item = state.items.get(event.id);
      if (!item) return;
      if (item.type === "text") item.data = (item.data ?? "") + event.data;
      if (item.type === "reasoning") item.output = (item.output ?? "") + event.data;
      return;
    }
    case "usage":
      state.usage = event;
      return;
    case "done":
      state.status = event.status;
      return;
    case "error":
      state.status = "failed";
      return;
    case "interrupt":
      state.interrupted = { id: event.id, status: event.status, usage: event.usage };
      if (event.status === "canceling") state.status = "interrupting";
      return;
    default:
      return;
  }
}

item 是完整 Upsert,后到的 completed Item 可以替换 running Item。delta 只追加到已有 Item;若 因为异常网络顺序先收到 Delta,SDK 可以短暂缓冲,但不能创建错误 Item 类型。

8. 断线恢复

恢复请求:

async function recover(baseUrl: string, apiKey: string, sid: string) {
  return fetch(`${baseUrl}/v1/session/responses`, {
    method: "GET",
    headers: { "x-api-key": apiKey, sid, accept: "text/event-stream" },
  });
}

V1 只使用 SID,因此恢复固定从当前响应开头重放。客户端必须:

  1. 保留已经完成的旧 Exchange;
  2. 清空当前未完成回答的 Item、Delta 和 Usage;
  3. 调用恢复 GET;
  4. 从新的 start 重新构建当前回答;
  5. 收到 done 或 error 后停止重连。

网络异常建议使用带抖动指数退避,例如 0.5s、1s、2s、4s,最大 10s。以下情况不自动重试:

  • 400 invalid_sid;
  • 401/403 认证失败;
  • 404 session_not_found;
  • 已收到终态;
  • 用户主动离开且产品不要求后台观察。

恢复 GET 不重新执行模型、不增加 History、不产生模型 Token 费用。

9. 安全停止

await fetch(`${baseUrl}/v1/session/responses/cancel`, {
  method: "POST",
  headers: { "x-api-key": apiKey, sid },
});

202 {"status":"canceling"} 只是接受取消。按钮进入“正在停止”状态,禁止立即提交下一条消息。 继续读取原流或调用恢复 GET,直到:

{"type":"done","id":"resp_...","status":"canceled"}

取消和正常完成可能竞争。如果 Cancel 返回 200 completed,界面应保留完整回答,不能改画为已取消。 刷新页面、关闭 Fetch Reader 或调用 AbortController.abort() 都只断开观察者,不等于安全停止。 被停止时仍为 running 的 Text、Reasoning 或工具 Item 会以 failed Item Upsert 收口;Exchange 本身仍以 status: canceled 展示,不能把 Item 的 failed 误判为整个 Session 已删除。

10. 中途打断并提交补充信息

用户在回答尚未完成时补充条件,只发一个请求:

const response = await fetch(`${baseUrl}/v1/session/responses/interrupt`, {
  method: "POST",
  headers: { "content-type": "application/json", "x-api-key": apiKey, sid },
  body: JSON.stringify({ model, input: supplement, stream: true }),
});

不要先发 Cancel 再发普通 POST。服务端会原子取消旧 Response、创建后继 Response,并保证后继在旧 调用真实终止后才执行。新 Question 自动由“旧原始 Question + 补充 Input”组成;旧部分 Answer 保留为 canceled History,但不进入后继 Context。

同一 SSE 依次出现:

interrupt(canceling old)
interrupt(canceled old, usage)
start(new)
item / delta / usage
done(new)

把旧 Response 的 Usage 存到 interrupted,不要覆盖或累加到新 Response 的累计 Usage。交接时断线, 仍只调用 GET /v1/session/responses 并携带 SID;恢复流会从第一个 interrupt 事件重放。收到 409 session_busy 表示前一次取消/交接尚未完成,客户端等待或恢复,不重复提交补充消息。若旧响应 在请求到达前恰好完成,服务端按普通续写接受补充,此时没有 interrupt 事件。

11. 编辑最近一条用户消息

正确客户端顺序:

  1. 当前响应仍在运行:先 Cancel;
  2. 等待 done(status=canceled);
  3. 本地暂存替换文本,不先永久修改 History;
  4. 调用 PUT /v1/session/responses;
  5. PUT 被接受后,用新问题替换最近用户消息;
  6. 剪切该问题之后的旧 Assistant、Reasoning、工具调用、工具结果和部分回答;
  7. 用 PUT 返回的新流重新生成后续投影。
const response = await fetch(`${baseUrl}/v1/session/responses`, {
  method: "PUT",
  headers: { "content-type": "application/json", "x-api-key": apiKey, sid },
  body: JSON.stringify({ model, input: editedText, stream: true }),
});

收到 409 session_busy 时不能在本地强行剪切;应恢复当前状态或继续等待 Cancel 终态。收到 409 no_editable_message 时恢复原界面内容。

最近一次生成若以 error 结束,服务端仍允许 PUT 替换该次已经持久化的用户 Input;客户端无需把 失败问题重新解释为普通 POST,也不会因此剪切更早的已完成 Exchange。

12. 工具调用循环

收到 completed function_call Item 后,客户端或 Agent Runtime 执行工具,再进行下一次 POST:

{
  "model": "gpt-5.6-sol",
  "input": [
    {
      "type": "function_call_output",
      "call_id": "call_456",
      "output": { "result": "检查完成" }
    }
  ],
  "stream": true
}

必须原样返回 call_id。400 function_call_mismatch 表示 Call 已闭合、已被编辑剪切,或 SID 不属于该工具链;客户端不得更换 Call ID 后盲目重试。

13. 历史查询与渲染

History 是当前 Revision 的真实可见 Exchange,不是压缩摘要:

async function readAllHistory(
  baseUrl: string,
  apiKey: string,
  sids: string[],
  order: "asc" | "desc" = "asc",
) {
  if (sids.length < 1 || sids.length > 100) throw new Error("history requires 1-100 sids");
  const sidHeader = [...new Set(sids)].join(",");
  const history: HistoryExchange[] = [];
  let cursor: string | null = null;
  do {
    const url = new URL(`${baseUrl}/v1/session/history`);
    url.searchParams.set("limit", "50");
    url.searchParams.set("order", order);
    if (cursor) url.searchParams.set("cursor", cursor);
    const response = await fetch(url, { headers: { "x-api-key": apiKey, sid: sidHeader } });
    if (!response.ok) throw protocolError(response.status, await response.json());
    history.push(...await response.json() as HistoryExchange[]);
    cursor = response.headers.get("has-more") === "true"
      ? response.headers.get("next-cursor")
      : null;
  } while (cursor);
  return history;
}
  • Question 和 Answer 永远按 Item 数组处理;
  • 每条 Exchange 固定包含来源 sid,主/子会话展示不得依赖数组位置或本地关系推断;
  • question_at 和 answer_at 是服务端 UTC ISO 8601 时间;服务端已经按 question_at 做稳定合并, 客户端不得再用本地时钟或各 SID 的 Sequence 重排;
  • completed Exchange 可以进入后续模型 Context;
  • canceled Exchange 可以展示停止前部分输出,但其 Answer 不进入后续 Context;
  • PUT 剪切的旧 Revision 不在默认 History 中;
  • compaction 是运行说明,不用摘要替换历史问题和回答;
  • 默认响应体每页仍是友好数组;显式 Prompt 时间线使用固定 Envelope;分页元数据只读 has-more 和 next-cursor Header;
  • Cursor 是不透明字符串,不解析、不缓存为业务主键;
  • order 默认 asc;聊天首屏使用 desc 可直接读取最近一页,后续 Cursor 继续读取更早内容;
  • Cursor 同时绑定整个 SID 集合、排序方向、时间线模式及各会话 Revision;翻页时必须发送完全相同的 SID 集合和参数;
  • 续页更换 order 或 include_system_prompt 时会收到 400 invalid_cursor,应丢弃 Cursor 并按目标参数重新读取;
  • 409 cursor_stale 表示 SID 集合变化,或其中任一会话发生编辑/Prompt 更新,应丢弃已拼接页并 使用目标 SID 集合从第一页重读;
  • UI 应按页增量渲染或虚拟滚动,不一次加载全部 DOM。
  • 使用 order=desc 的聊天 UI 应把每页按时间正序渲染;首屏定位在页尾,上拉时把下一页插入顶部。

需要显示 Prompt 变更时间线时,显式增加 include_system_prompt=true。该模式返回:

interface PromptHistoryItem {
  sid: string;
  revision: number;
  system_prompt_at: string;
  system_prompt: string | null;
}

interface HistoryWithPromptsPage {
  history: HistoryExchange[];
  system_prompts: PromptHistoryItem[];
}

limit 是两个数组的合计条数。服务端已经按 question_at/system_prompt_at 对两类记录共同分页;客户端 把两个数组按时间重新合并渲染。此模式的 Cursor 不能用于默认数组模式,下一页必须继续携带相同 SID 集合和 include_system_prompt=true。system_prompt: null 表示清空;重复设置相同值不会新增历史。

14. 文件输入

客户端仅提交公开可访问的 HTTP/HTTPS URL:

{
  "model": "gpt-5.6-sol",
  "input": [
    { "type": "text", "data": "总结附件" },
    { "type": "input_file", "file_url": "https://files.example.com/report.pdf" }
  ],
  "stream": true
}

不要上传 Base64、file_id、本地路径、内网 URL、带认证 Header/Cookie 才能下载的 URL。网关按本次 有效执行路由决定文件路径:可用的原生文件能力直接处理,否则自动完成 MCP 分析。透明路由不会改变 公开模型身份;客户端对两种路径使用同一请求,不应推断内部渠道,只需把可能出现的 file_analysis 当作普通 Item Upsert 渲染,并使用最终累计 Usage。

15. 更新 System Prompt

await fetch(`${baseUrl}/v1/session`, {
  method: "PATCH",
  headers: { "content-type": "application/json", "x-api-key": apiKey, sid },
  body: JSON.stringify({ system_prompt: "你是订单审计助手,回答必须简洁。" }),
});

发送 null、空字符串或纯空白字符串清空 Prompt。运行中、取消中和结果未知状态都可以更新,客户端 不需要先停止当前回答。DASP 在响应被接受时冻结 Prompt:当前正在输出的响应继续使用旧快照,PATCH 成功后才被接受的响应使用新 Prompt。

如果用户希望当前问题立即按新 Prompt 重新回答,客户端按以下顺序执行:

  1. 等待 PATCH 返回 200 {"status":"updated"};
  2. 调用 POST /v1/session/responses/interrupt 提交必要补充;
  3. 继续按 Interrupt 交接事件恢复和渲染。

同一 SID 的并发 PATCH 由服务端解决冲突,最后成功提交的值生效。客户端无需把 Prompt 重复附加到 每条 Input,也不应在默认 History 中伪造一条 System 消息。需要时间线时使用 include_system_prompt=true 返回的 system_prompts。Prompt 更新会使旧 History Cursor 失效。 服务端内部只在 Prompt State 保留当前值;每个响应使用接收时快照,快照不会进入消息 Context。客户端 不需要读取、保存或回传当前 Prompt 才能续写。

16. 删除

删除前必须保证 Session 已终态:

const response = await fetch(`${baseUrl}/v1/session`, {
  method: "DELETE",
  headers: { "x-api-key": apiKey, sid },
});
  • 204:立即清理本地 SID、History、当前投影和恢复任务;
  • 409 session_busy:先安全停止,等待 canceled,再删除;
  • 删除不能被实现为 AbortController.abort() 或 Cancel;
  • 删除成功后不得自动用同一 SID 继续提交。

17. 错误处理矩阵

Code 客户端行为
invalid_request 标记请求构造错误,不自动重试
invalid_sid 清理损坏 SID,提示用户
invalid_file_url 要求用户改用公开 HTTP/HTTPS URL
invalid_cursor 丢弃 Cursor,从第一页读取
invalid_limit 修正为 1–100,不自动重复原请求
invalid_order 修正为 asc 或 desc;续页保持与 Cursor 一致
invalid_system_prompt 缩短 Prompt 后由用户重试
session_not_found 清理本地会话引用,不自动新建
response_not_found Session 可继续提交,但没有可恢复回答
model_not_available 保留 SID,提示重新选择可用模型后再提交新请求
session_busy 恢复/等待当前响应,不重复 POST
no_editable_message 恢复编辑前 UI
cursor_stale 丢弃已拼接分页,从第一页重读
function_call_mismatch 终止该工具结果提交,重新读取 History
rate_limit_exceeded 按 Retry-After 或指数退避
context_compaction_failed 显示明确失败,不丢弃本地 History
file_analysis_failed 保留 SID 和原文件输入,提示分析失败,不重复 POST
provider_outcome_unknown 不自动重发原 POST,等待人工/产品策略
session_storage_unavailable 不发起收费重试风暴,稍后重试提交
session_unavailable 暂停新 POST/PUT/Interrupt;已有会话仍可恢复、查询、停止和删除
session_internal_error 本次请求失败;保留 SID,按服务端重试策略稍后重试或上报

流开始前错误使用 HTTP JSON;流开始后错误使用 error 事件。SDK 应统一成包含 HTTP Status、Code、 Message、SID 和可重试属性的异常类型。

SDK 建议为每次请求生成 GUID 格式 X-Request-Id 并保存到本地诊断事件。服务端响应会返回同一值, 且所有超过 500ms 的请求会使用该 ID 写结构化慢日志;客户端诊断事件不得记录 API Key、Prompt、 Question、Answer、工具输出或文件分析正文。

18. 客户端并发规则

  • 每个 SID 只维护一个写操作队列;
  • POST、PUT、Interrupt、Cancel 和 Delete 不能并行发送;
  • 多个 UI 组件可以观察同一 SID,但应共享一份投影 Store;
  • 页面多标签同时写同一 SID 时,使用 BroadcastChannel 或本地锁协调;
  • 即使做了客户端锁,也必须正确处理服务端 409 session_busy;
  • 不要把恢复 GET 放入写队列,它是只读观察操作。

19. 客户端完成检查表

  • 首次 POST 正确读取并持久化 SID;
  • 续写只发送新 Input;
  • 流解析支持分块边界、CRLF、Heartbeat 和未知事件;
  • Item Upsert 不产生重复回答;
  • Usage 使用最后累计值;
  • 刷新后先清空未完成投影,再从头恢复;
  • Cancel 等待 canceled 终态;
  • Interrupt 用一个请求完成旧响应停止与补充提交,并分别保存旧/新 Usage;
  • Edit 先停止,再剪切,再重新生成;
  • Delete 不代替 Cancel;
  • 工具结果保持 Call ID;
  • History 使用 Cursor 分页并处理 Revision 失效;
  • 可选 Prompt 时间线按消息与 Prompt 的服务端时间合并,正确显示清空事件;
  • 多 SID History 使用一个逗号分隔 sid Header,按服务端时序显示并保留每条来源 SID;
  • History 正确显示 question_at 和 answer_at,不以设备时间覆盖;
  • System Prompt 只通过 PATCH 更新;运行中可切换,当前响应保持旧快照,之后接受的响应使用新版本;
  • 文件只提交公开 HTTP/HTTPS URL,并能渲染 file_analysis;
  • 切换用户、租户、环境和 API Key 时隔离 SID;
  • 所有自动重试都不会重复 POST 模型调用。
  • 每个请求携带可关联的 X-Request-Id,本地日志不含会话正文。

首次创建建议始终携带稳定的 Idempotency-Key,并在拿到 SID 前保持不变。它只解决首个 SID/Response 绑定窗口;已有 SID 的续写、打断和编辑不能使用该 Header。