MMSP 开发工作流
添加或更新模型支持需按以下阶段顺序进行。当某个阶段提示暂停并询问时,请暂停并向用户提问;在问题解决前不要继续,切勿自行填补空白。
目录映射
llmsdk_docs/<model_version>/ 官方文档快照,每个模型代系一个文件夹 (README.md + docs/)
api_captures/<protocol>/ Git 忽略的原始 API 捕获数据:请求载荷 + 流事件
src_py/mmsp/<client_type>/ Python 客户端,每个客户端类型一个文件夹:<vendor>_official/ 或 <protocol>/
src_py/mmsp/auto_client.py 根据 client_type 名称创建客户端;模型 ID 的家族名称为其官方客户端
src_ts/src/<client_type>/ TypeScript 客户端,与 Python 文件夹镜像
src_ts/src/autoClient.ts TypeScript 路由,镜像 auto_client.py
src_py/tests/test_client.py 参数化的端到端测试 (环境变量门控的 AVAILABLE_MODELS)
src_ts/tests/client.test.ts 相同功能的 TypeScript 测试
changelog/unreleased/ 存放未发布的变更;发布时重命名为版本号
changelog/<version>/ 发布摘要 (README.md) 加每个条目的详情文件
changelog/README.md 条目格式:元数据块、正文规则、双语配对
CHANGELOG.md 每个版本一行简要说明,链接至 changelog/
src_py/mmsp/integration/ playground、tracer 页面和 MMSP 服务器,镜像于 src_ts/src/integration/
site/ 位于 mmsp.penguin.ooo 的 Astro 站点
.agents/skills/mmsp-dev/kill-ai-slop/ 针对下方 UI 工作引入的 AI 垃圾代码指南和扫描工具
<name>.zh.md changelog/ 中每个文件对应的中文版本
阶段 1 — 将官方文档同步到 llmsdk_docs/
- 来源必须是模型供应商的官方文档站点(例如 docs.anthropic.com, platform.openai.com, ai.google.dev)。禁止使用第三方镜像、博客文章或模型记忆。
- 将快照保存到
llmsdk_docs/<model_version>/下,遵循现有文件夹约定,并在llmsdk_docs/README.md中列出该文件夹。运行此工作流即是对仓库规则(禁止编辑llmsdk_docs/)的明确请求。 - 当获取的文档与现有快照不同时,以新官方文档为准:原地更新旧文件。
- 快照必须足够完整以支持实现:请求/响应模式、流事件序列、思考输出、工具调用、用量字段和错误响应。
- 暂停并询问如果官方 URL 不确定或某个页面无法获取。用户可手动粘贴内容。
阶段 2 — 将实时 API 交换捕获到 api_captures/
- 前提条件:提供商的 API 密钥环境变量必须已设置且可用。使用与
src_py/tests/test_client.py相同的环境变量和基础 URL(AVAILABLE_MODELS门控和_create_client)。暂停并询问用户如果密钥缺失;工作流不得在没有密钥的情况下继续。 - 使用提供商的官方 SDK,或完全按文档记录的原始 HTTP,运行一次启用思考功能的流式工具调用请求,然后发送工具结果,使捕获也显示助手轮次如何重新发送。
- 将完整交换(未修改)保存到
api_captures/<protocol>/下(Git 忽略),例如round1.request.json加round1.stream.jsonl,其中每个原始流事件按顺序排列。切勿保存凭据。 - 当交换中的某些内容无法序列化为 JSON 时(二进制载荷、包含非 JSON 值的 SDK 对象),保存其
str()形式并据此分析。保持容器可解析:将文本包装在 JSON 对象中,使.jsonl保持每行一个 JSON 值({"unserializable_str": "<str(event)>"},保持事件在流中的顺序),或者当没有任何内容可序列化时,将整个交换放入同级.txt文件中。str()捕获仍然是 API 返回内容的权威记录。切勿丢弃事件、手动编辑以使其成为有效 JSON,或由于序列化失败而回退到文档。 - 暂停并询问任何 API 错误(无效密钥、配额不足、速率限制)。不要模拟响应或仅凭文档继续。
- 捕获是主要的实现参考,优先级高于文档:当两者不一致时,以 API 实际返回的内容为准。
- 然后探测重放,因为捕获仅显示 API 发送的内容,而非其需要接收的内容。重复发送助手轮次,每次移除一个候选字段——推理项目的 id、其内容、整个推理项目、每个你曾想保留的字段——并记录哪些移除后 API 仍接受且模型行为保持不变。该结果(而非响应格式)决定了阶段 3 存储的内容。将探测结果保存在捕获旁边。
阶段 3 — 实现 Python 和 TypeScript 客户端
- 每个客户端类型一个文件夹,以其命名:官方客户端(每个供应商一个
openai_official/,anthropic_official/,google_official/,zai_official/,moonshot_official/,deepseek_official/,minimax_official/)使用供应商自己的 API,了解该供应商所有代系的模型,并从环境变量中读取该供应商的密钥;兼容客户端(每个 Wire 协议一个openai_responses/,openai_chat/,openai_chat_vllm_adapter/,openai_embedding/,ant_messages/,google_genai/,mmsp/)针对任何端点使用该协议。类名与文件夹相同(OpenAIOfficialClient,OpenaiChatClient),客户端类型为文件夹名称(带短横线openai-official,openai-chat)。- 供应商模型的新代系应放入该供应商的官方客户端。比较新协议(捕获 + 文档)与客户端当前发送的内容;如果代系之间存在差异,即使只是单个键名,也应在客户端内通过显式版本区分(
"4-6" in self._model,"gpt-6" in self._model),切勿使用裸子字符串(如"claude" in model),并保持旧代系正常工作。除非用户明确指示,否则绝不能丢弃代系。 - 新供应商应有新的官方客户端文件夹、
auto_client.py/autoClient.ts中新的<vendor>-official客户端类型,以及模型族表中对应的新行:族是供应商所有模型 ID 的开头前缀(glm-,kimi-),不区分大小写匹配。 - 多个供应商使用的新 Wire 协议应获得一个兼容客户端文件夹和客户端类型;兼容客户端不携带任何供应商的特殊行为。
- 供应商模型的新代系应放入该供应商的官方客户端。比较新协议(捕获 + 文档)与客户端当前发送的内容;如果代系之间存在差异,即使只是单个键名,也应在客户端内通过显式版本区分(
auto_client.py/autoClient.ts根据客户端类型表创建client_type命名的客户端。若未指定client_type,则模型 ID 开头的族名称将命名其官方客户端;未知族的 ID 将引发错误并要求提供。路由没有其他规则:禁止对模型 ID 进行子字符串匹配,禁止在官方客户端外进行版本匹配。- 凭据仅用于其被给予的端点。 这是安全模型,每个客户端构造函数都遵循此规则。作为
api_key传递的密钥原样使用。从环境变量读取通用密钥的客户端(openai_official和 OpenAI 协议兼容客户端使用OPENAI_API_KEY、anthropic_official和ant_messages使用ANTHROPIC_API_KEY、google_genai使用GEMINI_API_KEY)仅当端点也来自环境变量时才读取(OPENAI_BASE_URL/ANTHROPIC_BASE_URL/GEMINI_BASE_URL,或提供商的默认值):如果提供了base_url但没有api_key,则在构造时引发错误,而不是将该密钥发送到另一个端点。这是utils中的resolve_credentials/resolveCredentials;调用它,不要重新实现。任何其他官方客户端(deepseek_official,zai_official,moonshot_official,minimax_official,google_official)读取自己的变量(DEEPSEEK_API_KEY,ZAI_API_KEY,MOONSHOT_API_KEY,MINIMAX_API_KEY,GEMINI_API_KEY)而不管给定的基础 URL,且从不读取通用密钥;如果两者都缺失,则引发<VAR> is required for <Client>.。将每个凭据显式传递给供应商 SDK,包括 "none"(TypeScript 中的authToken: null,Python 中的已清除auth_token),因为 SDK 会填充所有未从自身环境变量接收的内容。tests/env-credentials.test.ts/tests/test_env_credentials.py中的离线测试在受控环境下构建每个客户端,并读取 SDK 实例持有的凭据;为新客户端添加测试。 - 重放必须最小化,而非穷举。重放的助手轮次必须能被 API 接受且模型行为保持不变;它不需要逐字段重现 Wire 项。
fidelity是一个异常通道,而非载荷的镜像:只有当阶段 2 的探测显示没有该字段请求会失败或模型会降级时,字段才有资格进入fidelity。提供者生成且 API 会重新生成或忽略的 ID(推理项目 ID、输出项 ID)应被排除。API 要求的字段必须包含:GPT-5.5 的phase、Claude 的思考signature、严格上游要求的精确推理字段名称。 - 切勿将通用字段已包含的内容复制到
fidelity中——思考文本、工具调用的名称或已解析参数。应改为从通用字段重建 Wire 项。 tool_call_id不可省略:始终捕获提供者的调用 ID 并重放它。当 Wire 格式同时包含项目 ID 和调用 ID 时,调用 ID 是将结果与调用关联的 ID。- 遵循参考客户端;不要重新设计。
openai_official/是 Responses 风格协议的模式,openai_chat/是 Chat Completions 的模式:相同的方法顺序、相同的控制流、相同的命名。新客户端应看起来像是针对其参考的差异,因为这就是审查方式。 - 保持从上到下的可读性。 读者应能顺畅地跟踪单个请求或响应穿过文件,而无需在定义之间跳转,因此内联是默认方式:直接读取 SDK 的属性(
model_output.delta,model_output.item.call_id),并将字段访问、用量计算和错误文本保留在使用它们的位置。仅当确实存在一个大型、自包含的块时才提取辅助函数——那种内联会掩埋主流程的块,例如获取和解码图像——绝不为了几行代码。镜像参考客户端自身的私有方法(_convert_thinking_level_*,_convert_tool_choice),而不是在旁边发明一层;同时接受字典和 SDK 对象的适配器绝不是其中之一,因为事件使用 SDK 自身的类型进行类型化。 - 客户端仅产生增量;基类负责关闭项目。
transform_model_output_to_uni_event/transformModelOutputToUniEvent将一个 Wire 事件转换为一个UniEvent,其content_items是该事件携带的.delta项,按 Wire 顺序排列,且从不包含.done项。_streaming_response_internal/_streamingResponseInternal构建请求、打开流,并逐个 Wire 事件产生一个事件:一个简单的循环,没有项目记账、累加器、参数解析、用量合并或合成停止。usage_metadata/finish_reason随 Wire 报告它们的stop事件一起传递——Anthropic 的message_start和message_delta、Chat Completions 的 finish 和 usage 块、response.completed——由基类逐字段合并;客户端的stop事件永不结束公共流。没有通用内容的 Wire 事件是空增量事件:没有项目,null 用量和 finish reason。基类将每个增量传递给StreamItems(stream_items.py/streamItems.ts),后者在接收到增量时立即发送出去,并在下一个项目开始或客户端的流结束时关闭每个项目的.done项;然后基类发出一个stop事件或引发EmptyResponseError。破坏协议的事件在每种模式下都引发StreamProtocolError,共享的端到端测试使用assert_stream_grammar/assertStreamGrammar检查每个客户端的流。 - 一个项目的增量是连续的,共享同一个
fidelity.item_id。 模型输出是串行的,因此一次流式传输一个项目,提供者的项目结束事件(content_block_stop,response.output_item.done,step.stop)被列为无操作:延迟或乱序关闭项目的网关不会改变任何内容。将提供者自身的 ID(字符串化)作为fidelity.item_id传递:Anthropic 的块index、Responses 事件的item_id或item.id or item.call_id(当网关未发送时为None/undefined)、Interactions 步骤的index、Chat Completions 的 Wire 字段(reasoning_content,reasoning,content,tool_calls)、generateContent 的部分类型(function_call,thought,inline_thinking,inline_data,text);嵌入向量不携带任何 ID。StreamItems会剥离该 ID,因此它永远不会到达消费者、追踪或历史记录。 StreamItems决定项目何时结束;客户端从不这样做。 当增量携带另一个item_id、属于另一种类型(Interactions 的 thought 步骤变为文本、图像、文本,在一个索引下是三个项目)、或自身开始一个项目时(携带名称的tool_call.delta——每个提供者只在调用的第一个增量上发送一次,因此 Chat Completions 调用不需要自己的 ID;图像——inline_thinking,或带有image/*MIME 类型的inline_data,而音频以单个项目的块流式传输;嵌入向量),该增量属于下一个项目。否则它继续当前正在流式传输的项目:没有 ID 的增量属于当前项目,并且无论网关在调用参数上放置什么 ID,它们都属于当前项目。在项目 ID 下单独发送的 Fidelity 属于该项目,无论携带它的类型如何(Interactions 在图像思考后的thought_signature)。该文件中的按类型表——增长的字段、第一个增量的头字段、块如何连接——是流中唯一的按类型代码:done 项目是该项目的第一个增量,其中增长字段被替换为所有增量的连接……