Multica Public API v1 契约设计解析:能力账本、Problem 错误封装与插件信任面
Multica 的 server/pkg/publicapi/v1 包是项目首个带版本号的 Public API 切片的事实来源(source of truth):它用 OpenAPI 定义线协议(wire contract)、用 Go 代码维护能力账本(capability ledger)、用独立 DTO 与 App API 解耦,并以统一的 Problem 错误封装贯穿路由前后。读完本文,你将理解 Multica 如何把"用户/PAT 凭证"与"插件凭证"两套信任面收敛到同一份资源契约上,并掌握其游标分页、幂等键、revision/ETag 乐观并发与审计生命周期等基础规则的落地方式,以及合同测试如何防止路由、scope 与文档三者漂移。
包结构:五份文件各司其职
按 server/pkg/publicapi/v1/README.md 的定义,该包由五个核心文件构成,每一份都承担明确的契约职责:
| 文件 | 职责 |
|---|---|
| openapi.yaml | 定义 v1 线协议契约(wire contract),OpenAPI 3.1.0 |
| routes.go | 能力账本(capability ledger),供路由器与合同测试共同引用 |
| types.go | DTO 定义,刻意独立于 App API 响应与数据库模型 |
| problem.go | 路由前后统一使用的稳定错误封装(error envelope) |
| foundation.go | 供所有未来资源切片共享的 actor、凭证、分页、幂等、revision、风险、审计与限流词汇 |
另有一个辅助文件 spec.go:它通过 //go:embed openapi.yaml 把规范文档嵌入二进制,并由 OpenAPI() 函数返回一份隔离拷贝(bytes.Clone),保证合同测试与任何需要规范字节的调用方拿到的都是不可变的规范文档。
这种"YAML 定义契约、Go 代码定义政策"的双轨结构是理解整个包的关键:routes.go 中的账本与 openapi.yaml 中的路径并不互相生成,而是由合同测试逐条比对,下文会详细展开。
基础规则:多信任面共享一份资源契约
README 中"Foundation rules"一节定义了 v1 的六条基础规则,这些规则全部物化在 foundation.go 中。
凭证族与 Actor:认证在信任面完成,资源层只见 Actor
foundation.go 定义了四种凭证类型(CredentialKind):
user_oauth:用户 OAuthpersonal_access_token:个人访问令牌(PAT)plugin_installation:插件安装令牌plugin_invocation:插件调用(回调)令牌
规则第一条明确:User OAuth 和 PAT 认证的是 member;插件安装/调用凭证认证的要么是 member 范围的调用,要么是安装本身。认证发生在信任面(surface)层,共享资源服务运行之前完成;资源服务收到的永远是"已认证的 Actor",不解析 token。Actor 结构体正是为此设计:
// Actor 是信任面认证完凭证后,交给共享鉴权、审计与服务层的
// 传输无关(transport-neutral)身份。
type Actor struct {
Kind ActorKind // member 或 plugin
SubjectID string
WorkspaceID string
Credential CredentialKind
}
routes.go 中可以看到两个凭证集合的划分:sharedCredentials 包含全部四种凭证(用户 OAuth、PAT、插件安装、插件调用),而 pluginCredentials 只含插件安装与调用两种。routes.go 的包注释也点明了设计意图:同一个资源契约通过不同的信任面暴露,Plugin API 是第一个消费者,未来的用户/PAT 入口应复用这些路径、DTO 与操作语义,而不是另建平行 handler。
分页、幂等与乐观并发的常量基线
foundation.go 顶部定义了四个跨切片的硬常量:
const (
HeaderIdempotencyKey = "Idempotency-Key" // 幂等键请求头
HeaderIfMatch = "If-Match" // 条件写请求头
MaxIdempotencyBytes = 255 // 幂等键最大 255 字节
DefaultPageSize = 50 // 分页默认页大小
MaxPageSize = 200 // 分页最大页大小
)
对应 README 的三条规则:
- 集合端点使用不透明游标分页:
limit默认 50、最大 200,响应返回next_cursor。PageInfo结构体即为此定义,且注释强调游标是不透明的、并绑定到认证 Actor 与查询过滤条件。README 还特别说明:已迁移的 comment 端点保留"最新 200 条"兼容窗口,直到其专属分页切片落地为止——这不是新集合的先例(OpenAPI 中listIssueComments的 summary 也写着 "List the newest 200 comments in chronological order")。 - 幂等去重必须持久化:create、trigger、replay、retry 操作必须对
Idempotency-Key(最大 255 字节)做持久化去重,操作才能被称为幂等;"仅仅接受这个 header 是不够的"。这一点在 openapi.yaml 的IdempotencyKey参数中也有体现——其描述是 "Reserved for durable retry deduplication as write endpoints adopt it",明确这是为写端点逐步采用而预留的组件。 - 可变更资源暴露 revision 与 ETag:条件写接受
If-Match,过期写返回revision_conflict。OpenAPI 中PatchIssueRequest的expected_revision字段(minimum: 1)与响应头ETag("Weak ETag derived from the resource revision")共同构成乐观并发控制;openapi.yaml中Issue的revision字段要求minimum: 1。
风险等级、限流画像与审计生命周期
OperationPolicy 把授权与可观测性要求"钉在"路由契约旁边:
type OperationPolicy struct {
Credentials []CredentialKind // 允许的凭证族
Scope string // manifest scope,如 issues:read
Risk RiskLevel // read / content_write / high
Audit AuditStatus // not_required / planned / enforced
RateLimits []RateLimitProfile // user_default / plugin_strict
}
其中两条规则值得注意:
- 限流取更严格的画像:即使资源 DTO 与服务与用户/PAT 请求共享,插件请求也使用更严格的 surface 画像。账本中体现为:共享操作挂
sharedRateLimits(同时声明user_default与plugin_strict两档),插件扩展操作只挂pluginRateLimits(仅plugin_strict)。 - 审计用显式生命周期而非布尔标记:
AuditStatus三态not_required/planned/enforced。README 强调账本中的布尔式标记不等于真的写了审计;已迁移操作在持久化审计 sink 接入前保持planned。AuditStatus的源码注释把这一点说得很直白:"A capability must not claim enforcement merely because it is listed in the contract ledger."
能力账本:当前 v1 切片的全部操作
routes.go 的 Operations 切片就是当前 v1 切片的完整能力账本,BasePath 为 /v1。账本按 ContractKind 把操作分成两类:shared_resource(多凭证族共享的 Multica 资源契约)与 plugin_extension(属于插件安装而非通用资源 API 的契约)。
当前账本登记了 9 个操作:
| 方法 | 路径 | 契约类型 | 凭证族 | Scope | 风险 | 审计 |
|---|---|---|---|---|---|---|
| GET | /context |
plugin_extension | 插件(2 种) | — | read | not_required |
| GET | /issues/{issue_ref} |
shared_resource | 全部 4 种 | issues:read |
read | planned |
| PATCH | /issues/{issue_ref} |
shared_resource | 全部 4 种 | issues:write |
content_write | planned |
| GET | /issues/{issue_ref}/comments |
shared_resource | 全部 4 种 | comments:read |
read | planned |
| POST | /issues/{issue_ref}/comments |
shared_resource | 全部 4 种 | comments:write |
content_write | planned |
| GET | /storage/{scope} |
plugin_extension | 插件(2 种) | — | read | not_required |
| GET | /storage/{scope}/{key} |
plugin_extension | 插件(2 种) | — | read | not_required |
| PUT | /storage/{scope}/{key} |
plugin_extension | 插件(2 种) | — | content_write | planned |
| DELETE | /storage/{scope}/{key} |
plugin_extension | 插件(2 种) | — | content_write | planned |
这与 openapi.yaml 中每个操作的 x-multica-contract(shared_resource / plugin_extension)和 x-multica-scope(issues:read 等)标注一一对应。
账本如何落到路由上
账本不是孤立的文档——服务器路由直接从包常量注册。在 server/cmd/server/router.go 中,插件 API 路由以 publicapiv1.BasePath 挂载,handler 逐一绑定账本中的路径常量:
r.Get(publicapiv1.PathContext, h.GetPluginContext)
r.Get(publicapiv1.PathIssue, h.GetPluginIssue)
r.Patch(publicapiv1.PathIssue, h.PatchPluginIssue)
r.Get(publicapiv1.PathIssueComments, h.ListPluginComments)
r.Post(publicapiv1.PathIssueComments, h.CreatePluginComment)
从源码结构看,路由器直接引用 routes.go 导出的 Path* 常量而非手写字面量,意味着账本与路由天然同源,账本改动会迫使路由同步改动。
合同测试:让路由、scope 与文档不能各自漂移
contract_test.go 是该包最重要的"护栏",四个测试逐一钉死契约不变式:
TestOpenAPICoversCapabilityLedger:解析嵌入的 OpenAPI,断言openapi == "3.1.0"且info.version == "v1";把账本中的每个(路径, 方法)与paths逐条比对(路径数必须相等、每个方法必须存在);再逐操作比对x-multica-contract与x-multica-scope是否与账本一致。它还有一个负向断言:/hooks/{hook_key}不得出现在公共契约中("Plugin-only person hook leaked into the public contract"),确保插件专属的 hook 不会泄漏进公共契约。TestOperationLedgerPinsSharedScopes:钉死四个共享操作的 scope 映射(GET/PATCH issue →issues:read/issues:write,GET/POST comments →comments:read/comments:write),要求每个共享操作声明恰好 4 种凭证族(用户与插件两侧齐备),且审计状态必须是planned("until an audit sink is implemented")。TestPluginExtensionsRejectUserCredentialsByContract:所有plugin_extension操作的凭证列表中不得出现user_oauth或personal_access_token——这从契约层面保证了 Plugin context 与 storage 不会隐式成为未来用户/PAT 面的一部分(README 原文:"Plugin context and storage are markedplugin_extensionand are not implicitly part of a future user/PAT surface")。TestOperationLedgerDeclaresAuditLifecycle:每个操作的Audit字段必须非空——审计生命周期是强制声明项,不允许"默认省略"。
Problem 错误封装:路由前后同一份契约
problem.go 定义了 v1 的稳定错误封装,核心设计目标是:路由失败与资源内部失败对外呈现同一份契约。WriteProblem 的注释直接点题:"It is shared by auth middleware and handlers so a failure before routing has the same contract as one inside a resource."
封装结构与 RFC 9457 风格
const ProblemContentType = "application/problem+json"
type Problem struct {
Type string `json:"type"` // urn:multica:problem:<code>
Title string `json:"title"`
Status int `json:"status"`
Code string `json:"code"` // 稳定错误码,客户端应据此分支
Detail string `json:"detail"`
RequestID string `json:"request_id"`
Errors []FieldError `json:"errors,omitempty"`
Error string `json:"error"` // 兼容别名,供既有插件客户端使用
}
Problem 采用 RFC 9457(Problem Details)风格的字段,但叠加了 Multica 自己的稳定 code 和 request_id。error 字段是兼容别名——注释说明它是既有插件客户端消费的字段,新客户端应基于 Code 分支、渲染 Detail。在 openapi.yaml 的 Problem schema 中,error 被标记为 deprecated: true,与源码注释互为印证。FieldError(field/code/message)是可选的字段级错误,让端点在不改变顶层封装的前提下逐步引入字段级校验。
状态码到稳定错误码的映射
CodeForStatus 把 HTTP 状态映射为稳定码,客户端无需自行约定:
| HTTP 状态 | 稳定码 |
|---|---|
| 400 | invalid_request |
| 401 | unauthorized |
| 402 | payment_required |
| 403 | forbidden |
| 404 | not_found |
| 409 | conflict |
| 422 | incompatible |
| 429 | rate_limited |
| 507 | quota_exceeded |
| 503 | service_unavailable |
| 502 | upstream_unavailable |
| 其他 | internal_error |
WriteProblem 的行为细节:code 为空时自动按状态码推导;type 固定为 urn:multica:problem:<code> 形式;请求 ID 优先取 chi middleware 上下文中的 RequestID,其次取 X-Request-Id 头,都没有才生成新的 UUID,并同时写回响应头,保证端到端关联。包内还提供 NotFound / MethodNotAllowed 两个快捷函数。
错误封装在真实 handler 中的落地
在 server/internal/handler/plugin_action.go 中可以看到该封装的实际用法:所有对外失败都经 publicapiv1.WriteProblem 输出,稳定码覆盖了整条信任链——鉴权层 unauthorized(缺凭证、缺已认证用户)、授权层 forbidden(member_required、missing_scope "this Plugin was not granted the X scope"、actor_membership_revoked)、并发层 conflict(revision_conflict "resource changed since it was loaded")、容量层 507 quota_exceeded,乃至上游失败 upstream_unavailable。中间件同样复用同一封装:server/internal/middleware/plugin_auth.go 对缺失 bearer token 返回 plugin_bearer_required;server/internal/middleware/plugin_ratelimit.go 对超限返回 rate_limited。这正是"路由前后同一契约"承诺的完整体现。
另外,乐观并发的边界校验也在此 handler 中落实:expected_revision 必须是正整数,If-Match 头必须解析出正 revision,两者同时给出时必须指向同一 revision(否则 invalid_if_match / revision_mismatch),最终冲突才以 409 revision_conflict 返回。
DTO 隔离:types.go 为何"刻意不碰" App API
types.go 的包级注释解释了 DTO 独立的动机:Issue "intentionally mirrors the fields already exposed by Plugin API v1, but is independent from handler.IssueResponse. Adding an App API field can therefore no longer widen the public contract by accident."
即:App API 新增字段时,Public 契约不会被意外拓宽——公共契约只能显式地、经过账本与 OpenAPI 评审地增长。DTO 细节与 OpenAPI schema 严格对应,几个值得注意的点:
Issue携带revision(int64,最小 1)与last_activity_at、metadata、properties等完整字段集合,description、assignee_type、parent_issue_id、project_id、stage等可为 null;PatchIssueRequest中三个字段全部可选,但anyOf约束要求title与description至少给一个,expected_revision用于条件写;CreateCommentRequest的content上限 65536 字符,parent_id可选(用于线程回复);Comment的author_type枚举为member/agent/plugin;- 插件存储键最长 1024 字符,
value上限 102400 字符,scope枚举为user/workspace。
OpenAPI 的服务器与鉴权部分同样体现了信任面设计:servers 指向插件 API 域(https://plugin-api.multica.ai/v1),securityScheme 为 pluginBearer,bearer token 前缀为 mpi_(安装)或 mpc_(回调)——与 foundation.go 中 plugin_installation / plugin_invocation 两个凭证族对应。
插件面与用户面的隔离约束
README 给出了一条硬性架构约束,值得单独强调:
Both surfaces should call the same Go service and handler layer. The Plugin API must not make an additional HTTP request to the user/PAT API.
即两个信任面必须调用同一层 Go 服务与 handler,插件 API 不得为了访问用户/PAT 面而发起二次 HTTP 请求。从源码结构看,当前实现符合这一约束:插件路由(server/cmd/server/router.go 中以 publicapiv1.BasePath 挂载的 /v1 子路由)与 App API 路由共用同一组 handler 与服务层,鉴权差异被收敛到各自的中间件(如 plugin_auth.go)与 Actor 注入,而不是服务层内的服务间调用。
滚动迁移账本(Rollout ledger)与演进规则
README 末尾的"Rollout ledger"记录了当前切片的进度与规划:
- 已迁移:Issue 读/内容更新、Comment 读/创建;Context 与 Storage 仍是插件扩展;Hook 与 invocation 投递仍在 bridge 上;
- 后续垂直切片:Projects、Members、Issue 搜索/创建/状态流转、Agents、Squads、Skills、Tasks/Runs、Autopilots;
- 切片接入顺序:每个切片先加 Public 契约,再显式地把安全的插件子集选入能力账本。
版本演进规则("Additive fields and endpoints remain in v1")同样明确:
- v1 内只做增量:新增字段与端点留在 v1;
- 破坏性线协议变更必须升主版本,并给出迁移窗口;
- 新写操作必须采用共享组件:应使用共享的
Idempotency-Key与 revision/ETag 组件,而不是为每个端点自造并发控制规则。
这一"契约先行、账本显式选入"的迁移模式,与合同测试的负向断言(如 /hooks/{hook_key} 不得泄漏进公共契约)共同保证了:插件专属能力不会隐式扩大公共 API 的攻击面,公共资源也不会被 App API 的字段增长意外污染。
小结
server/pkg/publicapi/v1 展示了一种在单仓库内治理多信任面 API 的工程范式:OpenAPI 文件钉死线协议,routes.go 账本钉死凭证族、scope、风险、审计与限流政策,types.go 的独立 DTO 阻断 App API 对公共契约的意外拓宽,problem.go 让鉴权失败与资源失败共享同一错误契约,foundation.go 为未来切片提供统一词汇,而 contract_test.go 把上述所有不变式变成可执行的测试断言。对于要在 Multica 之上构建插件、PAT 集成或自动化脚本的开发者,这一包既是 API 参考(字段、限制、错误码),也是理解 Multica"一个契约、多个信任面"架构的入口。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00