# 内置 LLM 服务

> 每个 MiMo 实例的服务端都挂着一套 OpenAI 兼容的 /v1 接口，技能、脚本与子进程凭按目录签发的临时令牌借用已配置的模型，真实密钥始终留在进程内。

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

技能或脚本常常只需要“一个模型”：做一次总结、合成一段语音、转写一段录音。直接把提供方的 API key 交给它们，密钥就跟着散到了这些进程里。MiMo 的做法是在每个实例自己的 HTTP 服务端上挂一套 OpenAI 兼容接口，路径前缀 `/v1`，调用方拿到的只有地址和一枚可撤销、会过期、可限定模型的临时令牌，真正的密钥从不离开 Provider 服务。

名字里的“LLM server”容易让人以为有个独立进程。其实 `mimo llm-server` 只负责签发和管理令牌，接口本身长在实例服务端上，入口的注释说明了原因（`packages/opencode/src/cli/cmd/llm-server.ts:19`）：

> This command family only MINTS credentials. The endpoint itself lives on the instance's own HTTP server, because that is the process where provider auth actually happens: a plugin's `chat.headers` hook supplies credentials for plugin-authenticated providers, and a separate listener would bypass it.

路由文件的注释举的例子是小米插件，说这类提供方的凭据由 `chat.headers` 钩子补上（`packages/opencode/src/server/routes/instance/capability.ts:31`）。不过开源版里小米插件的这个钩子只加了一个来源标识头，凭据本身是存进 `auth.json` 的普通 API key，其他内置插件的 `chat.headers` 也都不负责凭据（插件见[模型、提供方与提示词路由](https://daiw.org/manual/mimo-code/providers)）。代码层面可以确认的是：接口与会话跑在同一个进程里，发请求前触发同样的 `chat.params` 与 `chat.headers` 钩子。

## 怎么用

在启动实例的同一个项目目录里签发令牌，再把地址与令牌交给调用方。内置文档技能里的示例（`packages/opencode/src/skill/builtin/.bundle/mimocode-docs/reference/capability-api.md:52`）：

```bash
mimo llm-server issue --capability transcription --json --label my-skill
mimo llm-server issue --model anthropic/claude-haiku-4-5 --ttl 12h
mimo llm-server list [--json]
mimo llm-server revoke <id> | --all
```

| 选项 | 含义 |
| --- | --- |
| `--model` | 限定可用模型，格式 `provider/model`，可重复；不传表示全部 |
| `--capability` | 取值 `chat`、`speech`、`transcription`，按能力挑一个模型并把令牌限定到它，与 `--model` 互斥 |
| `--ttl` | 滑动有效期，从最后一次使用算起，缺省取配置 `llmServer.ttl`，再缺省为 `1d` |
| `--max-age` | 从签发起算的绝对上限，缺省取 `llmServer.maxAge`，再缺省为 `none` |
| `--json` | 输出一行 JSON：`api_key`、`id`、`base_url`、`expires_at`、`models`，以及续签用的 `renew_argv` 与 `renew_command`；按能力签发时再加 `capability`、`model`、`fallback`、`alternatives` |

时长写成 `30m`、`12h`、`7d` 这类形式，`none`、`never` 或 `0` 表示不限（`llm-server.ts:38`）。明文令牌只打印这一次，磁盘上只存它的 SHA-256。`base_url` 取自正在服务这个目录的实例，没有实例在跑时为 `null`（`llm-server.ts:157`）。`renew_argv` 用 `Self.argv` 拼出重新调用当前 CLI 的完整参数，不依赖 `mimo` 是否在 PATH 上（`packages/opencode/src/util/self.ts:40`），技能拿到 `expired_api_key` 后可以照着续签。

接口地址来自两种进程：TUI 启动时若没有指定 `--port` 之类的网络参数，也会在 `127.0.0.1` 的随机端口上绑定一个监听，哪怕它和后台 worker 走的是进程内通道（`packages/opencode/src/cli/cmd/tui/thread.ts:338`）；`mimo serve --port <n>` 则是固定端口。`mimo web` 与 `mimo acp` 起的监听不登记地址（`packages/opencode/src/cli/cmd/web.ts:56`、`packages/opencode/src/cli/cmd/acp.ts:29`）。

## 接口一览

| 接口 | 说明 |
| --- | --- |
| `GET /v1/models` | 列出令牌能用的模型，ID 一律是 `provider/model` |
| `POST /v1/chat/completions` | OpenAI Chat Completions，支持 `stream`、工具调用、`image_url` 与 `input_audio` 内容 |
| `POST /v1/audio/speech` | JSON 进，音频字节出 |
| `POST /v1/audio/transcriptions` | multipart 表单，字段 `file`、`model`、可选 `language` |

路由定义在上面提到的 `packages/opencode/src/server/routes/instance/capability.ts`（下文简写为 `routes/instance/capability.ts`，以区别于 `llm-server` 目录里的同名文件），挂在实例路由的 `/v1` 前缀下（`packages/opencode/src/server/routes/instance/index.ts:66`）。一次请求经过的模块：

```mermaid
flowchart TD
  A["技能或子进程"] -->|Bearer 令牌| B["实例 HTTP 服务"]
  B --> C["AuthMiddleware：带令牌的 /v1 请求跳过 Basic 认证"]
  C --> D["CapabilityRoutes：校验令牌，记下模型范围"]
  D --> E["chat/completions"]
  D --> F["audio/speech 与 audio/transcriptions"]
  E --> G["Provider.getLanguage 与 chat.params、chat.headers 钩子"]
  G --> H["streamText 发往提供方"]
  F --> I["SDK 有语音工厂：generateSpeech"]
  F --> J["OpenAI 形状的包：AudioChat 直接发 HTTP"]
```

## 监听与鉴权

TUI 自己绑定的这个监听没有人主动要求过，却让本机任何进程都能访问 `/session`、`/file`、`/pty` 等实例接口。所以 worker 在绑定前先生成一个只存在内存里的随机密码（`packages/opencode/src/cli/cmd/tui/worker.ts:87`、`packages/opencode/src/flag/flag.ts:73`），不写进环境变量，子进程也继承不到，其余路由从此都要 Basic 认证。`/v1` 是开出来的口子：`AuthMiddleware` 看到路径以 `/v1/` 开头并且带了令牌头，就跳过 Basic 认证，交给路由自己验令牌（`packages/opencode/src/server/middleware.ts:61`）。反过来，`/v1` 路由无论是否设置了 `MIMOCODE_SERVER_PASSWORD` 都强制要求令牌。

还有一道目录限制：除非运维人员自己设置了 `MIMOCODE_SERVER_PASSWORD`，实例拒绝访问服务端工作目录以外的项目，返回 403（`packages/opencode/src/server/routes/instance/middleware.ts:32`）。注释解释了为什么按“谁提供的密码”而不是“有没有密码”判断：隐式监听自己生成了密码，若因此放开目录限制，持有令牌的一方就能把 `?directory=` 指向机器上任何项目。

监听起来后，服务端把地址写进 `<state>/llm-server/<目录哈希>/server-<pid>-<port>.json`（`packages/opencode/src/server/server.ts:154`）。`<state>` 默认是 `~/.local/state/mimocode`，设了 `MIMOCODE_HOME` 则是其下的 `state` 目录（`packages/shared/src/global.ts:27`）；目录哈希是解析后项目路径的 SHA-1。绑定在 `0.0.0.0` 或 `::` 时登记为 `127.0.0.1`。读取方逐个用 `process.kill` 发 0 号信号检查进程是否还活着，死掉的文件顺手删掉（`packages/opencode/src/llm-server/tokens.ts:144`），`issue` 取最新启动的那个。

## 令牌：只存哈希，按目录分桶

令牌与地址放在同一个目录桶里，文件是 `tokens.json`，目录权限 0700、文件 0600，先写临时文件再改名（`tokens.ts:209`）。签发时生成 32 字节随机数做令牌，ID 是 `llmk_` 加 16 位十六进制，记录里只存 SHA-256、标签、模型列表、创建时间与两种有效期（`tokens.ts:268`）。

校验在 `tokens.ts:305`：

```ts
export async function verify(directory: string, token: string): Promise<Verdict> {
  const presented = Buffer.from(digest(token), "hex")
  return mutate(directory, (store) => {
    const found = store.tokens.find((t) => {
      const stored = Buffer.from(t.hash, "hex")
      return stored.length === presented.length && timingSafeEqual(stored, presented)
    })
    if (!found) return { ok: false, reason: "unknown" } as const
    if (expired(found)) {
      store.tokens = store.tokens.filter((t) => t.id !== found.id)
      return { ok: false, reason: "expired", record: found } as const
    }
    found.last_used = Date.now()
    return { ok: true, record: found } as const
  })
}
```

几个设计点：比较的是定长的哈希，`timingSafeEqual` 不会泄露长度；过期的记录在校验时顺手删除；校验成功要写回 `last_used`，滑动有效期才能往后推。`mutate` 用状态目录下的文件锁串行化读改写，内容没变就不写盘，注释特意提到这样未通过认证的请求就无法驱动无限次写文件（`tokens.ts:217`）。有效期的判定是：超过 `max_age_ms` 就过期，或者距最后一次使用（从未用过则距创建）超过 `idle_ms` 就过期（`tokens.ts:250`）。

令牌按目录分桶，因为服务端钉在一个目录上，令牌不应能拿去另一个项目的提供方配置里重放（`tokens.ts:25`）。令牌存在磁盘上，签发与校验可以发生在两个不同的进程里，所以它与会话、进程、端口都不绑定，实例重启后照样可用。

## 按能力挑模型

`--capability` 让技能声明“我要能转写的模型”，而不是写死某台机器上的模型 ID。模型种类由声明的模态推出（`packages/opencode/src/provider/provider.ts:2099`）：

```ts
export function modelKind(model: Model): ModelKind {
  const input = model.capabilities.input
  const output = model.capabilities.output
  if (output.audio && !output.text) return "speech"
  if (input.audio && !input.text && output.text) return "transcription"
  return "language"
}
```

输出有文本就算聊天模型，输入同时含文本和音频的多模态模型也不算转写模型。`LLMServerCapability.resolve`（`packages/opencode/src/llm-server/capability.ts:55`）在此基础上筛选：`speech` 与专用的 `transcription` 模型必须挂在 `@ai-sdk/openai`、`@ai-sdk/azure` 或 `@ai-sdk/openai-compatible` 上，因为只有这三个包的接口能承载音频；`transcription` 还接受能听音频的聊天模型作为替补，JSON 里以 `fallback: true` 标明。排序是专用模型优先，其次是配置的默认模型，最后按名称。一个都没有时，`explain` 会区分“根本没声明这类模型”与“声明了但这个服务够不着”（`llm-server/capability.ts:85`）。TTS、ASR 模型常常不在公共目录里，需要在配置里给模型写上 `modalities`，否则会被当成聊天模型（`capability-api.md:121`）。

## 聊天接口：翻译而不代劳

鉴权一步接受三种头：OpenAI 习惯的 `authorization`（可带 `Bearer` 前缀）、Anthropic 的 `x-api-key`、Azure 的 `api-key`（`routes/instance/capability.ts:53`）。令牌无效返回 401 与 `invalid_api_key`，过期返回 401 与 `expired_api_key`。令牌的模型范围存在以原始请求为键的 `WeakMap` 里，没经过鉴权的路由读不到（`routes/instance/capability.ts:86`）。

请求体按 OpenAI 的格式校验，未知字段一律忽略，让现成的 OpenAI 客户端只改 `base_url` 就能用；但会改变结果形状或数量、又没法兑现的字段直接 400：`n` 大于 1、`logprobs`、`top_logprobs`、`logit_bias`、`response_format`、`verbosity`（`packages/opencode/src/llm-server/protocol.ts:176`）。`reasoning_effort` 映射到模型的 variants，模型没有这一档就 400 并列出可选档位，不会悄悄降级（`packages/opencode/src/llm-server/completions.ts:250`）；提供方特有的参数放进扁平的 `provider_options`。消息转换时，OpenAI 的 tool 消息不带工具名，就从前面 assistant 的 `tool_calls` 里按 ID 找回来；assistant 消息可以带 `reasoning_content`，原样转回推理块（`protocol.ts:250`）。

`start`（`completions.ts:300`）把请求变成一次 `streamText`，尽量与会话里的 LLM 调用保持一致：

- 先用 `ProviderTransform.options` 生成默认选项，再叠 `reasoning_effort` 选中的 variant，最后叠 `provider_options`（`completions.ts:320`）。
- 触发 `chat.params` 与 `chat.headers` 两个插件钩子，`agent` 写成 `llm-api`，`sessionID` 是为这次请求生成的 `chatcmpl-` 开头的 ID（`completions.ts:345`）。
- 消息经 `ProviderTransform.message` 中间件规整，工具表经 `ProviderTransform.tools` 补上 `strict` 与缓存断点。
- 调用方传来的工具只登记 schema、没有 `execute`，模型产生的工具调用原样返回，由调用方自己执行后在下一次请求里带回结果（`completions.ts:272`）。

`streamText` 的最后几个参数（`completions.ts:411`）：

```ts
providerOptions: ProviderTransform.providerOptions(model, hooked.params.options),
// Model headers first, hook output last — same precedence as `session/llm.ts:737`,
// so a plugin can override a statically configured header rather than losing to it.
headers: { ...model.headers, ...hooked.headers },
// The caller owns retries. A proxy that silently retries turns one client
// request into several billed upstream calls with no way to observe it.
maxRetries: 0,
abortSignal: input.abort,
```

流式响应先拉到第一帧再写 200 状态行（`routes/instance/capability.ts:181`）。`streamText` 是惰性的，不这样做的话，凭据过期、提供方不可达都会变成“200 加一帧错误”，调用方分不清请求是被拒绝了还是写到一半断了。第一帧之后再出错，只追加一帧 `api_error` 与 `[DONE]`。分块的写法照 OpenAI：先发只含角色的开头块，文本走 `content`，推理走 `reasoning_content`，工具调用先发带名字的开头块、再发参数片段；`stream_options.include_usage` 为真时最后补一个只含用量的块（`completions.ts:470`）。

错误映射：模型不存在 404 与 `model_not_found`；模型存在但它的提供方包做不到这件事，501 与 `unsupported_capability`；其余一律 502，把“提供方坏了”与“MiMo 坏了”分开（`routes/instance/capability.ts:280`）。

## 语音：两种音频约定

音频在业界有两种走法。OpenAI 的做法是专门的 `/audio/speech` 与 `/audio/transcriptions` 接口，AI SDK 支持；小米的 TTS、ASR 与 `gpt-4o-audio-preview` 则把音频放在 chat completions 里，而 `@ai-sdk/openai-compatible` 的响应结构没有 `audio` 字段，返回的音频会被丢掉。所以 `packages/opencode/src/llm-server/audio-chat.ts` 绕开 SDK 直接发 HTTP，地址取提供方配置的 `baseURL`，密钥取配置或已保存的凭据，同样不出进程（`audio-chat.ts:55`）。

**合成**。`voice` 字段可以不填，填的话三种写法只能选一种：预设名字符串、`design` 描述、`clone` 参考音频（`protocol.ts:479`）。后两种要求模型在配置里声明 `voice_design` 或 `voice_clone`，否则发请求前就 400（`completions.ts:611`）。提供方 SDK 有语音工厂就调 `generateSpeech`，否则只要提供方包是上面那三个 OpenAI 形状的包，就改走 `AudioChat.synthesize`：风格说明放 user 消息，要合成的文本放 assistant 消息，`audio.format` 缺省为 wav，再从响应的 `message.audio.data` 取回音频（`audio-chat.ts:177`）。

**转写**。表单里的 `prompt`、`temperature` 以及 `verbose_json`、`srt`、`vtt` 格式都会被拒绝（`protocol.ts:569`）。文件类型先看上传声明的 MIME 并归一化别名，比如 `audio/x-wav` 改成 `audio/wav`，不是音频类型再看扩展名，都判断不出就 400（`protocol.ts:600`）。之后分三条路（`completions.ts:743`）：

| 模型 | 走法 |
| --- | --- |
| 能听音频的聊天模型，OpenAI 形状的包 | `AudioChat` 直接发请求，附一句转写指令，并带上 `thinking` 关闭推理 |
| 能听音频的聊天模型，其他包 | 经 SDK 发 `input_audio` 加转写指令，`max_tokens` 为 4096；回答落在推理里而正文为空时报 502 |
| 专用 ASR 模型 | `AudioChat` 只发音频，语言提示放在 `asr_options` |

关闭推理那一步的注释记录了实测：不关的话推理模型有时把整段转写放进 `reasoning_content`，正文为空（`audio-chat.ts:253`）。

这套音频接口与 TUI 的语音输入是两回事：输入框里的语音由 TUI 自己直接请求提供方，不经过 `/v1`，见[终端体验](https://daiw.org/manual/mimo-code/tui)。

## 代码与文档的出入

- 内置文档技能的参考页说语音合成会拒绝 `stream_format: "sse"`（`capability-api.md:92`），`protocol.ts` 里也确实定义了 `speechUnsupported`（`protocol.ts:509`），但路由处理语音请求时没有调用它（`routes/instance/capability.ts:215`），代码里找不到别的调用方。
- 同一份文档把地址文件写成 `server-<pid>.json`（`capability-api.md:42`），服务端实际写入的是 `server-<pid>-<port>.json`，同一进程绑两个端口时互不覆盖（`tokens.ts:84`）。
- `AudioChatError` 带着自己的状态码，比如没配 `baseURL` 时是 501（`audio-chat.ts:70`），但路由的错误处理不认这个类型，最终都按 502 返回。
- 仓库里没有对 `/v1` 做用量计量、配额或限流的代码：`tokens.ts` 管的是鉴权令牌，与 token 计数无关；用量只是把 AI SDK 报告的数字翻译成 OpenAI 的 `usage` 字段（`protocol.ts:334`）。服务端的限流中间件只用在会话的 `prompt_async` 与 shell 两个接口上（`packages/opencode/src/server/routes/instance/session.ts:1207`）。
- 开源仓库里除了 `mimocode-docs` 的文档，没有找到调用这套接口的技能或代码。

下一篇：[从 Claude Code、Codex 与 OpenCode 迁移](https://daiw.org/manual/mimo-code/imports)——三种外部会话怎样增量导入 MiMo 的数据库，以及登录框里的“从 Claude Code 导入”做了什么。
