跳到主要内容

数据截至 (上游 commit dad6f5196773)

模型运行时:一套代码接住几十家供应商与它们各异的流

30 秒导读: packages/model-runtime 是 LobeHub 的「供应商适配层」。上面这层只会说一句话——runtime.chat(payload);下面接的是 82 家供应商,各有各的 SDK、各有各的参数名、各有各的流事件格式。这一章讲它怎么把「各异」压成「统一」。

本章只讲模型运行时本身。消息和工具怎么被拼成 payload,见 上下文工程;拼好的指令谁来调度、工具怎么执行,见 运行时内核工具体系


1. 这是什么(零基础也能懂)

一句话定义: 一个「模型供应商翻译层」——把 LobeHub 内部统一的请求对象翻译成某一家供应商的 API 调用,再把那家返回的流翻译回统一的事件流。

它解决什么问题

假设你在做一个聊天应用,今天接 OpenAI,明天用户说要用 Claude,后天有人自建了 Ollama,大后天老板说要接通义千问。四家的差异不是「换个 URL」这么简单:

差异点OpenAIAnthropicGoogle GeminiOllama
请求 SDKopenai@anthropic-ai/sdk@google/genaiollama
思考内容在哪delta.reasoning_content独立的 thinking_delta 事件parts[].thought === truemessage.thinking
工具调用分片delta.tool_calls[] 增量input_json_delta 逐段 JSONfunctionCall 整块整块
用量字段名prompt_tokensinput_tokensusageMetadata
引用/来源delta.annotationscitations_deltagrounding metadata

如果每接一家就在业务层写一遍 if (provider === 'xxx'),代码会烂掉。model-runtime 就是那个「只烂在一个地方」的地方。

它能做什么

  • chat —— 聊天补全,返回一个 SSE 流的 Response
  • embeddings / textToSpeech / transcribe —— 向量化、TTS、语音转写。
  • createImage / createVideo —— 文生图、文生视频(含 webhook 与轮询)。
  • generateObject —— 结构化输出(JSON Schema),不支持原生 schema 的家用 tool calling 模拟。
  • models / pullModel —— 拉取该供应商的模型列表;本地模型(Ollama)还能下载模型并回传进度流。

用起来什么样

// 示意,非源码
// 1. 按 provider id 拿到一个 runtime(内部查 runtimeMap 表)
const runtime = ModelRuntime.initializeWithProvider('deepseek', {
apiKey: process.env.DEEPSEEK_API_KEY,
});

// 2. 用统一的 payload 发起对话,拿回的是一个 SSE 流 Response
const response = await runtime.chat({
messages: [{ content: '你好', role: 'user' }],
model: 'deepseek-reasoner',
temperature: 0.7,
});
// 重点看:换成 'anthropic' / 'google' / 'ollama',上面这两行一个字都不用改

真实入口是 ModelRuntime.initializeWithProvider(packages/model-runtime/src/core/ModelRuntime.ts:503)。

一句话直觉

把它当作万国插头转换器 + 传送带整流器:

  • 「插头」这一半负责下行——把统一 payload 翻译成某家的请求格式;
  • 「整流」这一半负责上行——把各家五花八门的流事件,整成同一种传送带上的标准零件。

两半的接口分别是 LobeRuntimeAI(下行)和 StreamProtocolChunk(上行)。


2. 顶层全景(它大概怎么转)

一张图看清一次 chat

从上往下是请求(下行),从下往上是响应(上行);中间那条虚线是「统一 ↔ 方言」的分界。

上层业务(agent-runtime / apps/server)
│ runtime.chat(payload) ▲ SSE 事件流(统一协议)
▼ │
┌───────────────────────────────────────────────────────────┐
│ ModelRuntime ── 门面:生命周期钩子(预算/计费/追踪) │
└───────────────────────────────────────────────────────────┘
│ runtimeMap[provider] ▲
▼ │
┌───────────────────────────────────────────────────────────┐
│ 某个 LobeXxxAI 实例(由工厂生成) │
│ ① handlePayload 统一 payload → 该家方言 │
│ ② 调 SDK │
│ ③ XxxStream 该家流事件 → StreamProtocolChunk │
└───────────────────────────────────────────────────────────┘
─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ 分界线 ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─
│ ▲
▼ 供应商方言 │ 供应商原生流
OpenAI / Anthropic / Gemini / Bedrock / Ollama ...

部件一句话职责

部件干什么在哪个文件
LobeRuntimeAI全可选方法的接口,是「一个 runtime 能干什么」的唯一契约core/BaseAI.ts:29
ModelRuntime门面类,包一层生命周期钩子后转发给具体 runtimecore/ModelRuntime.ts:141
providerRuntimeMapprovider id → runtime 类的静态查找表(82 个 key)runtimeMap.ts:85
createOpenAICompatibleRuntime主力工厂,给 OpenAI 兼容协议的家用core/openaiCompatibleFactory/index.ts:335
createAnthropicCompatibleRuntime第二工厂,给 Messages API 协议的家用core/anthropicCompatibleFactory/index.ts:451
createRouterRuntime同一 provider 下按模型/baseURL 再分流 + 失败回退core/RouterRuntime/createRuntime.ts:237
protocol.ts统一流协议的类型与三段管线工具core/streams/protocol.ts
各家 *Stream把某家原生流事件翻译成 StreamProtocolChunkcore/streams/{anthropic,openai,google,qwen,spark,ollama}...
usageConverters/各家 usage 字段 → 统一 ModelUsage + 算钱core/usageConverters/
errors/错误码表 + 分类学 + 上游报文模式匹配errors/
model-bank静态模型卡片库(上下文窗口、能力、定价)packages/model-bank/src/aiModels/

主线走一遍(不进代码)

  1. 上层调 ModelRuntime.chat(payload)
  2. ModelRuntime 先跑 beforeChat 钩子(比如预算检查,不够就抛),再把 onChatFinal 钩子注入到回调里。
  3. 转发给具体 runtime 的 chat
  4. 具体 runtime 做四件事:归一化工具 schema → handlePayload 翻译成方言 → 调 SDK → 把返回流交给对应的 *Stream
  5. *Stream 走三段管线,吐出 SSE 文本流。
  6. StreamingResponse 包成 Content-Type: text/event-streamResponse 返回。
  7. 流结束时,createCallbacksTransformerflush 汇总全文/思考/工具/用量,触发 onFinal → 落库、计费。

3. 门面层:接口、查表、钩子

3.1 LobeRuntimeAI:一个方法都不强制

最上面的契约反直觉地宽松——所有方法都是可选的(core/BaseAI.ts:29-72):

export interface LobeRuntimeAI {
baseURL?: string;
chat?: (payload: ChatStreamPayload, options?: ChatMethodOptions) => Promise<Response>;
embeddings?: (...) => Promise<Embeddings[]>;
models?: () => Promise<any>;
// createImage / createVideo / textToSpeech / transcribe / pullModel / generateObject ...
}

为什么全可选: 因为 82 家能力差得太远。Jina 只做 embeddings,BFL 只做图,Ollama 才有 pullModel。用「全可选」比用「抽象基类 + 一堆抛 not-implemented」更诚实。

代价是调用点必须自己判空。ModelRuntime.chat 就在第一行做了这件事,把「不支持」翻译成结构化错误而不是 TypeError(core/ModelRuntime.ts:191-197):

if (typeof this._runtime.chat !== 'function') {
throw AgentRuntimeError.chat({
error: new Error('Chat is not supported by this provider'),
errorType: AgentRuntimeErrorType.ProviderBizError,
...
});
}

文件里还有一个 LobeOpenAICompatibleRuntime 抽象类(core/BaseAI.ts:75),方法是必选的——它是给「确定走 OpenAI 兼容协议」的实现做类型约束用的。

3.2 runtimeMap:一张纯静态表

runtimeMap.ts 通篇只有两件事:82 行 import,加一个 82 个 key 的对象(runtimeMap.ts:85)。

export const providerRuntimeMap = {
ai21: LobeAi21AI,
anthropic: LobeAnthropicAI,
// ...
router: LobeNewAPIAI, // 别名:router 与 newapi 指向同一个类
zhipu: LobeZhipuAI,
};

三个值得注意的细节:

  • key 全小写、无分隔符。 githubcopilotglmcodingplanvolcenginecodingplan——因为 provider id 在 URL 和数据库里以小写形式流转。
  • 有别名。 routernewapi 都指向 LobeNewAPIAI(runtimeMap.ts:144)。
  • 兜底是 OpenAI。 查不到就退回 LobeOpenAI(core/ModelRuntime.ts:519)。这让「自定义 OpenAI 兼容端点」不用改代码就能跑;vertexai 故意不在表里,靠这条兜底之外的专门路径初始化(源码里有 @ts-expect-error 注明)。

3.3 ModelRuntime:门面只做钩子

ModelRuntime 自己不懂任何供应商,它只干两件事:跑钩子转发

钩子接口 ModelRuntimeHooks(core/ModelRuntime.ts:65)按「调用前 / 出错 / 结束」三类组织:

钩子时机典型用途失败会怎样
beforeChat调模型前预算检查、限流抛出即中止整次调用
onChatErrorchat 抛错时脱敏、记日志、写库跑完后原错误继续抛
onChatFinal流跑完记 token、计费吞掉并打日志,不影响响应
beforeEmbeddings / onEmbeddingsFinal / onEmbeddingsError同上,embeddings 版同上同上
onGenerateObjectCompletegenerateObject 返回或抛错都会触发全生命周期追踪吞掉并打日志

这里有个值得学的设计:钩子的失败隔离是分级的。「load-bearing」的钩子(beforeChat)抛错直接中止;「观测型」的钩子(onChatFinal)抛错只打 console.error(core/ModelRuntime.ts:315-326)。理由写在注释里:计费/追踪的故障不该让用户的回答发不出来。

onChatFinal 的注入方式也有点巧妙——它不是单独跑,而是被织进 options.callback.onFinal,并且保留用户原有的 onFinal(core/ModelRuntime.ts:287-329):

// 真实实现的骨架,见 core/ModelRuntime.ts:291-329
return {
...hookOptions,
callback: {
...hookOptions?.callback,
async onFinal(data) {
await existingOnFinal?.(data); // 先跑调用方自己的
try { await hookFn(data, { options, payload }); }
catch (e) { console.error('[ModelRuntime] onChatFinal hook error:', e); }
},
},
};

3.4 mergeHooks:两层钩子叠起来

当一个调用点同时需要「计费钩子」和「追踪钩子」时,用 mergeModelRuntimeHooks(a, b) 把同名键串成 a → b 顺序执行(core/mergeHooks.ts:13)。

注释点明了顺序语义:b 只在 a 兑现后才跑,所以「失败该中止调用」的钩子要放在 a(core/mergeHooks.ts:9-11)。


4. 两大工厂:82 家里绝大多数是配置出来的

82 个 provider 目录里,绝大多数不是手写类,而是给工厂传一个配置对象

4.1 createOpenAICompatibleRuntime:主力工厂

它是一个返回类的函数(core/openaiCompatibleFactory/index.ts:335)。一个最小 provider 长这样(providers/akashchat/index.ts:13-51):

export const params = {
baseURL: 'https://chatapi.akash.network/api/v1',
chatCompletion: {
handlePayload: (payload) => { /* 改写成该家能吃的字段 */ },
},
models: async ({ client }) => { /* 拉模型列表 */ },
provider: ModelProvider.AkashChat,
} satisfies OpenAICompatibleFactoryOptions;

export const LobeAkashChatAI = createOpenAICompatibleRuntime(params);

四个核心钩子

配置项 OpenAICompatibleFactoryOptions(core/openaiCompatibleFactory/index.ts:196)很大,但真正承重的是这四个:

钩子签名要点解决什么谁在用
chatCompletion.handlePayload(payload, options) => ChatCompletionCreateParamsStreaming改写请求体:删不支持的字段、加自定义字段、翻译 thinking几乎所有家
customClient.createClient(options) => any换掉 OpenAI SDK 客户端本身需要非标准鉴权/传输的家
customClient.createChatCompletionStream(client, payload, instance) => ReadableStream连「怎么发请求」都自己来完全非 OpenAI 传输的家
chatCompletion.handleError(error, options) => Partial<ChatCompletionErrorPayload>把该家特有的错误报文映射成统一错误码有特殊错误格式的家

handlePayload 最典型的两种用法:

  • 删字段。 Cerebras 直接丢掉 frequency_penalty / presence_penalty(providers/cerebras/index.ts:13-109)。
  • 加字段。 AkashChat 把统一的 thinking 翻译成 chat_template_kwargs: { thinking: true }(providers/akashchat/index.ts:16-32)。

chat 的主流程

chat 方法从 core/openaiCompatibleFactory/index.ts:607 开始,顺序是固定的:

payload

├─① normalizeToolsParameters 归一化工具 schema(:541)
├─② shouldUseResponsesAPI 判定走 Chat Completions 还是 Responses API(:557)
├─③ contextPreFlight 预检:估算 token 超窗就当场失败(:572)
├─④ handlePayload ← 供应商钩子(:578)
├─⑤ resolveModelSamplingParameters 裁剪 temperature/top_p(:591)
├─⑥ convertOpenAIMessages 消息内容转换(:642)
├─⑦ 发请求 / 或走 customClient (:663 / :714)
└─⑧ OpenAIStream 或 handleStream → StreamingResponse(:732)

第 ③ 步的预检是个便宜又有效的设计:与其把一个注定 400 的请求发出去等一个来回,不如在本地按 model-bank 里的上下文窗口估一次(core/openaiCompatibleFactory/index.ts:649-652)。DeepSeek 就开了这个开关(providers/deepseek/index.ts:69)。

第 ⑦ 步之后有个容易忽略的细节:流会先 tee() 分叉,一路给生产用、一路给 debug 用(core/openaiCompatibleFactory/index.ts:840)。

nonStreamToStream:把非流式响应伪装成流

上层只认流。可有些调用是非流式的(stream: false),或者某些家干脆不支持流。解法是造一个假流

transformResponseToStream(core/openaiCompatibleFactory/nonStreamToStream.ts:6)把一个完整的 ChatCompletion 拆成 3~4 个 chunk 依次 enqueue:

完整响应 ──► [reasoning chunk] ──► [content + tool_calls chunk] ──►
(仅当有 reasoning_content)
[usage chunk] ──► [finish_reason chunk] ──► close()

顺序不是随便定的:reasoning 必须先出,否则下游会把思考内容当正文渲染(nonStreamToStream.ts:11-32)。

Responses API 版本 transformResponseAPIToStream(nonStreamToStream.ts:95)同理,并且总是补一个 response.completed 事件——因为下游的终止检测依赖它。

4.2 anthropicCompatibleFactory:第二套协议

Anthropic 的 Messages API 与 OpenAI 差异太大,不值得硬塞进第一个工厂,于是有了 createAnthropicCompatibleRuntime(core/anthropicCompatibleFactory/index.ts:451)。

和 OpenAI 工厂的关键差异:

维度OpenAI 工厂Anthropic 工厂
handlePayload可选,不给就原样透传必填,不给直接抛(index.ts:532-534)
默认 payload 构造器buildDefaultAnthropicPayload(index.ts:142)
默认超时SDK 默认DEFAULT_ANTHROPIC_TIMEOUT = 295_000(index.ts:59)
非流式转流ChatCompletionChunk造一整套 MessageStreamEvent 序列(index.ts:606-668)

buildDefaultAnthropicPayload 里塞了不少 Anthropic 特有的坑处理:

  • 空 system 提示要滤掉——Anthropic 会拒绝纯空白的 system(index.ts:167-173)。
  • thinking.budget_tokens 必须小于 max_tokens,所以取 Math.min(budget, resolvedMaxTokens - 1)(index.ts:207)。
  • prompt caching 通过给 system / messages 打 cache_control: { type: 'ephemeral' } 实现(index.ts:178)。

非流式路径尤其能看出「假流」的思路:它手工 enqueue message_start → 各 content_block_*message_deltamessage_stop,让下游的 AnthropicStream 完全不需要知道这次其实不是流(index.ts:606-668)。


5. 同一个 provider 里再分流:RouterRuntime

5.1 它解决的小问题

有些 provider 一家提供多个不同协议的端点。DeepSeek 同时有 /v1(OpenAI 兼容)和 /anthropic(Messages 兼容);聚合网关(NewAPI、LobeHub Cloud)背后挂着一堆不同上游的通道。

于是需要「provider 之下、runtime 之上」再插一层:按模型/baseURL 选端点,失败了还能换下一个

5.2 三级匹配 + 顺序回退

chat(model)


resolveRouters(model) 路由表可以是静态数组,也可以是函数(按 options/model 动态生成)


resolveMatchedRouter ① baseURLPattern 正则命中? ──命中──► 用它
│ ② router.models 包含该模型? ──命中──► 用它
│ ③ 都不中 ──► 用最后一个(兜底)

runWithFallback 对该 router 的 options 数组依次尝试:
│ 成功 → 返回
│ 失败 → 上报 onRouteAttempt → 判断能不能重试 → 下一个

全部失败 → 抛最后一个错误

三级匹配在 core/RouterRuntime/createRuntime.ts:360-414;回退循环在 :445

5.3 三个值得学的细节

  • 回退不是无脑重试。 isNonRetryableRequestError(error) 命中就立刻抛,不再换通道(createRuntime.ts:764-766)——因为「参数写错了」换个通道也一样错。此外还有可注入的 shouldStopFallback 让上层加自己的止损规则。
  • runtime 是懒创建的。 构造函数只存配置,真正 new providerAI(...) 发生在每次尝试时(createRuntime.ts:494-584)。这样有 20 个通道也不会在初始化时建 20 个客户端。
  • apiType 可以在通道级覆盖。 回退到下一个通道时甚至能换协议族(createRuntime.ts:504-505),映射表是 baseRuntimeMap(core/RouterRuntime/baseRuntimeMap.ts:20,17 种 apiType)。

DeepSeek 的路由配置是最好的样例(providers/deepseek/index.ts:123-148):显式 sdkType 优先,没设的话靠 baseURL 后缀正则区分 OpenAI 端点和 Anthropic 端点。

5.4 参数裁剪:parameterResolver

要解决的小问题: 同一个 temperature: 0.8,OpenAI 收 02,Anthropic 收 01,Claude 4+ 还不允许 temperaturetop_p 同时出现,Claude Opus 4.7 干脆两个都不许传。

resolveParameters(core/parameterResolver.ts:165)把这些规则参数化成四类:

选项作用
hasConflict + preferTemperature冲突时二选一,默认保 temperature
normalizeTemperature是否把 02 折半映射成 01
temperatureRange / topPRange / maxTokensRange逐项夹到 min/max 区间

上层一般不直接用它,而是用 resolveModelSamplingParameters(model, config, opts)(core/parameterResolver.ts:262)——它按模型 id 自动判断有没有冲突,省得每个调用点重复写检测逻辑。

这里藏了一个很容易踩的 JS 陷阱,注释专门解释了(core/parameterResolver.ts:289-298):被丢弃的参数不是「不返回」,而是显式返回 undefined。因为调用方是 { ...payload, ...resolved } 展开合并的,只有显式的 undefined 才能覆盖掉 payload 里原有的值;反过来,输入里本来就没有的键也不能凭空加上,否则会给请求体塞进多余的 undefined 属性。

5.5 contextBuilders/:消息体的最后一公里

core/contextBuilders/ 负责把统一消息数组翻成各家的消息结构:convertOpenAIMessages(openai.ts:81)、buildAnthropicMessages(anthropic.ts:329)、buildGoogleMessages(google.ts:311)。

还有一个跨家共用的 normalizeToolsParameters(normalizeToolSchema.ts:101),在 chat 的第一步就跑(openaiCompatibleFactory/index.ts:618)。它存在的原因很具体:用户装的 MCP 工具会产生 OpenAI/Gemini 校验器拒收的 JSON Schema(比如布尔子 schema items: true、缺 type 的数组属性),不归一化就会 400。

上下文拼装的全貌不在本章,见 上下文工程


6. 统一流协议:本章的核心

6.1 协议长什么样

上行方向的唯一契约是 StreamProtocolChunk(core/streams/protocol.ts:107)——一个 { id, type, data } 三元组。type 一共 14 种:

分组type含义
正文text纯文本增量
正文content_part多模态正文片段(Gemini 3+ 用)
正文base64_image内联 base64 图
思考reasoning思考内容增量
思考reasoning_part多模态思考片段
思考reasoning_signature思考签名(基本只有 Anthropic 有)
思考flagged_reasoning_signature被屏蔽的思考块
工具tool_calls工具调用分片
检索grounding引用/来源列表
终止stop终止原因
终止error错误
计量usagetoken 用量(含成本)
计量speedTTFT / TPS / 时延
兜底data没归类的原始数据

配套的 StreamContext(core/streams/protocol.ts:31)是跨 chunk 的状态口袋——流是无状态的一串事件,但「现在是不是在思考段里」「这个工具的 id 是什么」这类判断必须跨 chunk 记住。

6.2 三段管线

各家的 *Stream 函数结构高度一致。以 OpenAIStream(core/streams/openai/openai.ts:693)为例:

供应商 SDK 的 AsyncIterable
│ convertIterableToStream 错误/中断包装成特殊 chunk(protocol.ts:334)

① createFirstErrorHandleTransformer 首 chunk 错误 → 结构化 error(protocol.ts:602)

② createTokenSpeedCalculator ← 真正的翻译在这里跑!顺便算 TTFT/TPS(protocol.ts:669)

③ createSSEProtocolTransformer chunk → "id/event/data" 三行 SSE 文本(protocol.ts:391)

④ createCallbacksTransformer 边转 Uint8Array 边聚合全文/思考/工具/用量(protocol.ts:432)

SSE 字节流 → StreamingResponse

这里有个反直觉的地方值得盯一眼: 第 ③ 步的 createSSEProtocolTransformer 收到的 transformer 是恒等函数 (c) => c,真正的 transformOpenAIStream 被传给了第 ② 步。原因是速度计算器必须看见翻译后的 chunk 类型(它要在第一个 text/reasoning/tool_calls 出现时打 TTFT 时间戳,在 usage 出现时算 TPS,见 protocol.ts:686-720),所以翻译被提前到了它内部。

不是所有家都走满四段:

适配器管线缺什么
OpenAIStream(openai/openai.ts:693)①②③④完整
AnthropicStream(anthropic.ts:255)②③④无首 chunk 错误处理(SDK 会直接抛)
QwenAIStream(qwen.ts:191)②③④同上
SparkAIStream(spark.ts:220)③④无速度计算,因而也没有 speed chunk 和 abort→stop 转换
OllamaStream(ollama.ts:53)③④同上,且完全没有 usage
Cloudflare都不走CloudflareStreamTransformer(cloudflare.ts:3)直接手写 SSE 文本行,只接 createCallbacksTransformer(providers/cloudflare/index.ts:146)

createCallbacksTransformerflush 是整条链的收尾(protocol.ts:452-475):它把一路聚合的 text / thinking / toolsCalling / usage / finishReason 打包成 OnFinishData,依次触发 onCompletiononFinal——第 3.3 节里 ModelRuntime 织进来的计费钩子,就是在这里落地的。

另外 model.ts 里的 createModelPullStream(core/streams/model.ts:4)是条独立支线:它不走 SSE 协议,而是吐换行分隔的 JSON,专门给 Ollama 下载模型的进度条用(providers/ollama/index.ts:269)。

6.3 难点一:reasoning / thinking 段的切分

问题: 「模型的思考过程」和「模型的正式回答」在流里必须分开,否则 UI 会把思考当答案渲染。但各家表达「这段是思考」的方式完全不同。

一共三类形态:

形态代表怎么识别处理位置
独立字段DeepSeek、Github Copilot、MiniMax M2、Mistral Magistraldelta 里有 reasoning_content / reasoning_text / reasoning / reasoning_detailsopenai/openai.ts:474-510
<think> 标签内联在正文里LM Studio、Ollama、各种本地模型正文字符串里出现 <think> / </think>openai/openai.ts:540-577ollama.ts:39-49
结构化事件Anthropic、OpenAI Responses、Gemini专门的事件类型 / thought: true 标记anthropic.ts:105-166responsesStream.ts:127-139google/index.ts:223-271

形态一:字段名的「五选一」

同一个概念,五个家取了五个名字。代码里是一串按优先级的 in 判断(openai/openai.ts:474-510),最后一个分支甚至要处理 Mistral 那种「正文是数组、里面有 type: 'thinking' 块」的嵌套结构。

紧接着有一段专治「两边都给」的补丁(openai/openai.ts:519-525):

// 真实实现,见 core/streams/openai/openai.ts:490-496
if (typeof content === 'string' && typeof reasoning_content === 'string') {
if (content === '' && reasoning_content === '') {
content = null;
} else if (reasoning_content === '') {
reasoning_content = null;
}
}

注释直接点名了两个真实 issue:硅基流动(#5681)和阿里云百炼(#5956)会在同一个 chunk 里同时给 contentreasoning_content,不做这个空串归零就会多吐空的 reasoning 事件。

形态二:<think> 标签的状态机

标签形态的麻烦在于标签可能被切在两个 chunk 中间,所以必须用 StreamContext.thinkingInContent 记状态(protocol.ts:58)。

一个 chunk 到来时的判定顺序(openai/openai.ts:540-577):

content 里有 </think> ?
├─ 有 ──► 按 </think> 切两半
│ 前半(去掉 <think>)当 reasoning
│ thinkingInContent = false
│ 后半当 text
│ ——— 一个 chunk 产出两个协议 chunk
└─ 无 ──► 去掉所有 <think>/</think> 标签
content 里有 <think> ? ──► thinkingInContent = true
按当前 thinkingInContent 决定这段是 reasoning 还是 text

注意「一个输入 chunk 产出多个输出 chunk」这件事——这正是 StreamProtocolChunk | StreamProtocolChunk[] 这个联合返回类型存在的理由。

形态三:结构化事件

Anthropic 最规整:thinking_deltareasoningsignature_deltareasoning_signatureredacted_thinkingflagged_reasoning_signature(anthropic.ts:105-166)。

content_block_start 里的 thinking 块如果自带签名,会一次返回两个 chunk(anthropic.ts:109-114)。

OpenAI Responses API 的思考是「摘要分段」的,于是用 StreamContext.startReasoning 区分首段和后续段——首段发空串占位,后续段前面补一个 \n 当分隔(responsesStream.ts:127-134)。这是 startReasoning 这个字段在整个代码库里唯一的用途。

6.4 难点二:tool_call 的分片累积

问题: 工具调用的参数是一大段 JSON,流式返回时被切成很多片。要把它们拼回完整的调用,得先知道「这一片属于哪个工具」。麻烦在于各家给的定位线索不一样:

indexid首片给 name
OpenAI首片给
Mistral不给
MiniMax不给
Qwen首片给首片给、参数片不给
Anthropic靠自己数
部分 NVIDIA / 代理首片给 null

处理分两层。

第一层在各家适配器里——补齐缺失字段,让下游看到统一形状(openai/openai.ts:203-261):

  • index 缺失就用数组下标顶(:188);
  • id 缺失按「显式 id > streamContext.tools[index] > streamContext.tool > 生成一个」四级兜底(:216-220);
  • 首次见到 id + name 就记进 streamContext.tools 这张按 index 索引的表(:192-199),给后续没有 id 的参数片用。

Anthropic 因为原生事件里根本没有 index,适配器自己维护一个自增的 context.toolIndex(anthropic.ts:59-66)。

第二层是 parseToolCalls(helpers/parseToolCalls.ts:20),在 createCallbacksTransformer 收到 tool_calls 事件时调用(protocol.ts:565-570),用 immer 的 produce 做不可变累积。它的匹配是三路的:

新分片到达

├─ 按 id 在已有列表里找到? ──► 追加 arguments;若之前 name 为空则补上 name

├─ draft[index] 是空的? ──► 在该位置插入新工具

├─ draft[index] 存在但 id 不同? ──► 这是并行调用的新工具,push 到末尾

└─ 否则(同 index 同 id / 无 id) ──► 追加 arguments,补 name

为什么 id 优先于 index: 并行工具调用(Gemini、GPT-5.2)里 index 会重复或错位,id 才是可靠主键(parseToolCalls.ts:30-31)。

还有个专治「首片 name 为 null」的前置修补 normalizeChunkForParse(parseToolCalls.ts:13-18)。注释写得很清楚:严格的 Zod schema 遇到 null在流中间抛 ZodError,直接杀掉整次操作;所以先把 null 归一成 '',等后续分片补上真名字。这是「宁可容忍脏数据也不能让流断掉」的典型取舍。

6.5 难点三:引用 / citation 的去重

问题: 「本次回答参考了哪些网页」这个列表,各家发送的时机完全不同——有的每个 chunk 都重发一遍,有的只在首个 chunk 发,有的一段一段地穿插着发。

StreamContext两个不同的字段对应两种截然不同的策略:

字段语义适用形态谁在用
returnedCitation: boolean一个只发一次的开关引用是整块的、会重复出现Perplexity、混元、文心、智谱
returnedCitationArray: ChatCitationItem[]一个累积数组引用是逐条穿插着来的Anthropic、OpenAI Responses

策略 A:布尔开关(openai/openai.ts:580-616)

一次判断吃掉四家的字段名差异:

// 真实实现的判定,见 core/streams/openai/openai.ts:552-560
const citations =
('citations' in chunk && chunk.citations) || // Perplexity:每个 chunk 都带
('search_info' in chunk && chunk.search_info?.search_results) || // 混元:每个 chunk 都带
('search_results' in chunk && chunk.search_results) || // 文心:首尾 chunk 带
('web_search' in chunk && chunk.web_search); // 智谱:首个 chunk 带

if (citations) { streamContext.returnedCitation = true; /* 发一次 grounding */ }

一旦发过,if (!streamContext?.returnedCitation) 这层门就再也不会打开。

策略 B:累积数组(anthropic.ts:168-179 + :228-239)

Anthropic 的引用是内联的——每段文字都可能带自己的来源。所以 citations_delta 事件只往数组里塞,并且返回 { data: null, type: 'text' } 这个空文本 chunk(不产生可见输出);等到 message_stop 才把整个数组作为一个 grounding chunk 发出去。

数组在 message_start 时初始化为 [](anthropic.ts:30)——这一步是必需的,因为 citations_delta 的写入带 if (context.returnedCitationArray) 判空。

OpenAI Responses 用同一套模式,只是终结事件换成 response.output_item.done(responsesStream.ts:196-206)。

还有个第三层:脏数据过滤。 filterValidCitations 会丢掉没有 url 的引用对象(openai/openai.ts:51-52)。注释指名了原因:OpenRouter 的内置搜索会发空对象 {},下游 new URL(undefined) 会崩,Zod 持久化也会拒。


7. 用量与计费:从 token 数到一个美元数

7.1 三段式

供应商原生 usage 字段
│ convertOpenAIUsage / convertAnthropicUsage / convertGoogleAIUsage

ModelUsage(统一字段名:totalInputTokens / outputReasoningTokens / inputCachedTokens ...)
│ withUsageCost(usage, pricing)

computeChatCost(pricing, usage) → 逐单元算 credits → 折算美元

ModelUsage.cost ──► usage chunk ──► onFinal ──► 上层落库/扣预算

定价从哪来?getModelPricing(model, provider, ctx)(utils/getModelPricing.ts:18)先按「模型 + provider」精确匹配,匹配不到再退到「同名模型的任意 provider」。它在 chat 里被提前 await 好塞进 streamOptions.payload(openaiCompatibleFactory/index.ts:749),这样流里的每个 usage chunk 都能就地算钱。

7.2 归一化里的坑

convertOpenAIUsage(core/usageConverters/openai.ts:68)看着是字段搬运,实际处理了好几处不一致:

  • Perplexity 的 citation_tokens 要额外并进 totalInputTokenstotalTokens(:27-30:52)。
  • xAI 的 completion_tokens 不含 reasoning,别家含,所以要分叉计算 outputTextTokenstotalOutputTokens(:44-50)。
  • 零值不能一刀切地滤掉。 shouldKeepUsageValue 会丢掉所有 0,唯独保留 inputCacheMissTokens: 0(:14-22)——因为「缓存全命中」这件事,0 本身就是有意义的信息。

Anthropic 版更特殊:它的用量是分两次到的message_start 给输入侧(含缓存读写),message_delta 才给输出侧,而且是增量。所以 convertAnthropicUsage(core/usageConverters/anthropic.ts:58)得在 StreamContext.usage 上累加(mergeDeltaUsage,:33),把「输入 token 记在开头、输出 token 记在结尾」缝成一条完整记录。

7.3 computeChatCost:三种计价策略

computeChatCost(core/usageConverters/utils/computeChatCost.ts:387)遍历定价卡上的每个 unit,按 strategy 分三种算法:

策略算法谁在用
fixedquantity × rate绝大多数
tiered总输入 token 落到某个档位,再整体乘该档费率OpenAI、Google 的长上下文阶梯价
lookup按运行时参数(如缓存 TTL)查价格表Anthropic 的 5m/1h 缓存写入价

tiered 有个不显然的细节:决定档位的量是 usage.totalInputTokens,而不是当前这个 unit 自己的 quantity(computeChatCost.ts:446-448)——因为供应商的阶梯是按「整个 prompt 有多大」定的,不是按「这一项有多少」。

计价结果一路带到 usage chunk,最终经由 onChatFinal 交给 运行时内核 那一章讲的 Cost / CostLimit 去累计和限额——本层只负责算出一个数,不负责判断能不能花


8. 错误体系:从供应商报文到「该重试还是该停」

8.1 三层结构

供应商抛出的原始错误(HTTP status / JSON body / SDK Error)
│ ① 工厂的 handleError:归到一个 AgentRuntimeErrorType

AgentRuntimeErrorType(字符串码,如 'InsufficientQuota')
│ ② ERROR_CODE_SPECS:给这个码贴上分类学标签

{ category, severity, attribution, retryable, numericId, httpStatus }
│ ③ 上层 classifyLLMError:折叠成一个二元决定

'retry' | 'stop'

8.2 第一层:handleError 的判定顺序

core/openaiCompatibleFactory/index.ts:1379handleError 是个有明确优先级的瀑布:

顺序判据产出
1error instanceof ContextExceededPreFlightErrorExceededContextWindow(本地预检拦下的)
2provider 自定义的 chatCompletion.handleError该家特有的码
3HTTP status 401InvalidProviderAPIKey
4OpenAI 错误码 insufficient_quota / model_not_found / context_length_exceeded对应码
5ErrorClassifier.isXxx(message) 按报文文本匹配对应码
6兜底ProviderBizError

第 5 层是脏活所在:很多供应商既不给标准 status 也不给标准 code,只在 message 里写人话。ErrorClassifier(errors/classifier.ts:27)把这些正则模式收在 errors/patterns.ts 里统一维护。

注意 endpoint 一律经过 desensitizeUrl 脱敏后才进错误体(index.ts:1240-1245)——错误对象会一路流到前端,不能带出用户的私有网关地址。

8.3 第二层:错误码与分类学

AgentRuntimeErrorType 是一张纯字符串常量表(packages/types/src/agentRuntime.ts:25),约 30 个码。packages/model-runtime/src/types/error.ts 只是把它连同 HTTP 标准码一起再导出(types/error.ts:4-34)。

真正有意思的是 errors/taxonomy.ts 定义的四维分类学(taxonomy.ts:19-32),它和错误码正交——码说「这是什么」,分类学说「该怎么反应」:

维度取值用途
categoryauth / quota / capacity / request / safety / network / stream / provider / config看板切片
severityinfo / warning / error / critical日志级别、告警
attributionuser / provider / harness / system谁该修这个 bug
countAsFailureboolean要不要计入失败率(用户侧错误一般不计)

ERROR_CODE_SPECS(errors/specs.ts:68)是码 → 规格的唯一真源,每条还带一个 numericId,结构是四位数字:

E 2 9 0 2
│ │ └┴── 该桶内序号
│ └───── 层级:0 = 开源自托管,9 = LobeHub Cloud 独有
└─────── 分类:1=auth 2=quota 3=capacity ... 9=config

规格里写明了 numericIdappend-only 的(specs.ts:41-46):一旦发布,(code, numericId) 这一对永不变化,哪怕字符串码后来改名。这样 E1001 才能长期出现在工单、文档和外部 SDK 里当稳定引用。

specs.ts:59-67 还留了一份「新增一个错误码要改哪四处」的清单,值得照抄。

8.4 第三层:上层怎么用

apps/server/src/modules/AgentRuntime/llmErrorClassification.ts 把上面这一切折成一个二元决定:retry 还是 stop(classifyLLMError,:260)。

它的判定顺序(classifyKind,:196):

  1. ProviderBizError 且 status 是 400/422,或报文含 invalid_request / input_schema 之类 → stop(参数错了,重试没用)。
  2. ERROR_CODE_SPECSretryable 字段,得到 stop/retry 集合。
  3. 看 code 关键词(UNAUTHORIZED / RATE_LIMIT / TIMEOUT)。
  4. 看 HTTP status(401/403/400/404/409/422 → stop;408/425/429/5xx → retry)。
  5. 看报文关键词。
  6. 都不中 → 默认 retry

两个设计上的取舍值得记:

  • RETRY_OVERRIDES(:26)是对规格表的有意偏离。 ProviderBizErrorStreamChunkError 这类兜底码在规格里不可重试,但实践中重试一次经常就好了,所以运营侧比规格更激进。注释要求「这个集合要保持精简,每一条都是有意识的偏离」。
  • 分类器自己包了 try/catch(:266-279)。 理由写得很直白:一个会抛异常的分类器,会用它自己的 TypeError 盖住原始的供应商错误,让线上排查彻底失效。兜底是保守的 stop + 原始报文。

调用点在 packages/agent-runtime/src/executors/callLlm.ts:112(retryPolicy.classifyError,原 RuntimeExecutors 单体已重构进 packages/agent-runtime),分类结果直接决定要不要走重试退避循环。


9. providers/ 的组织约定与「新增一家」的最小改动面

9.1 目录约定

packages/model-runtime/src/providers/ 下 82 个目录(外加一个 utils/),每个目录的约定是:

文件何时需要内容
index.ts必须导出 params 配置对象 + LobeXxxAI = createXxxRuntime(params)
chatPayload.tspayload 改写逻辑超过十几行时handlePayload 的实现
modelFetch.ts模型列表要特殊拉取时models 的实现
xxxModelId.ts需要按模型 id 判能力时claudeModelId.tsopenaiModelId.ts 里的一堆 isXxxModel
createImage.ts / createVideo.ts支持多模态生成时对应实现
generateObject.ts结构化输出要特判时对应实现

两条一致的惯例:

  • params 与 runtime 类都导出。 因为 RouterRuntime 和聚合网关(aihubmix、newapi)要复用别家的 params 去拼自己的路由表(参见 providers/deepseek/index.ts:39/54/103)。
  • debug 开关走环境变量。 统一写成 debug: { chatCompletion: () => process.env.DEBUG_XXX_CHAT_COMPLETION === '1' }

9.2 新增一家的最小改动面

如果这家是标准 OpenAI 兼容协议,改动面是五个文件:

① packages/model-bank/src/const/modelProvider.ts 加一个 ModelProvider 枚举值
② packages/model-bank/src/aiModels/<id>.ts 模型卡片(上下文窗口/能力/定价)
└ 并在 aiModels/index.ts 里 import + 注册
③ packages/model-bank/src/modelProviders/<id>.ts 供应商卡片(名称/文档链接/settings.sdkType)
└ 并在 modelProviders/index.ts 里 import + 注册
④ packages/model-runtime/src/providers/<id>/index.ts params + createOpenAICompatibleRuntime
⑤ packages/model-runtime/src/runtimeMap.ts import + 在表里加一行

第 ④ 步的最小实现就是第 4.1 节那个 AkashChat 的样子——十几行配置。

难度随「离 OpenAI 协议多远」递增:

情况额外要做什么
标准 OpenAI 兼容只填 baseURL + models
参数名不同chatCompletion.handlePayload
流事件格式不同写一个 core/streams/<id>.ts,挂到 chatCompletion.handleStream
鉴权/传输不同customClient.createClientcreateChatCompletionStream
走 Messages API改用 createAnthropicCompatibleRuntime
一家多端点再包一层 createRouterRuntime
完全自定义 SDK(Bedrock / Google / Ollama)手写实现 LobeRuntimeAI

9.3 model-bank 是什么

packages/model-bank纯静态数据包,没有运行时逻辑。它按 provider 分文件存模型卡片(src/aiModels/,82 个文件),字段包括 contextWindowTokensabilitiespricingknowledgeCutofffamily 等(packages/model-bank/src/types/aiModel.ts:274)。

model-runtime 在三处消费它:

  • 模型列表兜底 —— models() 拉到的裸 id 会去 LOBE_DEFAULT_MODEL_LIST 里找已知卡片补全信息(openaiCompatibleFactory/index.ts:1037-1043)。
  • 上下文预检 —— contextPreFlight 用卡片上的窗口大小做本地判断。
  • 计价 —— getModelPricing 最终查的就是卡片上的 pricing

10. 巧妙之处(可以带走的技术)

  1. 翻译器放进速度计算器,而不是 SSE 编码器。 因为算 TTFT/TPS 需要看见语义化的 chunk 类型,而不是原始报文。管线里 createSSEProtocolTransformer((c) => c, ...) 这个恒等函数就是这个决定留下的痕迹(core/streams/openai/openai.ts:721-728)。

  2. 钩子失败是分级的。 「预算检查」失败必须中止,「计费记账」失败只能记日志——同一个钩子系统里两套语义,写在 core/ModelRuntime.ts:315-326

  3. 假流让下游只写一套逻辑。 无论是非流式响应、Responses API 的一次性结果,还是 Anthropic 的整包 Message,都被伪造成事件序列喂给同一个流适配器(nonStreamToStream.ts:6anthropicCompatibleFactory/index.ts:671)。

  4. usageMissingDiagnostics:把「没拿到用量」也当成一条数据。 只要终止事件到了却没有 usage,StreamContext 就记下 apiMode / chunkIndex / finishReason / 是否请求过 usage 等一整套现场(protocol.ts:74-97),最后随 onFinal 一起上报(protocol.ts:463-466)。这是「计费缺口可观测」而不是「悄悄丢账」。

  5. 容忍脏数据优先于严格校验——但只在流里。 normalizeChunkForParsename: null 改成 ''(parseToolCalls.ts:13),filterValidCitations 丢掉无 url 的引用(openai/openai.ts:51),safeJsonStringify 处理 BigInt 和循环引用(protocol.ts:268)。共同的判断是:流中间抛异常 = 用户看到对话凭空断掉,代价远大于少一条引用。

  6. 错误码的 numericId 是 append-only 的。 字符串码可以改名,数字 id 永不变——这样 E1001 能安全地出现在工单和外部文档里(errors/specs.ts:41-46)。

  7. 懒创建 runtime。 RouterRuntime 构造时只存配置,每次尝试才 new(RouterRuntime/createRuntime.ts:494)。20 个回退通道不等于 20 个常驻客户端。

  8. 被丢弃的参数要显式返回 undefined 因为调用点用展开合并,只有显式 undefined 才盖得住原值(core/parameterResolver.ts:289-298)。这个 JS 陷阱值得单独记一笔。


11. 边界与局限(诚实清单)

  • OpenAIStream 的翻译函数是个近千行的巨型分支。 transformOpenAIStream(core/streams/openai/openai.ts:90-680)里塞满了 MiniMax 的 base_resp、xAI 的 citations、小米 MiMo 的首 chunk annotations、one-api 的「finish_reason 和 content 同时出现」等等。每接一个新代理网关,这个函数就长一点。代码里没有把这些拆成可插拔的规则表。

  • 不是所有适配器都补齐了管线。 SparkAIStream(spark.ts:220)的参数类型里写了 inputStartAt,但函数体没有解构使用,也没有接 createTokenSpeedCalculator——所以 Spark 的对话拿不到 speed chunk,ABORT_CHUNK 也不会被翻成 stopOllamaStream(ollama.ts:53)则完全没有 usage。

  • Cloudflare 绕过了统一协议。 CloudflareStreamTransformer(cloudflare.ts:3)直接手写 event: text 的 SSE 文本行,注释里明写 // TODO: Add test; Handle tool_call parameter.——它不支持工具调用。

  • baseURL 变更会在 chat 里就地重建客户端。_options.baseURL 与实例上的 baseURL 不一致时,chat 会当场 new OpenAI(...) 替换 this.client(openaiCompatibleFactory/index.ts:684-717)。这意味着 runtime 实例是有可变状态的,不能假设它跨请求不变。

  • ProviderBizError 是个很大的兜底桶。 规格表里 isFallback 这个字段的存在本身就承认了这点(errors/specs.ts:31-38),注释说监控要盯着兜底桶的总量,来决定哪些还值得细分出去。

  • getModelPricing 的 fallback 可能配错价。 找不到「模型 + provider」精确匹配时,它会退到「任意 provider 的同名模型」(utils/getModelPricing.ts:36-40)。源码里的 TODO 明说需要一个 fallback 优先级列表(优先官方 provider),现在还没有。

  • 本章不覆盖的: 上下文与工具怎么被拼进 payload(见 上下文工程)、工具调用拿到后怎么执行(见 工具体系)、这套 runtime 在浏览器/服务端/沙箱各自怎么跑(见 四个执行面)。


12. 代码地图(导航索引)

路径相对克隆根 packages/model-runtime/src/,除非另有标注。

门面与路由

主题文件符号
runtime 能力契约core/BaseAI.tsLobeRuntimeAILobeOpenAICompatibleRuntime
门面 + 生命周期钩子core/ModelRuntime.tsModelRuntimeModelRuntimeHooksinitializeWithProviderapplyHooks
provider → 类查找表runtimeMap.tsproviderRuntimeMap
钩子合并core/mergeHooks.tsmergeModelRuntimeHooks
同 provider 内路由 + 回退core/RouterRuntime/createRuntime.tscreateRouterRuntimeresolveMatchedRouterrunWithFallbackcreateRuntimeFromOption
apiType → 类映射core/RouterRuntime/baseRuntimeMap.tsbaseRuntimeMap

工厂与参数

主题文件符号
OpenAI 兼容工厂core/openaiCompatibleFactory/index.tscreateOpenAICompatibleRuntimeOpenAICompatibleFactoryOptionsCustomClientOptionshandleErrorshouldUseResponsesAPI
非流式伪装成流core/openaiCompatibleFactory/nonStreamToStream.tstransformResponseToStreamtransformResponseAPIToStream
Anthropic 兼容工厂core/anthropicCompatibleFactory/index.tscreateAnthropicCompatibleRuntimecreateAnthropicCompatibleParamsbuildDefaultAnthropicPayload
采样参数裁剪core/parameterResolver.tsresolveParametersresolveModelSamplingParameterscreateParameterResolver
消息体构造core/contextBuilders/{openai,anthropic,google}.tsconvertOpenAIMessagesbuildAnthropicMessagesbuildGoogleMessages
工具 schema 归一化core/contextBuilders/normalizeToolSchema.tsnormalizeToolsParametersnormalizeToolJsonSchema

流协议与适配器

主题文件符号
协议类型与管线工具core/streams/protocol.tsStreamContextStreamProtocolChunkcreateSSEProtocolTransformercreateCallbacksTransformercreateTokenSpeedCalculatorcreateFirstErrorHandleTransformerconvertIterableToStream
OpenAI 适配器core/streams/openai/openai.tstransformOpenAIStreamOpenAIStreamfilterValidCitations
Responses API 适配器core/streams/openai/responsesStream.tsOpenAIResponsesStream
Anthropic 适配器core/streams/anthropic.tstransformAnthropicStreamAnthropicStream
Google 适配器core/streams/google/index.tsGoogleGenerativeAIStream
Bedrock 适配器core/streams/bedrock/{claude,llama,common}.ts
Qwen / Spark / Ollama 适配器core/streams/{qwen,spark,ollama}.tstransformQwenStreamtransformSparkStreamtransformSparkResponseToStreamtransformOllamaStream
Cloudflare(绕过协议)core/streams/cloudflare.tsCloudflareStreamTransformer
模型下载进度流core/streams/model.tscreateModelPullStream
工具调用累积helpers/parseToolCalls.tsparseToolCallsnormalizeChunkForParse
SSE Response 包装utils/response.tsStreamingResponse

用量、计费与错误

主题文件符号
OpenAI 用量归一core/usageConverters/openai.tsconvertOpenAIUsageconvertOpenAIResponseUsage
Anthropic 用量归一(跨事件累加)core/usageConverters/anthropic.tsconvertAnthropicUsagebuildAnthropicInitialUsage
挂成本core/usageConverters/utils/withUsageCost.tswithUsageCost
算钱core/usageConverters/utils/computeChatCost.tscomputeChatCost
取定价utils/getModelPricing.tsgetModelPricing
错误码常量packages/types/src/agentRuntime.tsAgentRuntimeErrorType
错误码再导出types/error.tsAGENT_RUNTIME_ERROR_SETStandardErrorType
错误规格表errors/specs.tsERROR_CODE_SPECSErrorCodeSpecgetErrorCodeSpec
错误分类学errors/taxonomy.tsErrorCategoryErrorAttributionCATEGORY_NUMERIC_PREFIX
报文模式匹配errors/classifier.tserrors/patterns.tsErrorClassifierERROR_PATTERNS
上层 retry/stop 判定apps/server/src/modules/AgentRuntime/llmErrorClassification.tsclassifyLLMErrorclassifyKindRETRY_OVERRIDES

静态模型库

主题文件符号
模型卡片类型packages/model-bank/src/types/aiModel.tsAIBaseModelCardAiFullModelCardPricing
各家模型卡片packages/model-bank/src/aiModels/<provider>.ts默认导出数组
供应商卡片packages/model-bank/src/modelProviders/<provider>.tsModelProviderCard
provider id 枚举packages/model-bank/src/const/modelProvider.tsModelProvider

样例 provider(照着抄)

复杂度文件看点
最小providers/akashchat/index.ts十几行配置就是一个 provider
删字段providers/cerebras/index.tshandlePayload 剔除不支持的参数
双协议 + 路由providers/deepseek/index.tsOpenAI 端点 + Anthropic 端点 + createRouterRuntime
完全自定义providers/cloudflare/index.tsproviders/ollama/index.ts手写 LobeRuntimeAI 实现