OpenWork 与 LiteLLM 集成:零依赖 per-member 虚拟密钥对账方案实战
【免费下载链接】openworkThe open-source alternative to Claude Cowork (powered by opencode)项目地址: https://gitcode.com/GitHub_Trending/ope/openwork
本文基于 OpenWork 仓库中
examples/litellm-per-member-keys示例展开。该示例是一个零依赖的 Node.js provisioner(对账器),用于将 OpenWork Cloud 的 per-member LLM 凭据绑定与 LiteLLM 虚拟密钥(virtual key)体系进行双向同步,解决"一个上游密钥被多成员共享"的安全问题。读完本文你将掌握:如何配置该 provisioner 的环境变量、理解reconcile与offboard两条命令背后的完整调用链与 fail-closed 校验逻辑,以及如何用一条命令拉起完整的本地验证环境(桌面客户端 + 隔离的 Den 组织 + 数据库版 LiteLLM 网关)。
背景:为什么需要 per-member 凭据绑定
在组织场景中,管理员往往只持有一个上游 LLM 网关(如 LiteLLM)的接入密钥。若把这个共享密钥直接配给所有成员,将无法审计"谁在用"、也无法单独吊销"某个人"的访问权。OpenWork 的解决方案是per-member 凭据绑定:为组织中的一个托管 LLM Provider 声明credentialMode: "per_member",由外部网关(例如 LiteLLM)为每个成员签发独立的虚拟密钥,OpenWork 侧只保存成员与外部凭据 ID 的绑定关系。
这一点在 OpenWork 的官方 API 契约文档 per-member-llm-credentials.mdx 中有明确说明:
shared:仅保存一个 Provider 凭据,所有被授权成员通过 connect 路由获得同一份凭据;per_member:为调用方组织成员解析各自独立的绑定,Provider 可以在绑定存在之前就完成授权。
需要特别强调的是:LiteLLM 的密钥铸造与元数据发现能力并未内建于 Den。官方文档明确指出"LiteLLM provisioning and metadata discovery are not built into Den",因此组织需要自行运行一个 provisioner,把它对接在 Den 的通用 Provider/凭据绑定 API 与 LiteLLM 管理 API 之间——这正是本文主角examples/litellm-per-member-keys/provision.mjs的定位:示例级对账器,而非 Den 的原生 LiteLLM 集成。
环境变量配置
在仓库根目录examples/litellm-per-member-keys下,provision.mjs通过configFromEnv()(源码 provision.mjs)从环境读取全部配置。下表逐项说明含义:
| 环境变量 | 含义 | 源码中的处理 |
|---|---|---|
OPENWORK_DEN_API_URL | Den API 基础地址 | 经cleanBaseUrl去除尾部斜杠(provision.mjs) |
OPENWORK_DEN_TOKEN | 组织 owner 或 admin 的 Bearer Token | 作为authorization头发送 |
OPENWORK_ORG_ID | 组织 ID | 作为x-openwork-org-id请求头发送(provision.mjs) |
OPENWORK_LLM_PROVIDER_ID | per-member LLM Provider 的 ID | 用于 URL 路径编码encodeURIComponent |
LITELLM_BASE_URL | LiteLLM 基础地址,带或不带/v1均可 | 通过liteLlmAdminBaseUrl剥离末尾的/v1(provision.mjs) |
LITELLM_MASTER_KEY | LiteLLM master key | 用于所有 LiteLLM 管理接口的 Bearer 认证 |
LITELLM_MODELS | 逗号分隔的模型 ID 列表 | 经去重、trim 后作为每个虚拟密钥的授权模型集合(provision.mjs) |
配置完成后,即可触发对账:
node provision.mjs reconcile该脚本是零依赖的:只用 Node.js 内建能力(fetch、node:url),通过// @ts-check加 JSDoc 类型注释获得静态检查,没有任何npm install步骤。
reconcile 的两阶段流程
reconcile命令内部调用reconcileMemberKeys()(provision.mjs),分两个阶段执行:先同步模型元数据,再铸造缺失的成员密钥。
阶段一:Provider 模型元数据同步
syncProviderModelMetadata()(provision.mjs)的执行顺序:
- 调用
GET {denApiUrl}/v1/llm-providers?scope=manageable获取可管理的 Provider 列表,并校验目标 Provider:- 必须存在(
providerId was not found); source必须为custom;credentialMode必须为per_member(对应源码 manageableProvider)。
- 必须存在(
- 使用 master key 调用 LiteLLM
GET /model_group/info拉取模型组元数据。 - 对
LITELLM_MODELS中的每个模型要求精确的model_group精确匹配,且max_input_tokens、max_output_tokens必须是有限的正数(对应 liteLlmModelMetadata 中的requirePositiveNumber)。若 LiteLLM 漏掉了某个请求的模型、或缺少任一限额,在创建任何成员密钥之前就 fail closed(失败即中止)——示例从不猜测 token 限额,也不回退到通用值。 - 将 LiteLLM 提供的能力事实映射到 Den 的模型字段:
| LiteLLM 元数据字段 | Den 模型字段 | 说明 |
|---|---|---|
max_input_tokens | limit.context/limit.input | 上下文与输入 token 上限 |
max_output_tokens | limit.output | 输出 token 上限 |
supports_function_calling | tool_call | 函数调用能力 |
supports_reasoning | reasoning | 推理能力 |
supports_vision | attachment | 视觉/附件能力 |
supports_response_schema | structured_output | 结构化输出能力 |
supported_openai_params含temperature | temperature | 温度参数支持 |
该映射逻辑集中在 synchronizedModelConfig:只有 LiteLLM 明确给出布尔事实时才写入对应字段(typeof metadata.facts.supports_function_calling === "boolean"),绝不臆造。
- 构造"当前配置"与"期望配置"两份快照后,用
canonicalJson(对对象键递归排序,provision.mjs)做规范化比较;仅当确实发生变化时才向 Den 发送PATCH /v1/llm-providers/:id,否则返回{ action: "unchanged" },避免无意义写入。
这个"全量替换式 PATCH"有一个关键安全细节:请求体中不包含apiKey/apiKeys字段,因此 Den 会保留已落库的只写凭据(write-only stored credential),不会在元数据同步时被覆盖或泄露。
阶段二:铸造缺失的成员密钥
元数据同步完成后,脚本调用GET /v1/llm-providers/:id/member-credentials列出所有被授权成员绑定的凭据状态,对每个state === "missing"的成员:
- 生成 key alias:
openwork-${orgMembershipId},并携带metadata.openwork_org_membership_id,调用 LiteLLMPOST /key/generate铸造虚拟密钥; - 要求响应中必须同时包含
key(明文密钥)与token_id(externalCredentialId 中强制校验,缺失则拒绝落库); - 调用 Den
PUT /v1/llm-providers/:id/member-credentials/:orgMembershipId,写入{ apiKey, externalCredentialId, externalPrincipalId? }。
值得注意:LiteLLM v1.97 返回的token_id可以不保留明文密钥即可寻址该虚拟密钥。示例将token_id存入 Den 的externalCredentialId,之后管理员列表接口可以把该标识安全地回传给 provisioner——这正是 offboard 阶段不需要明文密钥的前提。
最终输出摘要中只包含元数据动作(updated/unchanged)与安全的模型限额、以及新铸造凭据的externalCredentialId,绝不打印成员密钥本身。provision.mjs中多处通过redact()(provision.mjs)在错误信息里对denToken、liteLlmMasterKey、成员apiKey等敏感串做[REDACTED]脱敏,且所有请求统一走 30 秒超时的requestJson(provision.mjs),错误文本截断为 1000 字符。
Den 侧 API 契约速览
为读懂 provisioner 的每一步,这里补上 Den 侧契约(详见 per-member-llm-credentials.mdx):
成员视角(只能管理自己的只写凭据):
PUT /v1/llm-providers/:id/my-credential:写入自己的凭据,body 只能是{ "apiKey": "..." }或{ "apiKeys": { "ENV_NAME": "..." } }二者之一;DELETE /v1/llm-providers/:id/my-credential:删除自己的凭据;GET /v1/llm-providers/:id/connect:获取 Provider 配置与解析后的凭据。即使绑定缺失也返回 HTTP 200,凭据字段为 null,并附带memberCredential.state。
管理员/Provisioner 视角(中央对账):
GET /v1/llm-providers/:id/member-credentials:列出每个被授权成员绑定的状态、版本与外部标识,永不返回凭据明文;PUT /v1/llm-providers/:id/member-credentials/:orgMembershipId:写入某成员的凭据,body 支持apiKey/apiKeys,可选externalPrincipalId、externalCredentialId、expectedVersion;POST /v1/llm-providers/:id/member-credentials/:orgMembershipId/block:将既有绑定标记为 blocked。
其中memberCredential.state取值包括missing、active、blocked、stale、error。两个并发相关的语义:expectedVersion用于多 provisioner worker 场景,版本不匹配时返回 HTTP 409{ "error": "version_conflict" };被 block 的绑定由管理员持有,成员无法覆盖或删除,成员侧写/删请求返回 HTTP 409{ "error": "credential_blocked" },管理员PUT是显式的解封与替换路径。
offboard:必须先吊销上游,再标记本地
撤下某成员时,顺序规则是:先 block 上游 LiteLLM 密钥,再标记 Den 绑定为 blocked。原因在于:若上游吊销失败,应让 Den 绑定保持 active,使失败可见、可重试,而不是制造一个"本地已封禁"的假象。
node provision.mjs offboard member_...offboardMember()(provision.mjs)执行的顺序:
- 从
GET /v1/llm-providers/:id/member-credentials读取该成员的externalCredentialId(即 LiteLLM 的token_id);若不存在则直接报错拒绝执行; - 调用 LiteLLM
POST /key/block,body 为{ "key": credentialId },并确认成功——LiteLLM v1.97 接受其生成的token_id,因此 offboard 阶段不需要成员密钥的明文; - 调用 Den
POST /v1/llm-providers/:id/member-credentials/:orgMembershipId/block标记本地绑定; - 验证成员 connect 请求返回 HTTP 200、凭据为 null 且
memberCredential.state: "blocked"。
这一顺序也被 per-member-llm-credentials.mdx 文档作为官方推荐流程列出。
一键拉起完整本地验证环境
README 提供了一个"可运行的整体世界",用于手工演练完整闭环(桌面客户端 + 隔离 Den 组织 + 数据库版 LiteLLM 网关,且 Provider 与成员密钥已预先对账完成)。从仓库根目录执行:
pnpm world up ./worlds/litellm-per-member.ts启动后会打印:Den 的 URL、LiteLLM URL、同步后的模型限额、Den Provider 记录 ID、以及桌面客户端的 CDP URL。测试期间保持运行,结束后按 Ctrl-C 拆除整个环境(world 的 teardown 会自动清理)。
前置条件:Docker、本地 MySQL、本地 Redis。该 world 的网关使用确定性的本地 OpenAI 兼容 witness(witness),不会读取也不需要OPENAI_API_KEY。
从源码看,这个 world 的实现位于 worlds/litellm-per-member.ts:它启动一个数据库版 LiteLLM(database: true,对应 evals/packages/env/src/litellm.ts 中以ghcr.io/berriai/litellm:v1.97.0镜像拉起网关与postgres:16-alpine的流程),创建名为LiteLLM Per-Member World的组织、管理员与成员 Alice,用liteLlmPerMemberProvider(evals/packages/env/src/litellm-provider.ts)完成 Provider 创建 + 元数据对账,并以管理员身份登录桌面客户端、将模型指向{provider}/{model},最后输出 Alice 的 per-member 虚拟密钥、master key、upstream key 等凭证供手工验证。
这里有一个值得玩味的工程细节:witness 网关会校验虚拟密钥的指纹。在 litellm.ts 的 witness 中,每个上游请求都记录了tokenId(对携带的 Bearer token 做 SHA-256),只有与upstreamTokenId指纹匹配的请求才会返回 200——这意味着任何未走 Den 下发、非法铸造的密钥都会在网关层直接被拒,整条链路可观测、可审计。这为验证"每成员一密钥"是否真正生效提供了最直接的证据。
从源码看对账器的设计要点
- 保守的 fail-closed 哲学:从
requirePositiveNumber、requireString到"模型必须精确匹配 model_group 且限额必须是有限正数",任何元数据缺口都会让对账在铸造密钥前整体中止,绝不回退猜测值; - 最小写、保现状:Den PATCH 仅在规范化 JSON 比较后确有变化才发出,且全量替换时省略
apiKey/apiKeys,保留 Provider 配置、模型名与未知字段、当前成员/团队授权不变,只更新 token 限额与能力字段; - 密钥只在最短窗口内出现:明文
apiKey仅在铸造响应与写入 Den 的请求之间停留,随后只以token_id(externalCredentialId)继续流转,错误信息全程脱敏; - 与 Den 核心解耦:
/model_group/info调用与字段映射被刻意实现为 LiteLLM 专属的示例逻辑,而 Den 的 Provider PATCH 与 per-member 凭据 API 只接收通用模型配置,不硬编码任何 LiteLLM 行为,从而保持厂商中立。
延伸阅读
- API 契约与成员/管理员双视角流程:per-member-llm-credentials.mdx
- 示例完整实现:provision.mjs
- 本地 world 编排:worlds/litellm-per-member.ts
- 测试环境封装(witness 网关与 Provider 对账):evals/packages/env/src/litellm.ts、evals/packages/env/src/litellm-provider.ts
该集成在 OpenWork 的 eval 体系中作为可执行证明存在:evals/packages/env/src/litellm-provider.ts会直接import示例模块并调用reconcileMemberKeys,验证其返回的元数据动作与模型限额符合预期,同时该世界也登记在evals/specs/shared-world-engine.test.ts的 world 清单中,可纳入自动化回归。
【免费下载链接】openworkThe open-source alternative to Claude Cowork (powered by opencode)项目地址: https://gitcode.com/GitHub_Trending/ope/openwork
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考