opencode LLM 包架构解析:Schema 优先的 LLM 核心与四轴 Route 模型
2026/9/7 16:48:39 网站建设 项目流程

opencode LLM 包架构解析:Schema 优先的 LLM 核心与四轴 Route 模型

【免费下载链接】opencodeThe open source coding agent.项目地址: https://gitcode.com/GitHub_Trending/openc/opencode

本文围绕@opencode-ai/llm包的架构指南(AGENTS.md)展开,系统讲解这套基于 Effect 的 LLM 核心的请求流程、Route 四轴(Protocol / Endpoint / Auth / Framing)组合模型、Provider Facade 配置模式、工具调度运行时,以及协议文件编写规范与 cassette 录制测试体系。读完本文后,你可以理解 opencode 如何把「一套类型化请求/响应/事件/工具语言」与「各家提供商的差异适配」彻底解耦,并知道如何为该项目新增一条 provider 路由、编写一个类型化工具或运行一次录制测试。

什么是 @opencode-ai/llm

packages/llm是 opencode 的 Schema 优先 LLM 核心包:一套类型化的请求、响应、事件和工具语言,提供商的怪癖(quirks)全部收敛在适配器里,不出现在调用方代码中。README 给出的最小示例即典型用法:

import { Effect } from "effect" import { LLM, LLMClient } from "@opencode-ai/llm" import { OpenAI } from "@opencode-ai/llm/providers" const model = OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY }).responses("gpt-4o-mini") const request = LLM.request({ model, system: "You are concise.", prompt: "Say hello in one short sentence.", generation: { maxTokens: 40 }, }) const program = Effect.gen(function* () { const response = yield* LLMClient.generate(request) console.log(response.text) })

事件流是 provider 中立的——OpenAI Chat、OpenAI Responses、Anthropic Messages、Gemini、Bedrock Converse 以及任何 OpenAI 兼容部署返回的事件形状完全一致。包入口在 src/llm.ts,对外导出面通过 package.json 的exports字段精确划分:根导出、./route高级 barrel、按提供商拆分的./providers/*,以及按协议拆分的./protocols/*(openai-chat、openai-responses、anthropic-messages、gemini、bedrock-converse、openai-compatible-chat)。

Effect 编码规范

该包构建在 Effect 之上,AGENTS.md 对 Effect 写法有明确约定,新代码必须遵循:

  • 在包边界上优先使用HttpClient.HttpClient/HttpClientResponse.HttpClientResponse,而不是 web 的fetch/Response
  • 流式数据一律使用Stream.Stream,避免临时性的 async generator 或手工 web reader 循环,除非 Effect 的StreamAPI 确实无法建模该行为;
  • JSON 编解码使用 Effect Schema codec(如Schema.fromJsonString(...)),实现代码中不直接写JSON.parse/JSON.stringify
  • Effect.gen中直接 yield 可 yield 的错误(return yield* new MyError(...)),而不是Effect.fail(new MyError(...))
  • 成功值有意为空时使用Effect.void而非Effect.succeed(undefined)

从源码结构看,这些约定在 src/route/client.ts 中得到了贯彻:compilepreparegenerate等入口均使用Effect.fn("LLM.xxx")具名函数声明,便于追踪与测试断言。

命名约定:per-type 构造器与 LLM 命名空间

同一事物的两种构造方式就多了一种。因此约定:类型专属构造器挂在类型本身上,而不是做成顶层再导出。直接使用:

Message.system(...) Message.user(...) Message.assistant(...) Message.tool(...) Model.make(...) ToolDefinition.make(...) ToolCallPart.make(...) ToolResultPart.make(...) ToolChoice.make(...) ToolChoice.named(...) SystemPart.make(...) GenerationOptions.make(...)

顶层LLM命名空间保留给「请求形态的调用 API」:LLM.requestLLM.generateLLM.streamLLM.updateRequestLLM.generateObject

在 src/llm.ts 中可以看到这一约定的落地:request(input)是一个薄构造器,把易用型输入(system: stringprompt: string)归一化进规范 Schema 类——SystemPart.content(requestSystem)messages.map(Message.make)ToolDefinition.makeGenerationOptions.make等,最终new LLMRequest({...})返回同一个 Schema 类实例。updateRequest(input, patch)则是「先展开回RequestInput再合并 patch」的不可变更新。此外LLM.generateObject的实现也印证了「不制造第二套模型」的原则:它内部强制构造一个名为generate_object的合成工具并配合ToolChoice.named,在所有协议上走完全相同的路径——刻意回避各家 provider 原生的 JSON mode,以保证行为一致。

请求流程:从 LLMRequest 到 LLMResponse

预期调用方式是先构造、再执行:

const request = LLM.request({ model: OpenAI.configure({ apiKey }).responses("gpt-4o-mini"), system: "You are concise.", prompt: "Say hello.", }) const response = yield* LLMClient.generate(request)

LLM.request(...)构造一个LLMRequestLLMClient.generate(...)随后读取request.model.route上携带的可执行路由,构建 provider 原生 body,向路由的 transport 索取一个真实的HttpClientRequest.HttpClientRequest,经由RequestExecutor.Service发出,把 provider 流解析为公共LLMEvent,最终返回LLMResponse。三个执行入口各有分工:

  • LLMClient.stream(request)—— 调用方想要增量LLMEvent流;
  • LLMClient.generate(request)—— 把同样的事件收集成LLMResponse
  • LLMClient.prepare<Body>(request)—— 把请求编译过整条路由管线但不真正发送。可选的Body类型参数把.body收窄为路由原生形状(例如prepare<OpenAIChatBody>(...)返回PreparedRequestOf<OpenAIChatBody>)。运行时 body 完全相同,泛型只是调用方做出的类型级断言。

client.ts 中的compile注释精确描述了这条管线的重要边界:

// `compile` is the important boundary: it turns a common `LLMRequest` into a // validated provider body plus transport-private prepared data, but does not // execute transport. const compile = Effect.fn("LLM.compile")(function* (request: LLMRequest) { const resolved = applyCachePolicy(resolveRequestOptions(request)) const route = resolved.model.route const body = yield* route.body .from(resolved) .pipe(Effect.flatMap(ProviderShared.validateWith(Schema.decodeUnknownEffect(route.body.schema)))) const prepared = yield* route.prepareTransport(body, resolved) ... })

注意其中applyCachePolicy(resolveRequestOptions(request))一步:请求级generation/providerOptions/http会先与模型默认值、路由默认值逐轴合并(mergeGenerationOptionsmergeProviderOptionsmergeHttpOptions),缓存策略在编译期就落进 body——这与 README 中「prompt 缓存默认开启、cache: "auto"是缺省值」的描述一致。

过滤或收窄事件流使用LLMEvent.is.*驼峰守卫,例如events.filter(LLMEvent.is.toolCall)。kebab-case 的LLMEvent.guards["tool-call"]形式仍然可用,但新代码应优先is.*

Route 四轴模型:一条路由 = Protocol + Endpoint + Auth + Framing

这是整个包最核心的架构决策。路由(Route)是四个正交部件的已注册、可执行组合

  • Protocol(src/route/protocol.ts)——语义 API 契约。拥有请求 body 构造(body.from)、body schema(body.schema)、流事件 schema(stream.event)以及事件到LLMEvent的状态机(stream.step)。Route.make(...)会用body.schema校验并 JSON 编码 body,用stream.event解码帧。实例:OpenAIChat.protocolOpenAIResponses.protocolAnthropicMessages.protocolGemini.protocolBedrockConverse.protocol
  • Endpoint(src/route/endpoint.ts)——URL 构造。host、path、route query 都挂在 endpoint 上。Endpoint.path("/chat/completions", { baseURL })是常见形态;当路径内嵌模型 id 或 body 字段时(如Endpoint.path(({ body }) =>/model/${body.modelId}/converse-stream)),传入一个函数。
  • Auth(src/route/auth.ts)——每请求传输鉴权。Provider facade 在选模型之前把凭证配置到路由上,通常通过Auth.bearer(apiKey)Auth.header(name, apiKey)。需要每请求签名的路由(Bedrock SigV4、未来的 Vertex IAM、Azure AAD)把Auth实现为对 body 签名并把签名头合并进结果签名的函数。
  • Framing(src/route/framing.ts)——字节 → 帧。SSE(Framing.sse)是共享实现;Bedrock 把 AWS event-stream 的帧保持为类型化的Framing<object>值,与它的协议并存。

通过Route.make(...)组合它们:

export const route = Route.make({ id: "openai-chat", provider: "openai", protocol: OpenAIChat.protocol, endpoint: Endpoint.path("/chat/completions", { baseURL: "https://api.openai.com/v1", }), auth: Auth.bearer(), framing: Framing.sse, })

路由上的defaults是「请求塑形默认值」:headerslimitsgenerationproviderOptionshttp。Endpoint 的 host/query 属于路由 endpoint。选中的Model值只携带模型 id、provider id 和已配置的路由值;模型能力/目录元数据活在这个包之外,协议兼容性由请求降级(lowering)阶段和类型化LLMError强制。

从源码看,Route接口(client.ts 的Route<Body, Prepared>)还暴露with(patch)(不可变修补路由,facade 覆盖 auth/endpoint 的入口)、model(input)(由路由构造带路由值的Model)以及prepareTransport/streamPrepared(传输私有准备与流读取)。makeRouteModel中有两个硬性前置条件:路由必须能解析出 provider,且 endpoint 必须已有baseURL——Route.model(...)在 baseURL 缺失时会直接抛出要求「先配置路由」。这正是「无规范 URL 的路由必须先配置后执行」这条约定在代码中的落点。

四轴分解的收益:DeepSeek、TogetherAI、Cerebras、Baseten、Fireworks、DeepInfra 全部原样复用OpenAIChat.protocol——每个 provider 部署只是一段 5~15 行的Route.make(...)调用,而不是 300~400 行的路由克隆;某个协议里修一个 bug,一次提交就能传导到该协议的所有消费者。

非 HTTP 传输的接缝是Transport:当某 provider 提供非 HTTP 传输(OpenAI 的 WebSocket Responses 后端、假想的双向流式 API)时,WebSocketTransport.jsonTransport.with(...)构造一个 IO 模板,其prepare在编译期接收路由 endpoint/auth,构建 WebSocket URL 与消息;其frames从 socket 产出解码后的文本。同样的协议与 endpoint 来源,不同的 transport。LLMClient.layer(client.ts 末尾)同时装配RequestExecutor.Service与可选的WebSocketExecutor.Service,两种运行时在此汇合。

URL 构造规则

Endpoint拥有{ baseURL, path, query }。每个协议路由在 provider 有规范地址时会带一个(如https://api.openai.com/v1);provider 助手在选模型之前通过配置路由来覆盖 endpoint 字段。没有规范 URL 的路由(OpenAI 兼容 Chat、GitHub Copilot)执行前必须完成配置。

对 URL 由类型化输入派生的 provider(Azure 资源名、Bedrock region),provider 助手在调用.model(...)之前配置路由 endpoint。当输入接受两条二选一的派生路径时(Azure:resourceNamebaseURL),使用 route/auth-options.ts 中的AtLeastOne<T>

Provider Facade:先配置、后选模型

面向 provider 的 API 是「路由值之上的已配置 facade」:endpoint/auth/资源/API 版本的设置在选模型之前完成,模型选择器只接受一个模型 id 或部署 id:

const openai = OpenAI.configure({ apiKey, baseURL }) const model = openai.responses("gpt-4o-mini") const azure = Azure.configure({ resourceName, apiKey, apiVersion: "v1" }) const deployment = azure.responses("my-deployment") const gateway = CloudflareAIGateway.configure({ accountId, gatewayId, gatewayApiKey, apiKey }) const proxied = gateway.model("openai/gpt-4o-mini")

Facade 应保持小而显式:

  • 直接构造 id 时使用 branded 的ProviderID.make(...)ModelID.make(...)
  • model表示默认 API 路径,用命名方法表示 provider 原生替代路径(OpenAI 的responsesresponsesWebSocketchat);
  • provider 专属设置放.configure(...);不要新增model(id, overrides)这种重复构造路径;
  • 仅当高级内部接线确有需要时,才单独导出底层routes数组;
  • apiKey作为 provider 专属糖,auth作为显式覆盖;在 provider option 类型里用ProviderAuthOption保持二者互斥;
  • AuthOptions.bearer(options, "<PROVIDER>_API_KEY")apiKey解析为Auth——它尊重显式auth覆盖,并回退到Auth.config(envVar),使缺失的 key 表现为类型化Authentication错误而不是运行时崩溃;
  • 对需要不同必填设置的同一厂商产品,使用独立的顶层 facade,如CloudflareAIGatewayCloudflareWorkersAI

Provider.make(...)对简单静态 provider 定义仍然可用,但新的内置 provider 应优先使用普通已配置 facade,除非某个 helper 在不增加运行时行为的前提下消除了真实重复。auth.ts 中的MissingCredentialError/AuthenticationReason映射(toLLMError)正是「缺失凭证 → 类型化错误」这一承诺的实现细节。

目录布局与依赖方向

packages/llm/src/ schema/ 规范 Schema 模型,按关注点拆分 ids.ts branded IDs、字面量类型、ProviderMetadata options.ts Generation/Provider/Http options、Limits、Model、cache policy messages.ts content parts、Message、ToolDefinition、LLMRequest events.ts Usage、各事件、LLMEvent、PreparedRequest、LLMResponse errors.ts 错误原因、LLMError、ToolFailure index.ts barrel llm.ts 请求构造器与便捷 helper route/ index.ts @opencode-ai/llm/route 高级 barrel client.ts Route.make + LLMClient.prepare/stream/generate executor.ts RequestExecutor service + transport 错误映射 protocol.ts Protocol 类型 + Protocol.make endpoint.ts Endpoint 类型 + Endpoint.path auth.ts Auth 类型 + Auth.bearer / Auth.apiKeyHeader / Auth.passthrough auth-options.ts ProviderAuthOption 形状、AuthOptions.bearer、AtLeastOne helper framing.ts Framing 类型 + Framing.sse transport/ transport 实现 index.ts Transport 类型 + HttpTransport / WebSocketTransport 命名空间 http.ts HttpTransport.httpJson — POST + framing websocket.ts WebSocketTransport.json + WebSocketExecutor service protocols/ shared.ts 协议实现内使用的 ProviderShared 工具集 openai-chat.ts protocol + route(组合 OpenAIChat.protocol) openai-responses.ts anthropic-messages.ts gemini.ts bedrock-converse.ts bedrock-event-stream.ts AWS event-stream 二进制帧的 framing openai-compatible-chat.ts 复用 OpenAIChat.protocol、无规范 URL 的 route utils/ 每协议 helper(auth、cache、media、tool-stream 等) providers/ openai-compatible.ts 通用兼容 helper + 家族模型 helper openai-compatible-profile.ts 家族默认值(deepseek、togetherai 等) azure.ts / amazon-bedrock.ts / cloudflare.ts / github-copilot.ts / google.ts / xai.ts / openai.ts / anthropic.ts / openrouter.ts tool.ts 类型化 tool() helper tool-runtime.ts 窄化的单调用类型化工具调度器

依赖箭头向下:providers/*.ts导入协议路由与 auth-option 工具;协议模块导入endpointauthframing与 transport 部件。协议不导入 provider facade;更底层的模块对 provider 目录元数据一无所知

ProviderShared:协议实现的公共工具箱

protocols/shared.ts 导出一个小工具集,让协议实现聚焦于 provider 原生形状:

  • joinText(parts)—— 用换行连接TextPart数组(或任何带.text的对象)。协议把文本内容压平为单一字符串填 provider 字段时都用它;
  • parseToolInput(route, name, raw)—— 用规范错误消息 "Invalid JSON input for<route>tool call<name>" 对工具调用参数串做 Schema 解码;空输入按{}处理;
  • parseJson(route, raw, message)—— 非工具 body 的通用 JSON-via-Schema 解码;
  • eventError(route, message, ...)—— 流式解码失败时构造类型化InvalidProviderOutput
  • validateWith(decoder)—— 把 Schema 解码错误映射为InvalidRequestRoute.make(...)用它做 body 校验,低层路由可复用;
  • matchToolChoice(provider, choice, branches)—— 对LLMRequest["toolChoice"]做 provider 专属降级分支。

准则:如果你发现自己在两个协议之间复制同一段 3~5 行的片段,把它提升到ProviderShared,与上述 helper 并排放置,而不是重复实现。

时间序列 System 更新

LLMRequest.system是初始的特权提示词,作用于整段对话之前。而Message.system(...)是另一回事:它是LLMRequest.messages中一个独立的、provider 中立的时间序列操作者更新,只从其所在位置起向后生效,且只接受文本内容。

原生时间序列 system 消息是 route/model 相关的:Anthropic Messages 对 Claude Opus 4.8(claude-opus-4-8)做原生降级。其他路由与模型刻意把更新就地降级为普通 user 兼容文本,使用稳定的转义表示:

<system-update> ... </system-update>

这条 wrapped-user 回退在降低权限外观的同时保持顺序。绝不要把裸的时间序列role: "system"消息穿过可能拒绝它的路由;也不要把检索到的原始文档、工具输出或 web 内容塞进特权时间序列 system 更新——不可信内容留在普通 user/tool 通道。

工具循环与类型化工具调度

工具循环用公共消息和事件表示:

const call = ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } }) const result = Message.tool({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }) const followUp = LLM.request({ model, messages: [Message.user("Weather?"), Message.assistant([call]), result], })

路由把这些降级为 provider 原生的 assistant 工具调用消息与工具结果消息。流式 provider 应在参数到达期间发出tool-input-delta事件,随后发出带解析后 input 的最终tool-call事件。

ToolRuntime.dispatch:只跑一个 provider turn

LLM.stream(request)LLM.generate(request)各执行恰好一个provider turn。把工具 schema 通过Tool.toDefinitions(tools)加进request.tools;当调用方想要包提供的类型化单调用执行行为时,把每个规范的本地tool-call事件传给ToolRuntime.dispatch(tools, call)

const get_weather = tool({ description: "Get current weather for a city", parameters: Schema.Struct({ city: Schema.String }), success: Schema.Struct({ temperature: Schema.Number, condition: Schema.String }), execute: ({ city }) => Effect.gen(function* () { // city: string — 由 parameters Schema 推导类型 const data = yield* WeatherApi.fetch(city) return { temperature: data.temp, condition: data.cond } // 返回类型相对 success Schema 被检查 }), }) const tools = { get_weather, get_time, ... } const events = yield* LLM.stream( LLM.updateRequest(request, { tools: Tool.toDefinitions(tools) }), ).pipe(Stream.runCollect) const call = Array.from(events).find(LLMEvent.is.toolCall) if (call && !call.providerExecuted) { const dispatched = yield* ToolRuntime.dispatch(tools, call) // 持久化 call + dispatched.result,然后显式构造下一个请求。 }

tool-runtime.ts 中的调度器职责边界非常窄,dispatch的实现可以逐行核对:

  • tool-call:按名字查工具,用parametersSchema 解码 input,分派到类型化execute,用successSchema 编码结果,返回规范的tool-result事件;
  • 流式读 provider、不构造 Session 事件、不调度 fiber、不追加历史、不数步数、不继续模型回合;
  • 持久化与继续(continuation)留给外层产品流程。

handler 依赖(services、permissions、plugin hooks、abort 处理)由消费方在工具构造时闭包捕获。建议在Effect.gen内一次性构建 tools 记录并在多次 dispatch 间复用。

错误必须表达为ToolFailure。运行时捕获它并发出tool-error事件,随后是一条type: "error"tool-result,模型可以在下一步自我纠正。任何非ToolFailure的东西都被视为缺陷(defect),使整个流失败。源码中三条可恢复错误路径都会产出tool-error事件:

  1. 模型调用了未知工具名(Unknown tool: ...);
  2. input 未通过parametersSchema(Invalid tool input: ...);
  3. handler 返回了ToolFailure

此外 tool-runtime.ts 还处理了execute缺失与 success schema 编码失败(Tool returned an invalid value for its success schema)——前者产生错误结果,后者同样折叠为ToolFailure

Provider 定义/托管工具直通:Anthropic 的web_search/code_execution/web_fetch,OpenAI Responses 的web_search_call/file_search_call/code_interpreter_call/mcp_call/local_shell_call/image_generation_call/computer_use_call在运行时原样穿过:

  • 路由把模型的调用作为providerExecuted: truetool-call事件呈现,把 provider 结果作为匹配的providerExecuted: truetool-result事件呈现;
  • 调用方在tool-call上检测providerExecuted跳过本地分派——不调 handler,也不为「未知工具」抛tool-error,provider 已经执行过了;
  • 继续对话的调用方在协议要求时应在显式历史中保留两个事件:Anthropic 把它们编码回server_tool_use+web_search_tool_result(或code_execution_tool_result/web_fetch_tool_result)块;OpenAI Responses 调用方通常使用previous_response_id而不是重发 hosted-tool 条目。

把 provider 定义工具加进request.tools(不需要运行时条目)。匹配的路由必须知道如何把工具定义降级为 provider 原生形状;当前 Anthropic 接受web_search/code_execution/web_fetch,OpenAI Responses 接受上述托管工具名。

协议文件风格:让文件互相「长得像」

协议文件应当彼此自相似。provider 怪癖应藏在具名 helper 后面,使得评审一个新路由时可以跨文件比对相同章节。

章节顺序

每个协议模块使用这个顺序:

  1. 公共模型输入
  2. 请求 body schema
  3. 流事件 schema
  4. 解析器状态
  5. 请求 body 构造(fromRequest
  6. 流解析(step与逐事件 handler)
  7. Protocol 与 route
  8. 协议路由导出

规则

  • 协议文件聚焦于协议本身。provider 专属投影、签名、媒体归一化或其他臃肿转换移入src/protocols/utils/*
  • 请求 body 构造入口用Effect.fn("Provider.fromRequest");yield effect 的事件 handler 用Effect.fn(...);纯同步 handler 保持为普通函数、返回StepResult,由调度器经Effect.succeed(...)提升;
  • 解析器状态拥有终止信息:状态机记录 finish reason、usage 与挂起工具调用;每个完成的响应恰好发出一个终止finish事件(或provider-error)。若 provider 把 reason 和 usage 拆在不同事件里,在 flush 前于解析器状态中合并;
  • 对完成的响应恰好发一个终止finish事件,通常在匹配的step-finish之后。provider 有完成哨兵时用stream.terminal停止读取;当最终事件必须在帧流结束后 flush 时用stream.onHalt。对应地,client.ts 中streamPrepared的实现正是Stream.mapAccumEffect(() => protocol.stream.initial(request), protocol.stream.step, ...)并在有terminal时套Stream.takeUntil
  • 重复的协议策略(文本拼接、usage 汇总、JSON 解析、工具调用累积)使用共享 helper。ToolStream(protocols/utils/tool-stream.ts)统一累积流式工具调用参数;
  • 有意的 provider 差异要在 helper 名或注释里显式表达。如果两个协议文件视觉上有差异,原因应当从命名上就能看明白;
  • 优先用从一个小顶层stepswitch 分派出来的逐事件 handler(onMessageStartonContentBlockDelta等),而不是长 if 链。分派器让事件面一目了然;
  • 测试与协议保持同一概念顺序:基础 prepare、工具 prepare、不支持的降级、文本/usage 解析、工具流、finish reasons、provider 错误。

评审清单

  • 能否与openai-chat.ts并排快速扫读而不必翻找对应章节?
  • provider 怪癖是否被命名、隔离并有聚焦测试覆盖?
  • 请求 body 构造是否在协议边界校验不支持的公共内容?
  • 流解析是否发出稳定的公共事件,而不把 provider 事件顺序泄漏给调用方?
  • toolChoice: "none"的行为读起来是否「有意为之」?

测试体系:Effect 层测试与 cassette 录制

单元测试层面:

  • 需要 Effect layer 的测试统一使用 test/lib/effect.ts 中的testEffect(...)
  • provider 测试保持 fixture-first:真实的 provider 调用必须留在RECORD=true与必需 API key 检查之后。

录制测试使用每场景一个 cassette 文件。cassette 保存一个有序{ request, response }交互数组,因此多步流程(工具循环、重试、轮询)都录制进同一个文件。用recordedTests({ prefix, requires }),让 helper 从测试名派生 cassette 名:

const recorded = recordedTests({ prefix: "openai-chat", requires: ["OPENAI_API_KEY"] }) recorded.effect("streams text", () => Effect.gen(function* () { // 测试主体 }), )

replay 是默认模式;RECORD=true录制新 cassette 并要求所列环境变量。cassette 以 pretty-printed JSON 写出,多交互 diff 可评审。给recordedTests(...)/recorded.effect.with(...)providerprotocol与可选tags,让 cassette 携带可搜索元数据。录制过滤器用于不重写整个文件就 replay 或录制窄子集:

  • RECORDED_PROVIDER=openai—— 匹配打了provider:openai标签的测试;支持逗号分隔多值;
  • RECORDED_PREFIX=openai-chat—— 按recordedTests({ prefix })匹配 cassette 组;支持逗号分隔;
  • RECORDED_TAGS=tool—— 要求所列标签全部存在,如RECORDED_TAGS=provider:togetherai,tool
  • RECORDed_TEST="streams text"—— 按测试名、kebab-case 测试 id 或 cassette 路径匹配(即RECORDED_TEST="streams text")。

过滤器在 replay 与 record 模式下都生效;配合RECORD=true即可只刷新一个 provider 或一个场景。

二进制响应体:大多数 provider 流式返回文本(SSE、JSON)。录制器把已知的文本型 media type(text/*、JSON/XML 结构化类型、JavaScript、表单、YAML、SVG)当文本处理,其余响应以bodyEncoding: "base64"存储为 base64——这让 AWS event-stream 帧等二进制格式免于有损的 UTF-8 往返。

匹配策略:replay 通过内部游标按录制顺序遍历 cassette——第 N 个运行时请求由第 N 个录制的交互提供,并逐一校验 method、URL、白名单 header 与规范化 JSON body。这统一地支持工具循环(每一轮请求因历史增长而不同)与重试/轮询场景(逐字节相同请求、不同响应)。如果测试重排了请求顺序,需要重新录制 cassette。test/lib/http.ts 中的scriptedResponses是不需要真实 provider 的确定性对等物:按顺序脚本化响应 body,不从磁盘读取。

纪律:新增一个 cassette 时不要整体重录整个测试文件RECORD=true会重写每个运行到的录制用例,而 provider 流里包含易变 id、时间戳、指纹与混淆字段。应删除那一个打算刷新的 cassette,或只运行注册目标场景的聚焦测试模式;除非请求形状或期望行为变了,保持既有稳定 cassette 不变。

仓库内集成点与边界

该包刻意保持独立于 session 关注点。session 鉴权、权限、插件、遥测头与运行时选择都属于 opencode 侧。主要集成点:

  • packages/opencode/src/session/llm.ts —— session 拥有的编排层,决定某次请求走 AI SDK 还是本包的原生 route runtime;
  • native-request.ts —— 把 opencode 的 session/AI SDK 形状数据降级为本包LLMRequest模型的适配器;
  • native-runtime.ts —— 调用裸LLMClient.stream(request)、通过本包的类型化分派器桥接 opencode 工具调用一个 provider turn 的执行适配器;
  • ai-sdk.ts —— 把 AI SDK 流部件转换为本包共享LLMEvent,保持默认 AI SDK 路径兼容。

这条边界意味着:在packages/llm内写代码时,永远不要把 session 级概念(鉴权上下文、权限检查、telemetry 头注入)带进来;它们属于 session 编排层及其本地适配器。

小结

@opencode-ai/llm的设计可以用三句话概括:src/schema/的 Schema 类是唯一运行时数据模型,llm.ts 的便捷函数只是返回同一批 Schema 类实例的薄构造器;一条路由由 Protocol、Endpoint、Auth、Framing 四个正交部件经Route.make(...)组合,提供商差异被压缩为 5~15 行的配置调用;而工具调度器(tool-runtime.ts)只负责「解码输入 → 执行 → 编码输出 → 产出事件」这一窄窄的一段,把流读取、持久化与对话继续全部留给外层。配套的类型化错误(LLMError/ToolFailure)、fixture-first 的 cassette 录制测试与「协议文件互相像」的风格清单,则共同保证了这套多协议体系在扩张时仍然可评审、可推理。可运行的端到端示例见 example/tutorial.ts,协议层测试见packages/llm/test/下的*.test.ts(fixture 优先)与*.recorded.test.ts(live cassette)。

【免费下载链接】opencodeThe open source coding agent.项目地址: https://gitcode.com/GitHub_Trending/openc/opencode

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询