OpenProgram Docs

凭据连接信息统一(一个 Credential + 一个解析出口)#

发一次请求所需的一切都集中在一个凭据结构里,再由一个解析出口交给 wire 层。凭据不会在中途被压成一根 api_key 字符串,base_url 不再只属于模型, "是不是 OAuth"也不靠 token 前缀去猜。

由此,一把 key 可以带自己的 base_url:同一个 openai-completions 协议下, 一把 key 打官方端点、另一把 key 打阿里云百炼 compatible-mode/v1,不需要为每个 兼容端点预建一份模型清单。

本文是 unified-auth-storage.md 的一次具体化:那份 文档定的是"一个存储、一套登录、跨界面统一"的整体方向;本文只收敛其中的 payload 结构层,不涉及登录注册表、存储路径、刷新所有权等议题。


一次请求需要两样东西#

发一次 LLM 请求要知道打到哪个地址(base_url)用哪个鉴权值(key/token)。 如果这两样各走各的线,凭据就只能贡献其中一半:

  • base_url 线:模型清单里每条模型写死一个 base_url → 加载成 Model → wire 层直接读 model.base_url。全程只跟模型绑定,不经过凭据。
  • 鉴权值线:AuthStore 的 CredentialPool 存凭据 → wire 层调 auth.usage.acquire_pooled(provider) → 内部 mgr.acquire_sync() 拿到完整的 Credential 对象。

如果最后一步把 Credential 压成一个 str 再交出去(按 6 种 payload 各自抽出一根 字符串:ApiKeyPayload→api_keyOAuth/DeviceCode→access_tokenCliDelegated→读外部文件credential_process→执行辅助命令sso→不支持),那么凭据知道的 base_url、headers 以及自己的 kind 就全部丢失。后果有两个:凭据里即使存了 base_url,wire 层也读不到;anthropic wire 只能靠 "sk-ant-oat" in key 猜这是不是 OAuth token,因为 kind 信息也一并没了。

所以解析出口交出的不是一根字符串,而是一次请求所需的完整连接信息。

一个凭据结构#

openprogram/auth/types.py一个 CredentialData(承载在 Credential.payload 位置)覆盖所有验证方式,而不是每种验证各建一个子类型。 不同验证方式的信息量差异是真实的 —— 简单验证信息少、复杂验证信息多 —— 因此 共性字段固定,差异部分放进一个 data 字典:

@dataclass
class CredentialData:
    # —— 共性字段:所有验证方式都在同一位置回答的「发请求要用什么」——
    kind: str                     # "api_key" | "oauth" | "device_code" |
                                  # "cli_delegated" | "credential_process" | "sso"
    auth_value: str = ""          # 最终要放进 Authorization/x-api-key 的鉴权值:
                                  #   api_key 类 → key 本身
                                  #   oauth/device → access_token
                                  #   cli_delegated → 空(运行时从外部文件读,见 data)
    base_url: str = ""            # 该凭据指定的端点;空 ⇒ 用清单里的默认值(见解析规则)
    headers: dict = field(default_factory=dict)   # 该凭据附带的额外请求头;多数为空

    # —— 差异容器:某种验证特有的一切都进这里 ——
    data: dict = field(default_factory=dict)

data 里按 kind 放各自私有的字段,不预留成正式字段,这样一份 api_key 凭据不会 带着一堆空的 oauth 字段:

kind auth_value data 里装什么
api_key key 本身 (通常空)
oauth access_token refresh_token / expires_at_ms / client_id / token_endpoint / scope / id_token
device_code access_token refresh_token / expires_at_ms / device_code_flow_id
cli_delegated store_path / access_key_path / refresh_key_path / expires_key_path
credential_process command / parses / json_key_path / cache_seconds
sso broker / subject

展示信息不进 payload。 账号邮箱、显示名、org id 等留在 Credential.metadata (UI 渲染它、manager 不解释它)。它们不影响请求怎么发,是 UI 与用量统计的消费 对象;混进连接信息只会让"要用的"和"给人看的"再次纠缠。

一个解析出口#

一个函数把凭据翻译成 wire 层真正要用的连接信息:

@dataclass
class ResolvedConnection:
    kind: str                     # 凭据类型——wire 判 OAuth 不再靠 key 前缀猜
    auth_value: str               # 已解析好的鉴权值(cli_delegated 已现读外部文件填好)
    base_url: str | None          # 凭据指定的端点;None ⇒ 让 wire 回退到 model.base_url
    headers: dict                 # 凭据附带的额外请求头(默认空)

def resolve_connection(cred: Credential) -> ResolvedConnection | None:
    """把一份 Credential 翻译成一次请求的连接信息。
    cli_delegated 在此现读外部文件取 token(保持它「外部 CLI 权威」的语义)。
    credential_process 在这里执行辅助命令,命中 cache_seconds 缓存窗口则复用;
    命令失败抛 AuthCredentialProcessError,resolver 各层原样上抛不再回退,
    因为用户显式配置的取值方式不该被别处的凭据悄悄顶替。
    sso 抛 AuthConfigError:该类型是预留的,没有任何流程能产出或使用它。"""

auth.usage.acquire_pooled 返回 (conn: ResolvedConnection, profile, cred_id) 而不是 (token: str, profile, cred_id),这样凭据知道的连接信息不会在半路丢失。 它内部本就持有完整的 cred,只是把末尾的字符串抽取换成 resolve_connection

wire 层:凭据优先,清单兜底#

各 wire(openai_completions / openai_responses / anthropic)统一按这套规则 取值:

conn = <来自 acquire_pooled  ResolvedConnection None>
api_key  = conn.auth_value if conn else opts.api_key
base_url = (conn.base_url if conn and conn.base_url else None) or model.base_url
headers  = { **(model.headers or {}), **(conn.headers if conn else {}), **(opts.headers or {}) }
is_oauth = bool(conn and conn.kind in ("oauth", "device_code"))

规则一句话:凭据带了 base_url 就用凭据的,没带就用模型的默认值。 于是官方 openai / deepseek / anthropic 照常工作(凭据不填 base_url),而接入百炼只需在存 key 时填上 base_url = https://…maas.aliyuncs.com/compatible-mode/v1,该凭据的 请求就打到百炼,其余不受影响。

Credential(CredentialData{auth_value, base_url, headers, kind, data})
      │
      └─(resolve_connection)─► ResolvedConnection{kind, auth_value, base_url, headers}
            │
            └─ wire: base_url = conn.base_url or model.base_url  ─► AsyncClient(...)
                     模型清单里的 base_url 仅作 conn.base_url 为空时的兜底默认

影响范围#

改动:

  • openprogram/auth/types.py:6 个 payload 类合并为一个 CredentialData_payload_to_dict/_payload_from_dict 随之简化为单类型序列化 (kind + 扁平字段 + data)。
  • openprogram/auth/resolver.pyresolve_connection 取代返回裸 str 的路径。
  • openprogram/auth/usage.pyacquire_pooled 返回 ResolvedConnection 三元组。
  • 各 wire(openai_completions.py / openai_responses/* / anthropic/anthropic.py):按上面的取值规则处理鉴权、base_url、headers 与 oauth 判定。
  • 读 payload 具体字段的地方(manager 的 OAuth 刷新读 refresh_token/expires、 delegated 读外部文件路径等):改为从 CredentialData.data[...] 取。

保留:

  • 模型清单里的 base_url 仍在,作为凭据未指定时的默认值。内置 provider 开箱 即用不变。
  • Credential.metadata 与 OAuth 的邮箱等展示信息,UI 与用量统计照旧读取。
  • 登录注册表、存储路径、刷新所有权、跨界面统一等议题不在本文范围内。

旧格式:一次性迁移#

运行时(_payload_from_dict / resolve_connection)只认新结构。读到旧的 6-payload JSON(带 __type__ 判别符)是错误,不是回退路径。既有凭据由一个 一次性迁移器搬到新结构,用户无需重新登录。

openprogram/auth/_migrate_payload.py 把每个 ~/.openprogram/auth/<provider>/<profile>.json 里的旧 payload 就地转成新的 CredentialData,原子写回(沿用 store 的 write→fsync→replace)。转换规则:

__type__ → 新 kind auth_value data(其余字段整体搬入)
ApiKeyPayload api_key api_key {}base_url/headers 若旧无则空)
OAuthPayload oauth access_token refresh_token expires_at_ms scope client_id token_endpoint id_token extra
DeviceCodePayload device_code access_token refresh_token expires_at_ms device_code_flow_id
CliDelegatedPayload cli_delegated "" store_path access_key_path refresh_key_path expires_key_path
ExternalProcessPayload credential_process "" command parses json_key_path cache_seconds
SsoPayload sso "" broker subject

迁移器是幂等的:payload 已是新结构(有 kind 顶层字段、无 __type__)则跳过。 首次 AuthStore 加载时自动运行,也可用 openprogram auth migrate 手动触发。 _rotation/_active/_disabled/_order.json 等不含 credentials 的管理文件没有 payload,迁移器跳过它们。

测试#

  • resolve_connection:每种 kind 各一条 —— api_key 带/不带 base_url、oauth 出 access_token、cli_delegated 现读外部文件、credential_process 跑假辅助脚本 (json 与 text 两种解析、缓存窗口内不重复 fork、各类失败一律报错不回落)、 sso 报错。
  • 序列化往返:CredentialData → dict → CredentialData 字段一致(含 data)。
  • wire 取值规则:凭据带 base_url 用凭据的,不带用 model.base_urlkind=oauthis_oauth 为真且不依赖前缀。
  • 端到端:存一把带 base_url=百炼api_key 凭据,跑 openai-completions 验证客户端 base_url 指向百炼,而官方 openai 凭据仍指向默认端点。
  • 迁移器:旧 ApiKeyPayload/OAuthPayload/CliDelegatedPayload JSON 各一条, 迁移后 kind/auth_value/data 正确且无 __type__;对已是新结构的文件 幂等跳过;管理文件(_rotation.json 等)不被改动。
Last updated · 2026-08-13