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

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

- 作者：David（道雾轩）
- 专栏：MiniMax Code 源码解读（https://daiw.org/manual/minimax-code.md）
- 最后更新：2026-09-19
- 原文：https://daiw.org/manual/minimax-code/model-system
- 转载与引用：请注明出处并附原文链接（https://daiw.org/about/copyright）

前三篇讲的都是 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`）：

```ts
/**
 * 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` 的完整链路如下：

```mermaid
sequenceDiagram
    participant C as mcode CLI
    participant A as McodeProviderApplication
    participant S as 模型系统服务
    participant T as ModelConnectionTester
    participant U as BYOK 服务端
    participant F as config.yaml
    C->>C: 读环境变量里的 API Key
    C->>A: saveCandidate（第一个模型，saveAndUse）
    A->>S: saveUserModelProviderCandidate
    S->>T: 用候选配置测第一个模型
    T->>U: 非流式 ping，10 秒超时
    U-->>T: 响应或错误
    alt 测试失败
        S-->>C: ok 为 false，配置一个字都不写
    else 测试通过
        S->>F: 加锁，核对版本，写状态缓存、provider 与默认模型
        S-->>C: 已添加并选中
    end
```

命令行这头（`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`：

```ts
  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`：

```ts
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;
}
```

落盘后的样子大致如下（示意，其余字段略）：

```yaml
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 通道](https://daiw.org/manual/minimax-code/pi-ai)。

## 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-messages` | `thinking.type` 设为 `adaptive`（默认思考模型则删掉 `thinking`），`output_config.effort` 设为所选强度 |
| `openai-completions` | `reasoning_effort` |
| `openai-responses` | `reasoning.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 与应用层](https://daiw.org/manual/minimax-code/runtime-entry)——`mcode` 启动后，TUI、exec 与 ACP 怎样经 CliService 进入运行时的应用层。
