模型系统:目录、BYOK 与连通性测试

local-runtime-v2 的模型系统怎样把模型键解析成 pi-ai 的 Model,BYOK 如何先测后存、API Key 存在哪里,以及内置的 models.dev 目录快照怎样更新。

作者 David更新于 第 8 篇(共 33 篇)

前三篇讲的都是 pi。从这里开始是 MiniMax 自己的代码:packages/local-runtime-v2/src/service/model-system(非测试代码 40 个文件、9492 行)是运行时里模型的唯一 owner。它的说明文档 packages/local-runtime-v2/src/service/model-system/README.md:3 开宗明义,同时维护两组能力:一是解析,把配置、登录状态和 Agent 的模型引用变成 Agent 循环要用的 LLMModelConfig;二是 provider 管理,负责 BYOK 配置、模型目录、连通性测试、状态缓存与 OAuth。配置文件的读写则落在 packages/config。

怎么用

命令行入口都在 packages/tui/src/cli/program.ts:168 起的 provider 子命令:

命令作用
mcode provider打开 TUI 并执行 /provider
mcode provider list [--json]列出 provider,Key 只显示掩码
mcode provider add --name <名称> --base-url <地址> --model <ID>添加自定义 provider;--model 可重复,--api-format 缺省 anthropic-messages
... --api-key-env <变量名>从哪个环境变量读 Key,缺省 MCODE_PROVIDER_API_KEY
... --context-limit <n> --output-limit <n>给这次添加的所有模型写上下文与输出上限
... --use先用第一个模型做连通性测试,通过才保存并设为默认模型
mcode provider test <provider-id> [--model <ID>] [--json]测试一个 provider 或其中一个模型
mcode provider remove <provider-id> --yes删除自定义 provider,不带 --yes 拒绝执行
mcode provider use token-plan 或 api-key切换 MiniMax 的凭据来源
mcode provider set-minimax-key [--api-key-env <变量名>]保存并启用 MiniMax API Key

模型用 <provider-id>/<model-id> 这样的键来指代(packages/local-runtime-v2/src/service/model-system/resolution/model-key.ts:44),provider ID 有三种形态:官方 Token Plan 用的 minimax,自带 MiniMax API Key 的 minimax_api,自定义 provider 的 custom_provider:<key>;Codex OAuth 登录后也登记在自定义 provider 下,key 为 openai-codex(packages/local-runtime-v2/src/service/model-system/codex-oauth.ts:486)。mcode exec --model <provider-id>/<model-id> 可以只为一次运行换模型(docs/examples.md:50)。

TUI 里有两个命令:/model 选择模型(packages/tui/src/application/command-descriptors.ts:15),模型选择器里还能“添加 provider”,从下文的 models.dev 目录挑一家预设(packages/tui/src/tui/controller/product/feature-flow.ts:720);/provider 查看 provider、测试连接、刷新模型列表、编辑自定义 provider 与 MiniMax 凭据、切换 MiniMax 来源(packages/tui/src/tui/commands/catalog.ts:393、feature-flow.ts:924)。仓库根的 README.md:79 说用 /provider 选择模型,与命令表的分工不完全一致:选模型的是 /model。

身份:三种来源、三种协议

packages/local-runtime-v2/src/service/model-system/identity.ts:11 把模型来源分成 provider(内置树,Token Plan 的 minimax 在这里)、minimax_api、custom_provider 三种;通用 BYOK 协议只有三种,Codex 被刻意排除在外(identity.ts:14):

/**
 * The generic BYOK wire protocols: a user-supplied base URL plus a bearer or
 * `x-api-key` credential that this runtime drives itself. Membership decides
 * base-URL normalization, connection-test and discovery URL shapes, and whether
 * a Thinking request patch is synthesized for the provider.
 *
 * `openai-codex-responses` is deliberately excluded even though
 * `LocalCustomProviderConfig.api` accepts it. The Codex OAuth provider is not a
 * generic endpoint: its credentials, base URL and reasoning controls are owned
 * by Pi's dedicated Codex transport, so this runtime must not rewrite its base
 * URL or inject a BYOK Thinking patch. Its Thinking level is still resolved and
 * exposed through the ordinary non-`anthropic-messages` path.
 */
export const MODEL_PROVIDER_APIS = [
  'anthropic-messages',
  'openai-completions',
  'openai-responses',
] as const;

“这个模型现在能不能调”由 packages/config/src/model-availability.ts 统一回答,模型列表、保存入口和回合执行共用这一个判定,免得三处说法不一。它先把 provider ID 映射成调用路线(model-availability.ts:90):minimax_api 走用户自己的 MiniMax Key,custom_provider: 前缀走自定义 provider,minimax 在用户把来源切到 API Key 时也改走 Key,否则是托管的 Token Plan。Token Plan 路线只允许官方模型表里启用的模型,minimax_api 路线用内置的 MiniMax API 模型表,自定义 provider 用用户自己配置的模型;Token Plan 下线某个模型,不会连带缩小 BYOK 路线的可选范围(model-availability.ts:112)。/model 里的模型列表就按这个判定拼出来:内置树的模型先过一遍路线过滤(packages/local-runtime-v2/src/service/model-system/catalog/list-models.ts:87),再接上 BYOK 模型,并附上各自最近一次连通性测试的状态(packages/local-runtime-v2/src/service/model-system/catalog/catalog.ts:24)。

先测后存:provider add --use

--use 的完整链路如下:

图表加载中…

命令行这头(packages/tui/src/cli/provider-command.ts:56)先从 --api-key-env 指定的变量(缺省 MCODE_PROVIDER_API_KEY)读出 Key 的值,读不到直接报错;带 --use 时调用 saveCandidate,并把第一个 --model 作为测试对象(provider-command.ts:80),失败时报错说明什么都没保存、也没切换默认模型,并以非零码退出;不带 --use 则直接 create 保存,不测试也不改默认模型。服务端还有一道闸:createUserProvider 收到 saveAndUse 会以 TEST_REQUIRED 拒绝(packages/local-runtime-v2/src/service/model-system/management/service-custom-provider-operations.ts:62),想“存并启用”就必须走测试路径。

测试路径的主体在 packages/local-runtime-v2/src/service/model-system/management/service-candidate-operations.ts:129:

  const resolved = context.resolveUserProviderCandidate(input.candidate);
  const modelId = input.modelId?.trim();
  const test = await prepareCandidateTest(context, resolved, input, modelId);
  if (test.failed) return { ok: false, status: requirePreparedCandidateStatus(test) };
  const commit: CandidateCommitState = {
    candidateCacheWritten: false,
  };
  const transaction: CandidateSaveTransaction = { resolved, input, modelId, test, commit };
  try {
    await persistCandidate(context, transaction);
  } catch (error) {
    await restoreCandidateAfterFailure(context, transaction, error);
  }
  throwCandidateCommitError(commit.blocked, input.skipConnectionTest === true);

测试针对的是尚未落盘的候选配置。测试通过后,persistCandidate 在配置文件锁里再核对一次:若测试期间别处改了同一个 provider,就以 CONFIG_CHANGED 放弃(service-candidate-operations.ts:205);然后先写测试状态缓存,再写 provider,最后在 saveAndUse 时把默认模型设成 custom_provider:<key>/<第一个模型>(service-candidate-operations.ts:237)。配置写失败会把缓存回滚到测试前。provider 的 key 由名称生成:ASCII 名称转成小写短横线形式,中文等名称则给一个 provider- 加随机十六进制的 key,与保留字或已有 key 冲突时加数字后缀(packages/local-runtime-v2/src/service/model-system/management/provider-key.ts:38)。

连通性测试怎么测

测试不经过 pi-ai,而是 packages/local-runtime-v2/src/service/model-system/connectivity/test-connection.ts 自己拼的一次非流式请求:消息只有一句 ping,按协议发到 /v1/messages、/responses 或 /chat/completions(packages/local-runtime-v2/src/service/model-system/connectivity/provider-request.ts:49);Anthropic 协议用 x-api-key 与 anthropic-version 头,两种 OpenAI 协议用 Bearer(provider-request.ts:9)。几个细节:

  • 输出预算与真实回合一致。探针的输出上限字段(Responses 协议是 max_output_tokens,其余是 max_tokens)用该模型实际生效的输出上限,缺省同样是 16384,注释的说法是让探针成为第一次真实回合的“彩排”:真实请求会因预算被拒,探针也会(test-connection.ts:177)。
  • 思考参数也一起带上。有配置思考强度时,探针附上与真实请求相同的思考补丁(test-connection.ts:195)。
  • 结果归一。默认 10 秒超时(test-connection.ts:24);401 与 403 记为 unauthorized,其余非 2xx 记为 http_<状态码>,超时 timeout,网络错误 network;返回 2xx 但正文里带着厂商错误的记 provider_error,结构不像该协议响应的记 invalid_response(test-connection.ts:248)。
  • 错误信息脱敏。上游的错误原因会先抹掉本次用到的 Key 与请求头取值,再按 sk- 前缀、Authorization、api-key 等常见形态二次清洗,最多保留 1200 个字符(test-connection.ts:157)。
  • 并发去重。同一个 provider 或模型的测试进行中时,新的调用直接复用那一次的结果(test-connection.ts:39)。

测试结果写进数据目录的 model-cache.json,文件头注释要求它只存归一化的状态、错误码、脱敏后的信息和时间戳,不存 Key、请求头与响应正文(packages/local-runtime-v2/src/service/model-system/catalog/model-cache.ts:14)。模型发现走另一个客户端:Anthropic 协议先请求 <base>/v1/models,只有 404 或 405 才依次退到 <base>/models 与源站的 /models,全都没有就返回 models_endpoint_missing,让界面提示手工添加模型(packages/local-runtime-v2/src/service/model-system/connectivity/discover-models.ts:32)。

API Key 存在哪里

明文存在 config.yaml 里,不是环境变量引用。--api-key-env 只在命令执行的那一刻读取变量的值,存下去的是值本身。配置文件是数据目录下的 config.yaml(packages/config/src/config.ts:1509),数据目录缺省 ~/.minimax-code,可用 MINIMAX_DATA_DIR 或 MAVIS_DATA_DIR 改(packages/tui/src/runtime/data-dir.ts:20、:28)。候选配置里的 Key 由 packages/local-runtime-v2/src/service/model-system/management/service-context.ts:83 写进 provider 的 options:

function candidateOptions(
  current: LocalCustomProviderConfig | undefined,
  input: UserModelProviderCandidateView,
  baseUrl: string,
): NonNullable<LocalCustomProviderConfig['options']> {
  const apiKeyUpdate = normalizeApiKeyUpdate(input.apiKey);
  const options = { ...(current?.options ?? {}), baseURL: baseUrl, authMode: 'api-key' as const };
  if (!current && apiKeyUpdate.kind !== 'set') {
    throw new LocalModelProviderError(400, 'API key must not be empty', 'INVALID_API_KEY');
  }
  if (apiKeyUpdate.kind === 'set') options.apiKey = apiKeyUpdate.apiKey;
  if (apiKeyUpdate.kind === 'clear') delete options.apiKey;
  applyCandidateHeaderUpdates(options, current, input);
  return options;
}

落盘后的样子大致如下(示意,其余字段略):

defaultModel: custom_provider:my-provider/my-model
custom_provider:
  my-provider:
    name: my-provider
    kind: custom
    enabled: true
    api: openai-completions
    options:
      apiKey: <明文 Key>
      baseURL: https://example.com/v1
      authMode: api-key
    models:
      my-model:
        limit:
          context: 32768
          output: 4096

MiniMax 自己的 API Key 同样明文存在 minimax_api.apiKey。明文之外的保护措施有这些:

  • 唯一写入口。minimax_api 与 custom_provider 这两棵带密钥的子树只能经 updateLocalByokConfig 修改,通用的配置更新接口够不着它们(packages/config/src/local-model-provider-write.ts:92)。
  • 文件锁与原子写。写之前用 proper-lockfile 加锁(过期判定 10 秒、最多重试 20 次),写到临时文件后改名替换,并沿用原文件的权限(local-model-provider-write.ts:232、:258)。
  • 权限。新建的配置文件权限为 0o600(local-model-provider-write.ts:17)。
  • 掩码不能回写。列表里的 Key 只显示首尾各 4 位,中间是 ****(packages/local-runtime-v2/src/service/model-system/secret.ts:1);含 **** 的 Key 在写入时直接拒绝,防止把界面上的掩码值当成新 Key 存回去(packages/local-runtime-v2/src/service/model-system/management/service-input.ts:16)。
  • 可以取回明文。管理服务提供 revealModelProviderApiKey 返回明文 Key 供界面显示,OAuth 凭据则拒绝取回(packages/local-runtime-v2/src/service/model-system/management/service.ts:92)。

读取这一侧在 packages/config/src/byok-config.ts:两棵 BYOK 子树被解析成类型化结构,旧版的思考强度写法顺带规范成 effortOptions 与 defaultEffort(byok-config.ts:91)。内置的 provider.minimax 每次读取都由 applyRequiredProviderOverrides 按当前预设补齐,受保护的构建还会把地址与认证方式拉回托管登录;按注释所说,这些调整只在内存生效,不写回文件,也不碰两棵 BYOK 子树(byok-config.ts:258),手写的 provider.minimax_api 则被忽略并告警(byok-config.ts:286)。早期把 BYOK 手写在 provider.* 下的配置,另有 migrateLegacyByokProvidersOnDisk 先备份再搬进 custom_provider(byok-config.ts:177),不过 TUI 启动时把这项迁移关掉了(packages/tui/src/cli/environment.ts:37)。

Codex OAuth 的凭据不进 config.yaml,由 CodexOAuthManager 借 pi-coding-agent 的 AuthStorage 存在数据目录的 codex-auth.json;登录过程中先放在内存,确认未取消才落盘(model-system/README.md:53,路径见 packages/local-runtime-v2/src/service/model-system/codex-oauth.ts:395)。不过这个入口受 beta.codexOAuth 控制,按公开仓库的构建脚本构建的 CLI 里是隐藏的,详见 pi-ai 一篇的 Codex 通道。

models.dev 目录快照

添加 provider 时看到的那份厂商与模型清单来自 models.dev(THIRD_PARTY_NOTICES.md:5 写明它是随包分发的目录快照,MIT 许可)。它和 pi-ai 自带的 models.generated.ts 是两套东西:后者只在解析时用来查内置 provider 的协议、地址以及上下文与输出上限(packages/local-runtime-v2/src/service/model-system/resolution/local-model-resolver.ts:819)。

存在哪里。有两份候选:

快照位置来源
内置快照仓库里的 packages/local-runtime-v2/assets/models-dev-catalog.json.gz,构建时复制到 dist/assets/(scripts/lib/local-runtime-assets.mjs:17)随版本提交
本地快照数据目录的 cache/models-dev-catalog.json(packages/local-runtime-v2/src/service/model-system/catalog/provider-presets/provider-presets.repository.ts:31),权限 0o600运行时刷新下载

读取时两份都解析,取 updatedAt 最新的一份,损坏的候选直接跳过(packages/local-runtime-v2/src/service/model-system/catalog/provider-presets/provider-presets.service.ts:277)。解压仓库里的内置快照可以看到,它是一个带 version、source、updatedAt、etag 与完整 catalog 的 JSON;本书所用快照中,这份文件的 updatedAt 是 2026-08-27,收录 203 家厂商。

如何更新。模型系统初始化时就创建 ProviderPresetCatalog,构造函数立刻在后台发起一次刷新,同一路径同一时刻只跑一次(provider-presets.service.ts:59、packages/local-runtime-v2/src/service/model-system/initialize.ts:60)。刷新按地区分两路(packages/local-runtime-v2/src/service/model-system/catalog/provider-presets/provider-presets.client.ts:36):

  • 国外直接请求 https://models.dev/api.json;本地已有刷新得来的快照时,带上它的 ETag 做条件请求(provider-presets.service.ts:243);
  • 国内先向 MiniMax 自己的服务请求 /mavis/api/v1/models-dev/catalog 描述符,拿到镜像地址后再下载,镜像必须是 filecdn.minimax.chat 上路径里带 64 位哈希的 HTTPS 地址,且不允许重定向(provider-presets.client.ts:10、:12)。

超时 8 秒(provider-presets.client.ts:6),刷新失败就继续用已有的快照。仓库自带的冒烟测试断言,连 mcode provider list 这样的命令也会发出这次目录请求(test/smoke.test.mjs:104)。至于仓库里那份 .json.gz,代码里没有找到重新生成它的脚本,只能随源码同步更新。

怎么变成预设。不是所有厂商都能成为预设(provider-presets.service.ts:106):npm 字段为 @ai-sdk/openai 的映射成 openai-responses,@ai-sdk/openai-compatible 映射成 openai-completions,@ai-sdk/anthropic 映射成 anthropic-messages,其余厂商丢弃;必须有合法的 HTTP 地址,模型必须声明支持工具调用;MiniMax 自家的四个条目被排除(provider-presets.service.ts:20)。按这套规则筛内置快照,能用的预设是 171 家(161 家 Chat Completions、Responses 与 Messages 各 5 家)。排在最前面的厂商优先取服务端下发的名单,拿不到就用内置的地区默认值(provider-presets.service.ts:26)。

解析:从模型键到 pi 的 Model

回合开始前,LocalModelResolver.resolveModel(local-model-resolver.ts:121)按三步决定路线:先看模型键是不是 minimax_api 或 custom_provider:,是就生成 BYOK 计划;再看 minimax 是否已切到 API Key 来源;都不是就走托管或内置 provider。

自定义 provider 的计划由 planCustomProviderResolution(packages/local-runtime-v2/src/service/model-system/resolution/model-resolver-byok.ts:75)给出:Key 取自 options.apiKey(OAuth 类型则运行时再取凭据),baseURL 必填,协议不认识就回落 anthropic-messages,模型没配上限时按上下文 200000、输出 16384 兜底(model-resolver-byok.ts:18),这正是文档里说的未知模型默认值(docs/examples.md:65)。

finishResolve(local-model-resolver.ts:196)最后用 buildResolvedModel(local-model-resolver.ts:616)拼出 pi-ai 的 Model:成本一律记 0,reasoning 取思考是否开启,附上 thinkingLevelMap 与 compat;流函数外面再包动态输出上限与 BYOK 错误归因;请求头里按地址自动加上 OpenRouter 的应用归属头与 OpenCode Go 的会话头(model-system/README.md:13)。

思考参数是这一层最绕的部分。对自定义 provider,resolveModelThinkingProtocol(packages/local-runtime-v2/src/service/model-system/resolution/model-ref.ts:442)把用户选的强度同时变成两样东西:一个 pi 的思考档位,和一份直接改请求体的补丁:

协议请求补丁
anthropic-messagesthinking.type 设为 adaptive(默认思考模型则删掉 thinking),output_config.effort 设为所选强度
openai-completionsreasoning_effort
openai-responsesreasoning.effort
强度为关闭分别发 thinking.type 为 disabled、reasoning_effort 或 reasoning.effort 为 none

MiniMax-M3 另有一套开关式的思考控制,不走强度档位(model-ref.ts:370)。Codex OAuth 的模型也复用这套可配置协议(local-model-resolver.ts:489),远端返回的等级原样写进请求,ultra 这类 pi 枚举里没有的新等级也不受影响(model-system/README.md:68)。补丁在发请求前经 pi 的 onPayload 深度合并进请求体,值为 undefined 的键表示删除(packages/local-runtime-v2/src/service/turn-system/agent-host/assembly/local-turn-payload-transform.ts:348)。TUI 还有一条专属策略:运行时的宿主类型是 tui 时打开 implicitCustomProviderThinking(packages/local-runtime-v2/src/application/session/runtime-session-composition.ts:103),自定义 provider 只要思考开关是开的,就默认按开启思考处理。

下一篇:进程内入口:CliService 与应用层——mcode 启动后,TUI、exec 与 ACP 怎样经 CliService 进入运行时的应用层。

本页目录