2026-10-21
AI
0

目录

1. 背景与目标
1.1 一个命令背后的四层状态
1.2 本篇范围
2. 命令架构
2.1 命令目录
2.2 命令入口
2.3 子命令分发
3. 数据模型设计
3.1 Provider 与模型分离
3.2 模型 ID
4. Provider 优先的配置流程
4.1 交互流程
4.2 一次性保存
5. 全局默认与会话切换
5.1 两层模型状态
5.2 临时切换实现
5.3 current 标记的 bug
6. 模型来源与动态发现
6.1 内置模型
6.2 用户模型
6.3 /models 动态发现
7. 配置持久化
7.1 进程内快照
7.2 原子写入
8. API Key 安全边界
8.1 为什么不能写进 config.json
8.2 配置与密钥分离
9. Secret Store
9.1 接口设计
9.2 分层顺序
9.3 macOS Keychain
9.4 Linux Secret Service
9.5 AES-GCM 文件回退
9.6 外部 Secret Command
10. API Key 输入
11. Provider 就绪判断
12. 错误和状态恢复

系列文章:从零实现一个通用智能体
本篇主题:/model 命令、Provider 配置、模型选择与 API Key 存储
代码版本:mx-claw 模型管理模块当前实现

项目内容
文章类型工程实现笔记
涉及模块apps/cli/src/commands/model、packages/model-provider
关键技术Commander、Inquirer、TypeScript、OpenAI-compatible API、Keychain、AES-GCM
阅读重点Provider 与模型的关系、会话级切换、API Key 安全存储

1. 背景与目标

这篇文章记录 mx-claw /model 命令的完整实现。内容从命令目录和数据模型开始,依次介绍 Provider 配置流程、全局默认模型、会话临时切换、动态模型发现和配置文件写入,最后重点 说明 API Key 为什么不能进入普通配置,以及系统 Keychain、外部 Secret Source 和 AES-GCM 文件回退如何衔接。

做 coding agent 到模型配置这一步,问题会突然变具体。

1.1 一个命令背后的四层状态

表面上 /model 只是让用户选个模型,但真正写起来,它至少牵扯四件事:

  1. Provider 的 Base URL、认证方式和连接状态;
  2. 一个 Provider 下可能有多个模型名称;
  3. 哪个模型是全局默认,哪个模型只对当前会话生效;
  4. API Key 到底放在哪里,怎么读,怎么写,怎么保证不进入日志和配置文件。

1.2 本篇范围

文章会沿着 mx-claw 当前的真实实现,从命令目录一直走到 /models 请求和 Secret Store。

本篇包括模型选择、Provider 切换、临时模型切换、模型发现,以及API Key存储。

2. 命令架构

2.1 命令目录

/model 不是一个几百行的文件,而是拆成:

text
apps/cli/src/commands/model/ index.ts 命令入口 handler.ts 子命令分发 interactive.ts 交互向导和临时切换 provider-selection.ts Provider/模型菜单数据 session.ts 配置变化后的会话同步 __tests__/ handler.test.ts

2.2 命令入口

index.ts 只做一层上下文适配:

ts
async execute(context) { if (!context.model) { writeLine(context.output, 'Model configuration is unavailable in this runtime.') return 'continue' } await handleModelCommand({ args: context.args, confirm: context.prompts.confirm, core: context.core, discoverModels: discoverProviderModels, output: context.output, provider: context.model.provider, prompt: context.prompts.prompt, readSecret: context.prompts.readSecret, selectModel: context.prompts.selectModel, selectProviderModel: context.prompts.selectProviderModel, selectProvider: context.prompts.selectProvider, session: context.session, store: context.model.store, }) return 'continue' }

这一层不解析参数,也不写交互流程。它只负责把 REPL、Inquirer、AgentCore 和配置服务接到一起。

2.3 子命令分发

真正的命令分发在 handler.ts:

text
/model 配置 Provider,并选择默认模型 /model configure [provider] 显式配置 Provider /model switch 在当前 Provider 内临时换模型 /model default ... 修改全局默认模型 /model status 查看状态 /model list 查看全部模型 /model refresh [provider] 从 /models 发现模型

这样拆之后,命令、交互、会话同步和持久化不会搅在一起。

3. 数据模型设计

3.1 Provider 与模型分离

一开始我只有一个 model 字段,后来发现这个数据结构不够用。

同一个 Provider 下面经常有多个模型,比如 DeepSeek 的 deepseek-flash 和 deepseek-v4-pro。用户切换这两个模型时,不应该重新配置 API Key,也不应该重新填 Base URL。

所以 ProviderConfig 现在长这样:

ts
export interface ProviderConfig { name: string kind: 'builtin' | 'custom' baseURL: string model: string models: string[] apiKey: string requiresApiKey: boolean configured: boolean }

字段含义要分清:

  • baseURL:该 Provider 的 API 地址;
  • apiKey:只在运行时内存中出现,写 config.json 时会被清空;
  • models:用户配置的可用模型名称;
  • model:该 Provider 当前的默认模型;
  • activeProvider:全局配置里当前生效的 Provider。

完整配置是:

ts
export interface ModelConfig { version: 1 activeProvider: string providers: Record<string, ProviderConfig> }

3.2 模型 ID

模型 ID 统一成:

text
provider/model

例如:

text
openai/gpt-4o deepseek/deepseek-reasoner ollama/qwen2.5-coder

Provider 负责连接信息,模型 ID 负责具体选择。这两件事分开后,模型切换就不用碰密钥和 Base URL。

4. Provider 优先的配置流程

4.1 交互流程

我中途把交互改过一次。

最初 /model 直接列出全部 provider/model,这在 Provider 少的时候没问题,但用户看到 的是:

text
openai/gpt-4o-mini openai/gpt-4o deepseek/deepseek-chat deepseek/deepseek-reasoner ollama/llama3.2

问题是一个还没配置好的 DeepSeek 模型也会混在列表里。用户很难分清自己是在“配置 Provider”,还是在“临时换模型”。

最后我把两个动作拆开。

配置 Provider:

text
/model

临时换模型:

text
/model switch

/model 的流程是:

text
选择 Provider -> Base URL -> 模型名称列表 -> API Key -> 选择默认模型 -> 保存并生效

实际交互大概是这样:

text
? Select a model provider ❯ openai deepseek ollama Base URL [https://api.openai.com/v1] Model names, comma-separated [gpt-4o-mini, gpt-4o] API Key for openai (input hidden): ? Select the default model ❯ gpt-4o-mini gpt-4o

也可以绕过菜单:

text
/model configure deepseek

对已经配置过的 Provider,第二次修改会先确认是否覆盖。对没配置的 Provider,不会弹 覆盖确认。

配置成功后会同时修改:

  • activeProvider;
  • 该 Provider 的 model;
  • 当前会话模型。

但不会删除其他 Provider。openai、deepseek、ollama 各自保留自己的 Base URL、 模型列表和密钥。

4.2 一次性保存

这部分逻辑在 configureInteractively() 里。关键是“先把所有输入收完,再一次性保存”:

ts
const updated = await options.store.configureProvider( selected, { apiKey, baseURL, model: defaultModel, models, }, overwrite, )

不要在填 Base URL 时保存一次,填模型时再保存一次,最后填 Key 又保存一次。中途取消会 留下半配置状态,后面很难判断 Provider 到底可用不可用。

5. 全局默认与会话切换

5.1 两层模型状态

这里有个很容易混掉的状态:

text
config.activeProvider + provider.model // 全局默认 session.model // 当前会话实际模型

我一开始让所有切换都写配置,结果用户只是临时想换个模型,下一次启动却被永久改了默认值。

5.2 临时切换实现

现在的规则是:

text
/model switch /model gpt-4o /model use openai gpt-4o

只修改当前会话。

修改全局默认要用:

text
/model default openai/gpt-4o

switchModel() 里通过 persist 参数区分:

ts
if (persist) { const updated = await options.store.selectProvider(providerName, modelName) syncSessionModel(options.core, options.provider, updated, options.session.sessionId) writeLine(options.output, `Default model: ${providerName}/${modelName}`) return } options.provider.setConfig(config) options.core.selectModel(options.session.sessionId, `${providerName}/${modelName}`)

临时切换也不会改 Provider:

ts
if (!persist && providerName !== config.activeProvider) { throw new Error( `Temporary model switch cannot change provider ` + `(${config.activeProvider} -> ${providerName}). ` + `Use /model default to change the default provider.`, ) }

这看起来有点“限制用户”,但状态更清楚。换 Provider 和换模型本来就是两个动作。

5.3 current 标记的 bug

/model switch 菜单会给当前模型加标记:

text
❯ deepseek-v4-pro · current deepseek-flash

第一版就有个 bug。

我先临时切到 deepseek-v4-pro,再次打开 /model switch,current 还标在 deepseek-flash。原因是当时 current 取的是:

ts
config.providers[config.activeProvider].model

这是全局默认,不是当前会话模型。

修复后优先读 session.model:

ts
// 临时切换只改 session.model;再次打开菜单时必须优先标记会话中的实际模型。 const current = modelOptions.some((option) => option.value === options.session.model) ? options.session.model : defaultModel

6. 模型来源与动态发现

当前模型目录有三路来源:

text
内置推荐模型 + 用户手动添加 + Provider /models 动态发现

6.1 内置模型

内置名称写在 defaultModelConfig():

ts
{ version: 1, activeProvider: "deepseek", providers: { openai: { name: "openai", kind: "builtin", baseURL: "https://api.openai.com/v1", model: "gpt-4o-mini", models: ["gpt-4o-mini", "gpt-4o"], apiKey: "", requiresApiKey: true, configured: false, }, deepseek: { name: "deepseek", kind: "builtin", baseURL: "https://api.deepseek.com", model: "deepseek-flash", models: ["deepseek-flash", "deepseek-v4-pro"], apiKey: "", requiresApiKey: true, configured: false, }, ollama: { name: "ollama", kind: "builtin", baseURL: "http://127.0.0.1:11434/v1", model: "llama3.2", models: ["qwen2.5-coder", "deepseek-r1"], apiKey: "", requiresApiKey: false, configured: false, }, }, }

内置目录保证离线也能使用,不至于打开 /model 先等一次网络请求。

6.2 用户模型

用户也可以手动加:

text
/model add-model openai gpt-custom

6.3 /models 动态发现

动态发现走 OpenAI-compatible 的 /models:

ts
export async function discoverProviderModels(provider, options = {}) { const fetchImpl = options.fetchImpl ?? fetch const headers = { accept: 'application/json' } if (provider.apiKey) { headers.authorization = `Bearer ${provider.apiKey}` } const response = await fetchImpl(`${provider.baseURL}/models`, { headers, method: 'GET', }) if (!response.ok) { const detail = (await response.text()).slice(0, 1_000) throw new Error(`Model discovery failed (${response.status}): ${detail}`) } return extractModelNames(await response.json()) }

刷新结果会合并,而不是覆盖:

text
/model refresh deepseek

这样内置模型、用户手动模型和 API 返回模型可以同时存在。

不过动态发现也要注意,/models 返回的是当前 Base URL 和 API Key 对应服务真正公开的 模型。账号页面、第三方控制台、网关别名和 API 模型 ID 可能不是同一个东西。

7. 配置持久化

7.1 进程内快照

默认路径:

text
~/.mx-claw/config.json

可以用 MX_CLAW_HOME 改目录。CLI 第一次 load() 后会把配置放进进程快照:

ts
async load(): Promise<ModelConfig> { if (this.#config) { return cloneConfig(this.#config) } // 读取 config.json,查询 Secret Store,然后保存快照 }

这样做有两个结果:

  • CLI 内修改配置,调用 save() 后立刻更新快照,当前会话马上生效;
  • 手动修改 config.json,当前进程不会自动重读,需要重新打开 CLI。

7.2 原子写入

写配置时用临时文件加原子替换,权限固定成 0600:

ts
await writeFile(temporaryPath, `${JSON.stringify(sanitizedConfig, null, 2)}\n`, { encoding: 'utf8', mode: 0o600, }) await rename(temporaryPath, this.configPath) await chmod(this.configPath, 0o600)

配置里只保存 Provider 元数据。真正写盘前,apiKey 会被清掉:

ts
const sanitizedConfig = { ...config, providers: Object.fromEntries( Object.entries(config.providers).map(([name, provider]) => [ name, { ...provider, apiKey: '' }, ]), ), }

8. API Key 安全边界

8.1 为什么不能写进 config.json

把 API Key 写进 config.json 最省事,但问题也很直接:

json
{ "apiKey": "sk-..." }

用户发个 issue、贴个配置、备份目录、同步到网盘,Key 就出去了。

8.2 配置与密钥分离

就算文件是 0600,也不能把它当普通配置。配置需要被读取、修改、分享,密钥不应该进入 这个生命周期。

text
config.json Provider、Base URL、模型名称、当前选择 Secret Store API Key 日志 不记录 Key 终端输出 只显示 [configured]

ProviderConfig 在内存里仍然有 apiKey,因为发请求需要它。但它不是可持久化字段。

9. Secret Store

9.1 接口设计

我抽象了一个很小的接口:

ts
export interface SecretReader { readonly backendName: string get(key: string): Promise<string | null> } export interface SecretStore extends SecretReader { set(key: string, value: string): Promise<void> delete(key: string): Promise<void> }

9.2 分层顺序

默认读取顺序:

text
外部只读 Secret Command -> macOS Keychain / Linux Secret Service -> AES-256-GCM 加密文件

写入顺序:

text
系统安全存储 -> 不可用时回退加密文件

这套顺序参考了 Hermes 的外部 Secret Source,并把系统安全存储放在本地文件之前。最终做成 了适合 mx-claw 的组合。

9.3 macOS Keychain

macOS 上默认用 Keychain。

service 名不是写死的 mx-claw,而是把 MX_CLAW_HOME 哈希进去:

ts
const profileHash = createHash('sha256') .update(homeDir) .digest('hex') .slice(0, 12) return `mx-claw-${profileHash}`

这样默认目录、测试目录和不同 profile 不会互相拿到对方的 Key。

account 名使用:

text
provider:openai provider:deepseek provider:ollama

写入时不能直接把 Key 放进 security add-generic-password -w ...,因为进程参数可以被 其他进程看到。

我的做法是把 Key 转 hex,通过 security -i 的 stdin 写:

ts
const hexValue = Buffer.from(value, 'utf8').toString('hex') const command = `add-generic-password -U ` + `-a ${quoteSecurityArgument(key)} ` + `-s ${quoteSecurityArgument(this.#service)} ` + `-X "${hexValue}"\n` const result = await this.#runner('/usr/bin/security', ['-i'], { input: command, })

security -i 有大约 4 KiB 的行缓冲。超长内容不再改用 argv,而是直接判定安全后端不可用, 让上层回退到加密文件。为了兼容去牺牲密钥可见性,不划算。

9.4 Linux Secret Service

Linux 上尝试 secret-tool:

ts
secret-tool store --label "mx-claw provider:openai" \ service mx-claw-<hash> account provider:openai

读取:

text
secret-tool lookup service <service> account <key>

删除:

text
secret-tool clear service <service> account <key>

如果系统没装 secret-tool,或者 Secret Service 不可用,命令失败后回退到加密文件。

Windows 先用 AES 文件。

9.5 AES-GCM 文件回退

回退存储由两个文件组成:

text
secrets.json IV、GCM tag、密文 secrets.key 32 字节随机主密钥

两个文件都是 0600。

加密使用 aes-256-gcm,每次写入生成新的 12 字节 IV:

ts
const key = await this.#readKey() const iv = randomBytes(12) const cipher = createCipheriv('aes-256-gcm', key, iv) const encrypted = Buffer.concat([ cipher.update(JSON.stringify(secrets), 'utf8'), cipher.final(), ]) const payload = { version: 1, iv: iv.toString('base64'), tag: cipher.getAuthTag().toString('base64'), data: encrypted.toString('base64'), }

GCM 除了加密,还会校验数据是否被改过。解密时:

ts
decipher.setAuthTag(Buffer.from(payload.tag, 'base64'))

密文文件也采用临时文件加 rename,避免写到一半留下损坏文件。

必须说清楚:这层方案防的是“磁盘上直接出现明文 Key”,不是防同用户恶意程序。只要能读 secrets.key 和 secrets.json,就能解密。它不能替代 OS Keychain。

9.6 外部 Secret Command

参考 Hermes 加了只读外部来源:

bash
MX_CLAW_SECRET_COMMAND='vault read mx-claw' mx-claw

helper 会收到:

text
MX_CLAW_SECRET_KEY=provider:openai

它可以只输出一个值:

text
sk-...

也可以输出一组:

text
provider:openai=sk-... provider:deepseek=sk-...

实现时故意只给 helper 最小环境变量:

ts
const env = { MX_CLAW_SECRET_KEY: key } for (const name of ['PATH', 'HOME', 'USER', 'LOGNAME', 'TMPDIR', 'LANG', 'LC_ALL']) { if (process.env[name]) env[name] = process.env[name] }

超时 3 秒,stdout 最多 1 MiB,stderr 捕获但不回显。helper 失败时按没有值处理。

外部来源是只读的。它提供 Key 后,本地 set() 不会把值再复制到 Keychain 或文件里。否则 用户以为自己在用 Vault,结果磁盘上又多了一份。

10. API Key 输入

向导里不能把 Key 作为普通 prompt 读。readSecret() 使用 raw mode,不显示字符:

text
API Key for deepseek (input hidden):

粘贴快捷键仍然有效,只是没有回显。粘贴完直接回车。

输出里永远只显示:

text
API Key: [configured]

不会打印前缀、末尾四位或长度。

旧的快捷命令仍然兼容:

text
/model set openai api-key <key>

但更推荐:

text
/model set openai api-key

后者不会让 Key 进入 shell history。

11. Provider 就绪判断

不是有 Base URL 就算配置完成。当前判断是:

ts
export function isProviderReady(provider: ProviderConfig): boolean { return Boolean( provider.baseURL.trim() && provider.model.trim() && provider.models.length > 0 && (!provider.requiresApiKey || provider.apiKey.trim()), ) }

OpenAI、DeepSeek 必须有 Key;Ollama 可以没有。

如果模型刷新时 Provider 还没就绪,会直接跳过:

text
Refresh skipped: deepseek needs configuration

这一步很有必要。不发请求比发一个必然 401 的请求更清楚。

12. 错误和状态恢复

模型请求失败不应该把 CLI 一起带走。

REPL 捕获回合异常:

text
Request failed: ... The session is still active. Try again or type /help.

Agent Core 用 try/finally 恢复会话状态:

ts
try { for await (const chunk of provider.stream(...)) { // 产生流式事件 } } finally { session.runtimeState = 'idle' session.updatedAt = now() }

Provider 抛错、消费者提前退出,会话都不能卡在 running。

如果对你有用的话,可以打赏哦
打赏
ali pay
wechat pay

本文作者:繁星

本文链接:

版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!