内置 LLM 服务
每个 MiMo 实例的服务端都挂着一套 OpenAI 兼容的 /v1 接口,技能、脚本与子进程凭按目录签发的临时令牌借用已配置的模型,真实密钥始终留在进程内。
技能或脚本常常只需要“一个模型”:做一次总结、合成一段语音、转写一段录音。直接把提供方的 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.headershook 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 也都不负责凭据(插件见模型、提供方与提示词路由)。代码层面可以确认的是:接口与会话跑在同一个进程里,发请求前触发同样的 chat.params 与 chat.headers 钩子。
怎么用
在启动实例的同一个项目目录里签发令牌,再把地址与令牌交给调用方。内置文档技能里的示例(packages/opencode/src/skill/builtin/.bundle/mimocode-docs/reference/capability-api.md:52):
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)。一次请求经过的模块:
监听与鉴权
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:
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):
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):
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,见终端体验。
代码与文档的出入
- 内置文档技能的参考页说语音合成会拒绝
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 迁移——三种外部会话怎样增量导入 MiMo 的数据库,以及登录框里的“从 Claude Code 导入”做了什么。