Network requests and reasoning
Full referenceHow a request is prepared, which runtime and provider SDK sends it, and how reasoning and history are stored and replayed. Release v1.18.34, commit aec0b9a6d889. These records derive only from public upstream source. Conditions describe possible harness behavior; they do not establish that any text was sent in a session. Runtime configuration, plugins, MCP servers, provider catalogs and SDK serialization can change a request. Private recordings are not inputs to this extractor. Source excerpts are copyright (c) 2025 opencode, under the upstream MIT license; its notice is preserved in upstream-license.txt.
Request preparation
Request preparation and plugin transforms
agent.prompt replaces provider prompt; then input.system and user.system append. Plugins may transform system, params and headers. Options merge base, model, agent and selected user variant in that order. OpenAI OAuth puts system text into instructions instead of system messages; GitLab workflows have a separate systemPrompt path.
When: LLMRequestPrep.prepare runs before the selected runtime executes.
Source: request.ts lines 56–146 · SHA-256 98f2b612644a…
export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: PrepareInput) {
const isOpenaiOauth = input.provider.id === "openai" && input.auth?.type === "oauth"
const system = [
[
...(input.agent.prompt ? [input.agent.prompt] : SystemPrompt.provider(input.model)),
...input.system,
...(input.user.system ? [input.user.system] : []),
]
.filter((x) => x)
.join("\n"),
]
const header = system[0]
yield* input.plugin.trigger(
"experimental.chat.system.transform",
{ sessionID: input.sessionID, model: input.model },
{ system },
)
if (system.length > 2 && system[0] === header) {
const rest = system.slice(1)
system.length = 0
system.push(header, rest.join("\n"))
}
const variant =
!input.small && input.model.variants && input.user.model.variant
? input.model.variants[input.user.model.variant]
: {}
const base = input.small
? ProviderTransform.smallOptions(input.model)
: ProviderTransform.options({
model: input.model,
sessionID: input.sessionID,
providerOptions: input.provider.options,
})
const options = mergeOptions(mergeOptions(mergeOptions(base, input.model.options), input.agent.options), variant)
if (
input.model.api.npm === "@ai-sdk/azure" &&
(input.provider.options.useCompletionUrls || input.model.options.useCompletionUrls || options.useCompletionUrls)
) {
delete options.reasoningSummary
delete options.include
}
if (isOpenaiOauth) options.instructions = system.join("\n")
const messages =
isOpenaiOauth || input.isWorkflow
? input.messages
: [
...system.map(
(x): ModelMessage => ({
role: "system",
content: x,
}),
),
...input.messages,
]
const params = yield* input.plugin.trigger(
"chat.params",
{
sessionID: input.sessionID,
agent: input.agent.name,
model: input.model,
provider: input.provider,
message: input.user,
},
{
temperature: input.model.capabilities.temperature
? (input.agent.temperature ?? ProviderTransform.temperature(input.model))
: undefined,
topP: input.agent.topP ?? ProviderTransform.topP(input.model),
topK: ProviderTransform.topK(input.model),
maxOutputTokens: ProviderTransform.maxOutputTokens(input.model, input.flags.outputTokenMax),
options,
},
)
const { headers } = yield* input.plugin.trigger(
"chat.headers",
{
sessionID: input.sessionID,
agent: input.agent.name,
model: input.model,
provider: input.provider,
message: input.user,
},
{
headers: {},
},
)
Session identity headers and overrides
x-opencode-session-id and optional x-opencode-parent-session-id provide explicit identity. Non-opencode providers also get x-session-affinity and X-Session-Id. Header presence in source does not prove the final captured request includes them.
When: Prepared request headers go to the selected runtime; model headers then plugin headers can override earlier fields.
Source: request.ts lines 177–208 · SHA-256 5d22219e991e…
const opencodeProjectID = input.model.providerID.startsWith("opencode")
? (yield* InstanceState.context).project.id
: undefined
return {
system,
messages,
tools: Object.fromEntries(Object.entries(tools).toSorted(([a], [b]) => a.localeCompare(b))),
params,
messageTransformOptions: options,
headers: {
"x-opencode-session-id": input.sessionID,
...(input.parentSessionID ? { "x-opencode-parent-session-id": input.parentSessionID } : {}),
...(input.model.providerID.startsWith("opencode")
? {
...(opencodeProjectID ? { "x-opencode-project": opencodeProjectID } : {}),
"x-opencode-session": input.sessionID,
"x-opencode-request": input.user.id,
"x-opencode-client": input.flags.client,
"User-Agent": USER_AGENT,
}
: {
"x-session-affinity": input.sessionID,
"X-Session-Id": input.sessionID,
"User-Agent": USER_AGENT,
}),
...(input.parentSessionID ? { "x-parent-session-id": input.parentSessionID } : {}),
...input.model.headers,
...headers,
},
}
})
Final request tool filtering
GitHub Copilot may gain a compatibility _noop tool only when no tools remain and replayed history contains tool calls.
When: After params/headers hooks, resolveTools applies agent/session permissions and per-user tools disablement; selected Responses-family tools have strict false.
Source: request.ts lines 152–178 · SHA-256 143ebda7ce0c…
if (
input.model.api.npm === "@ai-sdk/openai" ||
input.model.api.npm === "@ai-sdk/azure" ||
input.model.api.npm === "@ai-sdk/amazon-bedrock/mantle"
) {
for (const key of Object.keys(tools)) tools[key] = { ...tools[key], strict: false }
}
if (
input.model.providerID.includes("github-copilot") &&
Object.keys(tools).length === 0 &&
hasToolCalls(input.messages)
) {
// Copilot needs a tools field when replaying prior tool calls, even if no tools are currently enabled.
tools["_noop"] = aiTool({
description: "Do not call this tool. It exists only for API compatibility and must never be invoked.",
inputSchema: jsonSchema({
type: "object",
properties: {
reason: { type: "string", description: "Unused" },
},
}),
execute: async () => ({ output: "", title: "", metadata: {} }),
})
}
const opencodeProjectID = input.model.providerID.startsWith("opencode")
? (yield* InstanceState.context).project.id
Final permission and user-tools gate
Tool presence in the registry differs from final request inclusion.
When: resolveTools is called during request preparation.
Source: request.ts lines 210–216 · SHA-256 61a7ee329dec…
function resolveTools(input: Pick<PrepareInput, "tools" | "agent" | "permission" | "user">) {
const disabled = Permission.disabled(
Object.keys(input.tools),
Permission.merge(input.agent.permission, input.permission ?? []),
)
return Record.filter(input.tools, (_, k) => input.user.tools?.[k] !== false && !disabled.has(k))
}
Core runner request identity and context
Adds exact session/parent headers, model request, agent system and context baseline. Live runtime evidence is needed to identify which path produced a capture.
When: Separate core SessionRunner source path constructs a request.
Source: llm.ts lines 203–226 · SHA-256 8b83021af90e…
const toolMaterialization = isLastStep ? undefined : yield* tools.materialize(agent.info?.permissions)
const promptCacheKey = /^ses_[0-9a-f]{64}$/.test(session.id) ? session.id.slice(4) : session.id
const request = LLM.request({
model,
http: {
headers: {
"x-opencode-session-id": session.id,
...(session.parentID ? { "x-opencode-parent-session-id": session.parentID } : {}),
"x-session-affinity": session.id,
"X-Session-Id": session.id,
...(session.parentID ? { "x-parent-session-id": session.parentID } : {}),
},
},
providerOptions: { openai: { promptCacheKey } },
system: [agent.info?.system, system.baseline]
.filter((part): part is string => part !== undefined && part.length > 0)
.map(SystemPart.make),
messages: [...toLLMMessages(context, model), ...(isLastStep ? [Message.assistant(MAX_STEPS_PROMPT)] : [])],
tools: toolMaterialization?.definitions ?? [],
toolChoice: isLastStep ? "none" : undefined,
})
if (yield* compaction.compactIfNeeded({ sessionID: session.id, entries, model, request }))
return yield* Effect.die(continueAfterCompaction(currentStep))
const startSnapshot = yield* snapshots.capture()
Native runtime
Default AI SDK and opt-in native runtime
Default path passes prepared headers/messages/tools/options and applies ProviderTransform.message through middleware before SDK serialization.
When: experimentalNativeLlm attempts native runtime; unsupported status falls back to AI SDK. Otherwise streamText is the default path.
Source: llm.ts lines 224–380 · SHA-256 cdb03e317e03…
// Runtime seam: native is an opt-in adapter over @opencode-ai/llm. It
// either returns a ready LLMEvent stream or a concrete fallback reason.
if (flags.experimentalNativeLlm) {
const native = LLMNativeRuntime.stream({
model: input.model,
provider: item,
auth: info,
llmClient,
messages: prepared.messages,
tools: prepared.tools,
toolChoice: input.toolChoice,
temperature: prepared.params.temperature,
topP: prepared.params.topP,
topK: prepared.params.topK,
maxOutputTokens: prepared.params.maxOutputTokens,
providerOptions: prepared.params.options,
headers: prepared.headers,
abort: input.abort,
})
if (native.type === "supported") {
yield* Effect.logInfo("llm runtime selected", {
"llm.runtime": "native",
"llm.provider": input.model.providerID,
"llm.model": input.model.id,
})
return {
type: "native" as const,
stream: native.stream,
}
}
yield* Effect.logInfo("llm runtime selected", {
"llm.runtime": "ai-sdk",
"llm.provider": input.model.providerID,
"llm.model": input.model.id,
"llm.native_unsupported_reason": native.reason,
})
yield* Effect.logInfo("native runtime unavailable; falling back to ai-sdk", {
providerID: input.model.providerID,
modelID: input.model.id,
"session.id": input.sessionID,
small: (input.small ?? false).toString(),
agent: input.agent.name,
mode: input.agent.mode,
reason: native.reason,
})
}
yield* Effect.logInfo("llm runtime selected", {
"llm.runtime": "ai-sdk",
"llm.provider": input.model.providerID,
"llm.model": input.model.id,
})
// Default runtime path: AI SDK owns provider execution and tool dispatch;
// LLMAISDK.toLLMEvents below normalizes fullStream parts for the processor.
return {
type: "ai-sdk" as const,
result: streamText({
onError(error) {
bridge.fork(
Effect.logError("stream error", {
providerID: input.model.providerID,
modelID: input.model.id,
"session.id": input.sessionID,
small: (input.small ?? false).toString(),
agent: input.agent.name,
mode: input.agent.mode,
error,
}),
)
},
// Copilot returns the authoritative billed amount only in provider-specific response fields.
includeRawChunks: input.model.providerID.includes("github-copilot"),
async experimental_repairToolCall(failed) {
const lower = failed.toolCall.toolName.toLowerCase()
if (lower !== failed.toolCall.toolName && prepared.tools[lower]) {
return {
...failed.toolCall,
toolName: lower,
}
}
return {
...failed.toolCall,
input: JSON.stringify({
tool: failed.toolCall.toolName,
error: failed.error.message,
}),
toolName: "invalid",
}
},
temperature: prepared.params.temperature,
topP: prepared.params.topP,
topK: prepared.params.topK,
providerOptions: ProviderTransform.providerOptions(input.model, prepared.params.options),
activeTools: Object.keys(prepared.tools).filter((x) => x !== "invalid"),
tools: prepared.tools,
toolChoice: input.toolChoice,
maxOutputTokens: prepared.params.maxOutputTokens,
abortSignal: input.abort,
headers: prepared.headers,
maxRetries: input.retries ?? 0,
messages: prepared.messages,
model: wrapLanguageModel({
model: language,
middleware: [
{
specificationVersion: "v3" as const,
async transformParams(args) {
if (args.type === "stream") {
// @ts-expect-error
args.params.prompt = ProviderTransform.message(
args.params.prompt,
input.model,
prepared.messageTransformOptions,
)
}
return args.params
},
},
],
}),
experimental_telemetry: {
isEnabled: cfg.experimental?.openTelemetry,
functionId: "session.llm",
tracer: telemetryTracer,
metadata: {
userId: cfg.username ?? "unknown",
sessionId: input.sessionID,
},
},
}),
}
})
const stream: Interface["stream"] = (input) =>
Stream.scoped(
Stream.unwrap(
Effect.gen(function* () {
const ctrl = yield* Effect.acquireRelease(
Effect.sync(() => new AbortController()),
(ctrl) => Effect.sync(() => ctrl.abort()),
)
const result = yield* run({ ...input, abort: ctrl.signal })
if (result.type === "native") return result.stream
// Adapter seam: both runtimes expose the same LLMEvent stream. Native
// already returns one; AI SDK streams are converted here.
const state = LLMAISDK.adapterState()
return Stream.fromAsyncIterable(result.result.fullStream, (e) =>
e instanceof Error ? e : new Error(String(e)),
).pipe(
Stream.mapEffect((event) => LLMAISDK.toLLMEvents(state, event)),
Stream.flatMap((events) => Stream.fromIterable(events)),
)
}),
),
Native runtime support gate
Accepts provider IDs openai, anthropic or starting opencode, with supported SDK packages and configured API key; OAuth additionally needs the OpenAI fetch override. OpenRouter/Alibaba/DeepSeek/Moonshot do not pass this provider-ID gate.
When: Only when experimentalNativeLlm is enabled and this status gate succeeds.
Source: native-runtime.ts lines 48–75 · SHA-256 bdf464f6558b…
}
function statusWithFetch(
input: Pick<StreamInput, "model" | "provider" | "auth">,
fetch: typeof globalThis.fetch | undefined,
): RuntimeStatus {
const providerID = input.model.providerID
if (providerID !== "openai" && providerID !== "anthropic" && !providerID.startsWith("opencode"))
return { type: "unsupported", reason: "provider is not openai, opencode, or anthropic" }
const npm = input.model.api.npm
if (npm !== "@ai-sdk/openai" && npm !== "@ai-sdk/openai-compatible" && npm !== "@ai-sdk/anthropic")
return { type: "unsupported", reason: "provider package is not OpenAI, OpenAI-compatible, or Anthropic" }
if (input.auth?.type === "oauth" && !(input.provider.id === "openai" && fetch)) {
return { type: "unsupported", reason: "OAuth auth requires a provider fetch override" }
}
const apiKey = typeof input.provider.options.apiKey === "string" ? input.provider.options.apiKey : input.provider.key
if (!apiKey) return { type: "unsupported", reason: "API key is not configured" }
return {
type: "supported",
apiKey,
baseURL: typeof input.provider.options.baseURL === "string" ? input.provider.options.baseURL : undefined,
}
}
export function stream(input: StreamInput): StreamResult {
const fetch = providerFetch(input)
Native request conversion and adapter selection
Converts system messages, text, media, reasoning, tools and provider metadata into canonical LLM requests; selects adapters by model.api.npm.
When: Native request adapter invoked only by a caller whose runtime support gate admits it; standalone package adapters are not proof of main CLI use.
Source: native-request.ts lines 1–196 · SHA-256 ee47e4430d7b…
import type { JsonSchema, LLMRequest, ProviderMetadata } from "@opencode-ai/llm"
import { LLM, Message, SystemPart, ToolCallPart, ToolDefinition, ToolResultPart } from "@opencode-ai/llm"
import {
AmazonBedrock,
Anthropic,
Azure,
Google,
OpenAI,
OpenAICompatible,
OpenRouter,
} from "@opencode-ai/llm/providers"
import type { ModelMessage } from "ai"
import type { Provider } from "@/provider/provider"
import { isRecord } from "@/util/record"
type ToolInput = {
readonly description?: string
readonly inputSchema?: unknown
}
export type RequestInput = {
readonly model: Provider.Model
readonly apiKey?: string
readonly baseURL?: string
readonly system?: readonly string[]
readonly messages: readonly ModelMessage[]
readonly tools?: Record<string, ToolInput>
readonly toolChoice?: "auto" | "required" | "none"
readonly temperature?: number
readonly topP?: number
readonly topK?: number
readonly maxOutputTokens?: number
readonly providerOptions?: LLMRequest["providerOptions"]
readonly headers?: Record<string, string>
}
const providerMetadata = (value: unknown): ProviderMetadata | undefined => {
if (!isRecord(value)) return undefined
const result = Object.fromEntries(
Object.entries(value).filter((entry): entry is [string, Record<string, unknown>] => isRecord(entry[1])),
)
return Object.keys(result).length === 0 ? undefined : result
}
// Stored AI SDK parts historically kept provider-owned continuation metadata in
// `providerOptions`; native parts now use `providerMetadata` directly.
const partProviderMetadata = (part: Record<string, unknown>) =>
providerMetadata(part.providerMetadata) ?? providerMetadata(part.providerOptions)
const textPart = (part: Record<string, unknown>) => ({
type: "text" as const,
text: typeof part.text === "string" ? part.text : "",
providerMetadata: partProviderMetadata(part),
})
const mediaPart = (part: Record<string, unknown>) => {
if (typeof part.data !== "string" && !(part.data instanceof Uint8Array))
throw new Error("Native LLM request adapter only supports file parts with string or Uint8Array data")
return {
type: "media" as const,
mediaType: typeof part.mediaType === "string" ? part.mediaType : "application/octet-stream",
data: part.data,
filename: typeof part.filename === "string" ? part.filename : undefined,
}
}
const toolResult = (part: Record<string, unknown>) => {
const output = isRecord(part.output) ? part.output : { type: "json", value: part.output }
const type = output.type === "text" ? "text" : output.type === "error-text" ? "error" : "json"
return ToolResultPart.make({
id: typeof part.toolCallId === "string" ? part.toolCallId : "",
name: typeof part.toolName === "string" ? part.toolName : "",
result: "value" in output ? output.value : output,
resultType: type,
providerExecuted: typeof part.providerExecuted === "boolean" ? part.providerExecuted : undefined,
providerMetadata: partProviderMetadata(part),
})
}
const contentPart = (part: unknown) => {
if (!isRecord(part)) throw new Error("Native LLM request adapter only supports object content parts")
if (part.type === "text") return textPart(part)
if (part.type === "file") return mediaPart(part)
if (part.type === "reasoning")
return {
type: "reasoning" as const,
text: typeof part.text === "string" ? part.text : "",
providerMetadata: partProviderMetadata(part),
}
if (part.type === "tool-call")
return ToolCallPart.make({
id: typeof part.toolCallId === "string" ? part.toolCallId : "",
name: typeof part.toolName === "string" ? part.toolName : "",
input: part.input,
providerExecuted: typeof part.providerExecuted === "boolean" ? part.providerExecuted : undefined,
providerMetadata: partProviderMetadata(part),
})
if (part.type === "tool-result") return toolResult(part)
throw new Error(`Native LLM request adapter does not support ${String(part.type)} content parts`)
}
const content = (value: ModelMessage["content"]) =>
typeof value === "string" ? [{ type: "text" as const, text: value }] : value.map(contentPart)
const messages = (input: readonly ModelMessage[]) => {
const system = input.flatMap((message) => (message.role === "system" ? [SystemPart.make(message.content)] : []))
const messages = input.flatMap((message) => {
if (message.role === "system") return []
return [
Message.make({
role: message.role,
content: content(message.content),
native: isRecord(message.providerOptions) ? { providerOptions: message.providerOptions } : undefined,
}),
]
})
return { system, messages }
}
const schema = (value: unknown): JsonSchema => {
if (!isRecord(value)) return { type: "object", properties: {} }
if (isRecord(value.jsonSchema)) return value.jsonSchema
return value
}
const tools = (input: Record<string, ToolInput> | undefined): ToolDefinition[] =>
Object.entries(input ?? {}).map(([name, item]) =>
ToolDefinition.make({
name,
description: item.description ?? "",
inputSchema: schema(item.inputSchema),
}),
)
const generation = (input: RequestInput) => {
const result = {
temperature: input.temperature,
topP: input.topP,
topK: input.topK,
maxTokens: input.maxOutputTokens,
}
return Object.values(result).some((value) => value !== undefined) ? result : undefined
}
const baseURL = (input: Provider.Model | RequestInput) =>
"model" in input ? (input.baseURL ?? (input.model.api.url || undefined)) : input.api.url || undefined
const requireBaseURL = (model: Provider.Model, url: string | undefined) => {
if (url) return url
throw new Error(`Native LLM request adapter requires a base URL for ${model.providerID}/${model.id}`)
}
export const model = (input: Provider.Model | RequestInput, headers?: Record<string, string>) => {
const model = "model" in input ? input.model : input
const url = baseURL(input)
const options = {
...("model" in input && input.apiKey ? { apiKey: input.apiKey } : {}),
...(url ? { baseURL: url } : {}),
headers: Object.keys({ ...model.headers, ...headers }).length === 0 ? undefined : { ...model.headers, ...headers },
limits: {
context: model.limit.context,
output: model.limit.output,
},
}
if (model.api.npm === "@ai-sdk/openai") return OpenAI.configure(options).responses(model.api.id)
if (model.api.npm === "@ai-sdk/azure")
return Azure.configure({ ...options, baseURL: requireBaseURL(model, url) }).responses(model.api.id)
if (model.api.npm === "@ai-sdk/anthropic") return Anthropic.configure(options).model(model.api.id)
if (model.api.npm === "@ai-sdk/google") return Google.configure(options).model(model.api.id)
if (model.api.npm === "@ai-sdk/amazon-bedrock") return AmazonBedrock.configure(options).model(model.api.id)
if (model.api.npm === "@ai-sdk/openai-compatible")
return OpenAICompatible.configure({
...options,
provider: String(model.providerID),
baseURL: requireBaseURL(model, url),
}).model(model.api.id)
if (model.api.npm === "@openrouter/ai-sdk-provider") return OpenRouter.configure(options).model(model.api.id)
throw new Error(`Native LLM request adapter does not support provider package ${model.api.npm}`)
}
export const request = (input: RequestInput) => {
const converted = messages(input.messages)
// This is the only native adapter boundary that should construct canonical
// @opencode-ai/llm request objects from opencode's session/AI SDK-shaped data.
return LLM.request({
model: model(input, input.headers),
system: [...(input.system ?? []).map(SystemPart.make), ...converted.system],
messages: converted.messages,
tools: tools(input.tools),
toolChoice: input.toolChoice,
generation: generation(input),
providerOptions: input.providerOptions,
})
}
export * as LLMNative from "./native-request"
Native reasoning summary deltas and opaque metadata
Summary deltas become visible reasoning events, while encrypted_content stays in provider metadata. Ordering is handled on a best-effort basis when events are unexpected.
When: Native OpenAI Responses stream receives reasoning summary and output-item events.
Source: openai-responses.ts lines 624–671 · SHA-256 9c7e9b950c3e…
const onReasoningDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
if (!event.delta) return [state, NO_EVENTS]
const events: LLMEvent[] = []
const itemID = event.item_id ?? "reasoning-0"
const id =
event.summary_index !== undefined || state.reasoningItems[itemID] ? `${itemID}:${event.summary_index ?? 0}` : itemID
return [
{
...state,
lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, id, event.delta),
},
events,
]
}
const onReasoningDone = (state: ParserState, _event: OpenAIResponsesEvent): StepResult => [state, NO_EVENTS]
const reasoningMetadata = (item: OpenAIResponsesStreamItem & { id: string }) =>
openaiMetadata({ itemId: item.id, reasoningEncryptedContent: item.encrypted_content ?? null })
// OpenAI Responses streams reasoning items in a stable order:
// `output_item.added` (reasoning) →
// `reasoning_summary_part.added` (index=0) →
// `reasoning_summary_text.delta` →
// `reasoning_summary_part.done` (index=0) →
// (repeat for index>0) →
// `output_item.done` (reasoning).
// The handlers below rely on this ordering: `onOutputItemAdded` seeds the
// per-item entry, `onReasoningSummaryPartAdded` for `summary_index === 0`
// short-circuits when the entry already exists, and higher-index handlers
// fold against the same entry. Behaviour for out-of-order events is
// best-effort, not guaranteed.
const onOutputItemAdded = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
const item = event.item
if (item && isReasoningItem(item)) {
const events: LLMEvent[] = []
return [
{
...state,
lifecycle: Lifecycle.reasoningStart(state.lifecycle, events, `${item.id}:0`, reasoningMetadata(item)),
reasoningItems: {
...state.reasoningItems,
[item.id]: { encryptedContent: item.encrypted_content, summaryParts: { 0: "active" } },
},
},
events,
]
}
Native JSON export boundary
Writes {info,messages}, where each message carries info and parts from session storage. This is stored session evidence, not the fully assembled request or exact system prompt.
When: User explicitly runs export for a real session; optional sanitize mode redacts transcript/file data.
Source: export.ts lines 222–292 · SHA-256 79e3bd1625f3…
export const ExportCommand = effectCmd({
command: "export [sessionID]",
describe: "export session data as JSON",
builder: (yargs) =>
yargs
.positional("sessionID", {
describe: "session id to export",
type: "string",
})
.option("sanitize", {
describe: "redact sensitive transcript and file data",
type: "boolean",
}),
handler: Effect.fn("Cli.export")(function* (args) {
return yield* run(args)
}),
})
const run = Effect.fn("Cli.export.body")(function* (args: { sessionID?: string; sanitize?: boolean }) {
const svc = yield* Session.Service
let sessionID = args.sessionID ? SessionID.make(args.sessionID) : undefined
process.stderr.write(`Exporting session: ${sessionID ?? "latest"}\n`)
if (!sessionID) {
UI.empty()
prompts.intro("Export session", { output: process.stderr })
const sessions = yield* svc.list()
if (sessions.length === 0) {
prompts.log.error("No sessions found", { output: process.stderr })
prompts.outro("Done", { output: process.stderr })
return
}
sessions.sort((a, b) => b.time.updated - a.time.updated)
const selectedSession = yield* Effect.promise(() =>
prompts.autocomplete({
message: "Select session to export",
maxItems: 10,
options: sessions.map((session) => ({
label: session.title,
value: session.id,
hint: `${new Date(session.time.updated).toLocaleString()} • ${session.id.slice(-8)}`,
})),
output: process.stderr,
}),
)
if (prompts.isCancel(selectedSession)) {
return yield* Effect.die(new UI.CancelledError())
}
sessionID = selectedSession
prompts.outro("Exporting session...", { output: process.stderr })
}
// Match legacy try/catch — catches both typed failures and defects
// (Session.Service.get throws NotFoundError as a defect, not a typed E).
return yield* Effect.gen(function* () {
const sessionInfo = yield* svc.get(sessionID!)
const messages = yield* svc.messages({ sessionID: sessionInfo.id })
const exportData = { info: sessionInfo, messages }
process.stdout.write(JSON.stringify(args.sanitize ? sanitize(exportData) : exportData, null, 2))
process.stdout.write(EOL)
}).pipe(Effect.catchCause(() => fail(`Session not found: ${sessionID!}`)))
})
Native package OpenRouter protocol
Uses OpenAI Chat stream protocol at /chat/completions, adding usage/reasoning/prompt-cache body options. Default host comes from the native profile. It does not establish an observed serving provider or downstream hop.
When: Standalone native package OpenRouter route is selected; main CLI native runtime gate restricts provider IDs separately.
Source: openrouter.ts lines 1–98 · SHA-256 d036cfd496e0…
import { Effect, Schema } from "effect"
import { Route, type RouteDefaultsInput } from "../route/client"
import { Endpoint } from "../route/endpoint"
import { Framing } from "../route/framing"
import { Protocol } from "../route/protocol"
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options"
import { ProviderID, type ModelID, type ProviderOptions } from "../schema"
import * as OpenAICompatibleProfiles from "./openai-compatible-profile"
import * as OpenAIChat from "../protocols/openai-chat"
import { isRecord } from "../protocols/shared"
export const profile = OpenAICompatibleProfiles.profiles.openrouter
export const id = ProviderID.make(profile.provider)
const ADAPTER = "openrouter"
export interface OpenRouterOptions {
readonly [key: string]: unknown
readonly usage?: boolean | Record<string, unknown>
readonly reasoning?: Record<string, unknown>
readonly promptCacheKey?: string
}
export type OpenRouterProviderOptionsInput = ProviderOptions & {
readonly openrouter?: OpenRouterOptions
}
export type ModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
ProviderAuthOption<"optional"> & {
readonly baseURL?: string
readonly providerOptions?: OpenRouterProviderOptionsInput
}
const OpenRouterBody = Schema.StructWithRest(Schema.Struct(OpenAIChat.bodyFields), [
Schema.Record(Schema.String, Schema.Any),
])
export type OpenRouterBody = Schema.Schema.Type<typeof OpenRouterBody>
export const protocol = Protocol.make({
id: "openrouter-chat",
body: {
schema: OpenRouterBody,
from: (request) =>
OpenAIChat.protocol.body.from(request).pipe(
Effect.map(
(body) =>
({
...body,
...bodyOptions(request.providerOptions?.openrouter),
}) as OpenRouterBody,
),
),
},
stream: OpenAIChat.protocol.stream,
})
const bodyOptions = (input: unknown) => {
const openrouter = isRecord(input) ? input : {}
return {
...(openrouter.usage === true
? { usage: { include: true } }
: isRecord(openrouter.usage)
? { usage: openrouter.usage }
: {}),
...(isRecord(openrouter.reasoning) ? { reasoning: openrouter.reasoning } : {}),
...(typeof openrouter.promptCacheKey === "string" ? { prompt_cache_key: openrouter.promptCacheKey } : {}),
}
}
export const route = Route.make({
id: ADAPTER,
provider: profile.provider,
protocol,
endpoint: Endpoint.path("/chat/completions", { baseURL: profile.baseURL }),
framing: Framing.sse,
})
export const routes = [route]
const configuredRoute = (input: ModelOptions) => {
const { apiKey: _, auth: _auth, baseURL, ...rest } = input
return route.with({
...rest,
endpoint: { baseURL: baseURL ?? profile.baseURL },
auth: AuthOptions.bearer(input, "OPENROUTER_API_KEY"),
})
}
export const configure = (input: ModelOptions = {}) => {
const route = configuredRoute(input)
return {
id,
model: (modelID: string | ModelID) => route.model({ id: modelID }),
configure,
}
}
export const provider = configure()
export const model = provider.model
Native OpenAI-compatible endpoint profiles
Includes openrouter.ai/api/v1 and api.deepseek.com/v1. Main CLI endpoint defaults may instead come from the dynamic catalog or config.
When: Native package provider helpers use their default profile unless baseURL is overridden.
Source: openai-compatible-profile.ts lines 1–20 · SHA-256 7c4f115fb328…
export interface OpenAICompatibleProfile {
readonly provider: string
readonly baseURL: string
}
export const profiles = {
baseten: { provider: "baseten", baseURL: "https://inference.baseten.co/v1" },
cerebras: { provider: "cerebras", baseURL: "https://api.cerebras.ai/v1" },
deepinfra: { provider: "deepinfra", baseURL: "https://api.deepinfra.com/v1/openai" },
deepseek: { provider: "deepseek", baseURL: "https://api.deepseek.com/v1" },
fireworks: { provider: "fireworks", baseURL: "https://api.fireworks.ai/inference/v1" },
groq: { provider: "groq", baseURL: "https://api.groq.com/openai/v1" },
openrouter: { provider: "openrouter", baseURL: "https://openrouter.ai/api/v1" },
togetherai: { provider: "togetherai", baseURL: "https://api.together.xyz/v1" },
xai: { provider: "xai", baseURL: "https://api.x.ai/v1" },
} as const satisfies Record<string, OpenAICompatibleProfile>
export const byProvider: Record<string, OpenAICompatibleProfile> = Object.fromEntries(
Object.values(profiles).map((profile) => [profile.provider, profile]),
)
Native Chat request and response fields
Source schema separates system/user/assistant/tool content, tool function schemas, reasoning_content, usage.reasoning_tokens and finish_reason. This native schema is not proof of the default external SDK wire format.
When: Native OpenAI Chat-compatible protocol serializes a canonical request and decodes SSE events.
Source: openai-chat.ts lines 34–160 · SHA-256 e3c220427107…
// The body schema is the provider-native JSON body. `fromRequest` below builds
// this shape from the common `LLMRequest`, then `Route.make` validates and
// JSON-encodes it before transport.
const OpenAIChatFunction = Schema.Struct({
name: Schema.String,
description: Schema.String,
parameters: JsonObject,
})
const OpenAIChatTool = Schema.Struct({
type: Schema.tag("function"),
function: OpenAIChatFunction,
})
type OpenAIChatTool = Schema.Schema.Type<typeof OpenAIChatTool>
const OpenAIChatAssistantToolCall = Schema.Struct({
id: Schema.String,
type: Schema.tag("function"),
function: Schema.Struct({
name: Schema.String,
arguments: Schema.String,
}),
})
type OpenAIChatAssistantToolCall = Schema.Schema.Type<typeof OpenAIChatAssistantToolCall>
const OpenAIChatUserContent = Schema.Union([
Schema.Struct({ type: Schema.Literal("text"), text: Schema.String }),
Schema.Struct({
type: Schema.Literal("image_url"),
image_url: Schema.Struct({ url: Schema.String }),
}),
])
const OpenAIChatMessage = Schema.Union([
Schema.Struct({ role: Schema.Literal("system"), content: Schema.String }),
Schema.Struct({
role: Schema.Literal("user"),
content: Schema.Union([Schema.String, Schema.Array(OpenAIChatUserContent)]),
}),
Schema.Struct({
role: Schema.Literal("assistant"),
content: Schema.NullOr(Schema.String),
tool_calls: optionalArray(OpenAIChatAssistantToolCall),
reasoning_content: Schema.optional(Schema.String),
}),
Schema.Struct({ role: Schema.Literal("tool"), tool_call_id: Schema.String, content: Schema.String }),
]).pipe(Schema.toTaggedUnion("role"))
type OpenAIChatMessage = Schema.Schema.Type<typeof OpenAIChatMessage>
const OpenAIChatToolChoice = Schema.Union([
Schema.Literals(["auto", "none", "required"]),
Schema.Struct({
type: Schema.tag("function"),
function: Schema.Struct({ name: Schema.String }),
}),
])
export const bodyFields = {
model: Schema.String,
messages: Schema.Array(OpenAIChatMessage),
tools: optionalArray(OpenAIChatTool),
tool_choice: Schema.optional(OpenAIChatToolChoice),
stream: Schema.Literal(true),
stream_options: Schema.optional(Schema.Struct({ include_usage: Schema.Boolean })),
store: Schema.optional(Schema.Boolean),
reasoning_effort: Schema.optional(OpenAIOptions.OpenAIReasoningEffort),
max_tokens: Schema.optional(Schema.Number),
temperature: Schema.optional(Schema.Number),
top_p: Schema.optional(Schema.Number),
frequency_penalty: Schema.optional(Schema.Number),
presence_penalty: Schema.optional(Schema.Number),
seed: Schema.optional(Schema.Number),
stop: optionalArray(Schema.String),
}
const OpenAIChatBody = Schema.Struct(bodyFields)
export type OpenAIChatBody = Schema.Schema.Type<typeof OpenAIChatBody>
// =============================================================================
// Streaming Event Schema
// =============================================================================
// The event schema is one decoded SSE `data:` payload. `Framing.sse` splits the
// byte stream into strings, then `Protocol.jsonEvent` decodes each string into
// this provider-native event shape.
const OpenAIChatUsage = Schema.Struct({
prompt_tokens: Schema.optional(Schema.Number),
completion_tokens: Schema.optional(Schema.Number),
total_tokens: Schema.optional(Schema.Number),
prompt_tokens_details: optionalNull(
Schema.Struct({
cached_tokens: Schema.optional(Schema.Number),
}),
),
completion_tokens_details: optionalNull(
Schema.Struct({
reasoning_tokens: Schema.optional(Schema.Number),
}),
),
})
const OpenAIChatToolCallDeltaFunction = Schema.Struct({
name: optionalNull(Schema.String),
arguments: optionalNull(Schema.String),
})
const OpenAIChatToolCallDelta = Schema.Struct({
index: Schema.Number,
id: optionalNull(Schema.String),
function: optionalNull(OpenAIChatToolCallDeltaFunction),
})
type OpenAIChatToolCallDelta = Schema.Schema.Type<typeof OpenAIChatToolCallDelta>
const OpenAIChatDelta = Schema.Struct({
content: optionalNull(Schema.String),
reasoning_content: optionalNull(Schema.String),
tool_calls: optionalNull(Schema.Array(OpenAIChatToolCallDelta)),
})
const OpenAIChatChoice = Schema.Struct({
delta: optionalNull(OpenAIChatDelta),
finish_reason: optionalNull(Schema.String),
})
const OpenAIChatEvent = Schema.Struct({
choices: Schema.Array(OpenAIChatChoice),
usage: optionalNull(OpenAIChatUsage),
})
type OpenAIChatEvent = Schema.Schema.Type<typeof OpenAIChatEvent>
Providers, endpoints and catalogs
Bundled provider SDK factories
Includes OpenRouter, Alibaba and OpenAI-compatible SDK factories. SDK dependency versions are pinned in packages/opencode/package.json, separately from installed CLI identity.
When: Provider.resolveSDK selects factory using the configured or catalog-derived model.api.npm.
Source: provider.ts lines 144–175 · SHA-256 9d924c84e74b…
chat?: (modelId: string) => LanguageModelV3
responses?: (modelId: string) => LanguageModelV3
}
const BUNDLED_PROVIDERS: Record<string, () => Promise<(opts: any) => BundledSDK>> = {
"@ai-sdk/amazon-bedrock": () => import("@ai-sdk/amazon-bedrock").then((m) => m.createAmazonBedrock),
"@ai-sdk/amazon-bedrock/mantle": () => import("@ai-sdk/amazon-bedrock/mantle").then((m) => m.createBedrockMantle),
"@ai-sdk/anthropic": () => import("@ai-sdk/anthropic").then((m) => m.createAnthropic),
"@ai-sdk/azure": () => import("@ai-sdk/azure").then((m) => m.createAzure),
"@ai-sdk/google": () => import("@ai-sdk/google").then((m) => m.createGoogleGenerativeAI),
"@ai-sdk/google-vertex": () => import("@ai-sdk/google-vertex").then((m) => m.createVertex),
"@ai-sdk/google-vertex/anthropic": () =>
import("@ai-sdk/google-vertex/anthropic").then((m) => m.createVertexAnthropic),
"@ai-sdk/openai": () => import("@ai-sdk/openai").then((m) => m.createOpenAI),
"@ai-sdk/openai-compatible": () => import("@ai-sdk/openai-compatible").then((m) => m.createOpenAICompatible),
"@openrouter/ai-sdk-provider": () => import("@openrouter/ai-sdk-provider").then((m) => m.createOpenRouter),
"@ai-sdk/xai": () => import("@ai-sdk/xai").then((m) => m.createXai),
"@ai-sdk/mistral": () => import("@ai-sdk/mistral").then((m) => m.createMistral),
"@ai-sdk/groq": () => import("@ai-sdk/groq").then((m) => m.createGroq),
"@ai-sdk/deepinfra": () => import("@ai-sdk/deepinfra").then((m) => m.createDeepInfra),
"@ai-sdk/cerebras": () => import("@ai-sdk/cerebras").then((m) => m.createCerebras),
"@ai-sdk/cohere": () => import("@ai-sdk/cohere").then((m) => m.createCohere),
"@ai-sdk/gateway": () => import("@ai-sdk/gateway").then((m) => m.createGateway),
"@ai-sdk/togetherai": () => import("@ai-sdk/togetherai").then((m) => m.createTogetherAI),
"@ai-sdk/perplexity": () => import("@ai-sdk/perplexity").then((m) => m.createPerplexity),
"@ai-sdk/vercel": () => import("@ai-sdk/vercel").then((m) => m.createVercel),
"@ai-sdk/alibaba": () => import("@ai-sdk/alibaba").then((m) => m.createAlibaba),
"gitlab-ai-provider": () => import("gitlab-ai-provider").then((m) => m.createGitLab),
"@ai-sdk/github-copilot": () =>
import("@opencode-ai/core/github-copilot/copilot-provider").then((m) => m.createOpenaiCompatible),
"venice-ai-sdk-provider": () => import("venice-ai-sdk-provider").then((m) => m.createVenice),
}
OpenRouter attribution headers
Sets HTTP-Referer and X-Title for the client. These headers do not identify an upstream serving provider.
When: OpenRouter custom provider loader is used and headers are not overridden downstream.
Source: provider.ts lines 513–524 · SHA-256 bd0dd66aa81d…
openrouter: () =>
Effect.succeed({
autoload: false,
options: {
headers: {
"HTTP-Referer": "https://opencode.ai/",
"X-Title": "opencode",
},
},
}),
nvidia: (provider) =>
Effect.succeed({
Model endpoint, capabilities and options
api.id, npm package and URL have separate precedence. Interleaved reasoning can be configured; a new OpenAI-compatible DeepSeek model defaults to reasoning_content. Model options and headers are merged, and configured variants can disable defaults.
When: Config-defined model is merged with existing/catalog defaults.
Source: provider.ts lines 1542–1631 · SHA-256 6e5dc45e13ce…
}
for (const [modelID, model] of Object.entries(provider.models ?? {})) {
const existingModel = parsed.models[model.id ?? modelID]
const apiID = model.id ?? existingModel?.api.id ?? modelID
const apiNpm =
model.provider?.npm ??
provider.npm ??
existingModel?.api.npm ??
// Config-defined gateway models bypass fromModelsDevModel, so resolve the
// native passthrough npm here before falling back to the catalog default.
cloudflareGatewayNpm(providerID, apiID) ??
modelsDev[providerID]?.npm ??
"@ai-sdk/openai-compatible"
const name = iife(() => {
if (model.name) return model.name
if (model.id && model.id !== modelID) return modelID
return existingModel?.name ?? modelID
})
const parsedModel: Model = {
id: ModelV2.ID.make(modelID),
api: {
id: apiID,
npm: apiNpm,
url: model.provider?.api ?? provider?.api ?? existingModel?.api.url ?? modelsDev[providerID]?.api ?? "",
},
status: model.status ?? existingModel?.status ?? "active",
name,
providerID: ProviderV2.ID.make(providerID),
capabilities: {
temperature: model.temperature ?? existingModel?.capabilities.temperature ?? false,
reasoning: model.reasoning ?? existingModel?.capabilities.reasoning ?? false,
attachment: model.attachment ?? existingModel?.capabilities.attachment ?? false,
toolcall: model.tool_call ?? existingModel?.capabilities.toolcall ?? true,
input: {
text: model.modalities?.input?.includes("text") ?? existingModel?.capabilities.input.text ?? true,
audio: model.modalities?.input?.includes("audio") ?? existingModel?.capabilities.input.audio ?? false,
image: model.modalities?.input?.includes("image") ?? existingModel?.capabilities.input.image ?? false,
video: model.modalities?.input?.includes("video") ?? existingModel?.capabilities.input.video ?? false,
pdf: model.modalities?.input?.includes("pdf") ?? existingModel?.capabilities.input.pdf ?? false,
},
output: {
text: model.modalities?.output?.includes("text") ?? existingModel?.capabilities.output.text ?? true,
audio:
model.modalities?.output?.includes("audio") ?? existingModel?.capabilities.output.audio ?? false,
image:
model.modalities?.output?.includes("image") ?? existingModel?.capabilities.output.image ?? false,
video:
model.modalities?.output?.includes("video") ?? existingModel?.capabilities.output.video ?? false,
pdf: model.modalities?.output?.includes("pdf") ?? existingModel?.capabilities.output.pdf ?? false,
},
interleaved:
(typeof model.interleaved === "string" ? { field: model.interleaved } : model.interleaved) ??
existingModel?.capabilities.interleaved ??
(!existingModel && apiNpm === "@ai-sdk/openai-compatible" && apiID.includes("deepseek")
? { field: "reasoning_content" }
: false),
},
cost: {
input: model?.cost?.input ?? existingModel?.cost?.input ?? 0,
output: model?.cost?.output ?? existingModel?.cost?.output ?? 0,
cache: {
read: model?.cost?.cache_read ?? existingModel?.cost?.cache.read ?? 0,
write: model?.cost?.cache_write ?? existingModel?.cost?.cache.write ?? 0,
},
},
options: mergeDeep(existingModel?.options ?? {}, model.options ?? {}),
limit: {
context: model.limit?.context ?? existingModel?.limit?.context ?? 0,
input: model.limit?.input ?? existingModel?.limit?.input,
output: model.limit?.output ?? existingModel?.limit?.output ?? 0,
},
headers: mergeDeep(existingModel?.headers ?? {}, model.headers ?? {}),
family: model.family ?? existingModel?.family ?? "",
release_date: model.release_date ?? existingModel?.release_date ?? "",
variants: {},
}
const variants =
existingModel?.api.npm === parsedModel.api.npm
? (existingModel.variants ?? ProviderTransform.variants(parsedModel))
: ProviderTransform.variants(parsedModel)
const merged = mergeDeep(variants, model.variants ?? {})
parsedModel.variants = mapValues(
pickBy(merged, (v) => !v.disabled),
(v) => omit(v, ["disabled"]),
)
parsed.models[modelID] = parsedModel
}
database[providerID] = parsed
}
Resolved SDK endpoint and fetch layer
Nonempty provider.options.baseURL takes precedence over model.api.url, then configured/environment substitutions apply. Provider credentials and model headers are merged before the timeout-aware fetch wrapper. Actual captured host remains the evidence of client destination.
When: resolveSDK loads the selected provider package.
Source: provider.ts lines 1783–1864 · SHA-256 25f9ae85b705…
const list = Effect.fn("Provider.list")(() => InstanceState.use(state, (s) => s.providers))
async function resolveSDK(model: Model, s: State, envs: Record<string, string | undefined>) {
try {
const provider = s.providers[model.providerID]
const options = { ...provider.options }
if (
model.providerID === "google-vertex" &&
model.api.npm === "@ai-sdk/google-vertex/anthropic" &&
!options.baseURL
) {
const baseURL = googleVertexAnthropicBaseURL(
typeof options.project === "string" ? options.project : undefined,
typeof options.location === "string" ? options.location : undefined,
)
if (baseURL) options.baseURL = baseURL
}
if (model.providerID === "google-vertex" && !model.api.npm.includes("@ai-sdk/openai-compatible")) {
delete options.fetch
}
if (model.api.npm.includes("@ai-sdk/openai-compatible") && options["includeUsage"] !== false) {
options["includeUsage"] = true
}
const baseURL = iife(() => {
let url =
typeof options["baseURL"] === "string" && options["baseURL"] !== "" ? options["baseURL"] : model.api.url
if (!url) return
const loader = s.varsLoaders[model.providerID]
if (loader) {
const vars = loader(options)
for (const [key, value] of Object.entries(vars)) {
const field = "${" + key + "}"
url = url.replaceAll(field, value)
}
}
url = url.replace(/\$\{([^}]+)\}/g, (item, key) => {
const val = envs[String(key)]
return val ?? item
})
return url
})
if (baseURL !== undefined) options["baseURL"] = baseURL
if (options["apiKey"] === undefined && provider.key) options["apiKey"] = provider.key
if (model.headers)
options["headers"] = {
...options["headers"],
...model.headers,
}
const key = Hash.fast(
JSON.stringify({
providerID: model.providerID,
npm: model.api.npm,
options,
}),
)
const existing = s.sdk.get(key)
if (existing) return existing
options["fetch"] = timeoutFetch(options)
delete options["chunkTimeout"]
delete options["headerTimeout"]
const bundledLoader = BUNDLED_PROVIDERS[model.api.npm]
if (bundledLoader) {
const factory = await bundledLoader()
const loaded = factory({
name: model.providerID,
...options,
})
s.sdk.set(key, loaded)
return loaded as SDK
}
const installedPath = await (async () => {
Runtime model catalog source
Catalog URL defaults to models.opencode.ai and can be overridden. Provider endpoints/capabilities from a live catalog are not frozen by this source pin and are not copied into this inventory.
When: ModelsDev loads cache/OPENCODE_MODELS_PATH, build-injected snapshot, or network fetch when enabled; periodic refresh is conditional.
Source: models-dev.ts lines 1–266 · SHA-256 f6e11d21709b…
import path from "path"
import { Context, Duration, Effect, Layer, Option, Schedule, Schema } from "effect"
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http"
import { ModelsDev } from "@opencode-ai/schema/models-dev"
import { Global } from "./global"
import { Flag } from "./flag/flag"
import { Flock } from "./util/flock"
import { Hash } from "./util/hash"
import { FSUtil } from "./fs-util"
import { InstallationChannel, InstallationVersion } from "./installation/version"
import { EventV2 } from "./event"
import { makeGlobalNode } from "./effect/app-node"
import { httpClient } from "./effect/app-node-platform"
export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"])
export type CatalogModelStatus = typeof CatalogModelStatus.Type
const InterleavedField = Schema.Union([
Schema.Literals(["reasoning", "reasoning_content", "reasoning_text"]),
Schema.String,
])
const USER_AGENT = `opencode/${InstallationChannel}/${InstallationVersion}/${Flag.OPENCODE_CLIENT}`
const CostTier = Schema.Struct({
input: Schema.Finite,
output: Schema.Finite,
cache_read: Schema.optional(Schema.Finite),
cache_write: Schema.optional(Schema.Finite),
tier: Schema.Struct({
type: Schema.Literal("context"),
size: Schema.Finite,
}),
})
const Cost = Schema.Struct({
input: Schema.Finite,
output: Schema.Finite,
cache_read: Schema.optional(Schema.Finite),
cache_write: Schema.optional(Schema.Finite),
tiers: Schema.optional(Schema.Array(CostTier)),
context_over_200k: Schema.optional(
Schema.Struct({
input: Schema.Finite,
output: Schema.Finite,
cache_read: Schema.optional(Schema.Finite),
cache_write: Schema.optional(Schema.Finite),
}),
),
})
const ReasoningOption = Schema.Union([
Schema.Struct({
type: Schema.Literal("effort"),
values: Schema.Array(Schema.NullOr(Schema.String)),
}),
Schema.Struct({
type: Schema.Literal("toggle"),
}),
Schema.Struct({
type: Schema.Literal("budget_tokens"),
min: Schema.optional(Schema.Finite),
max: Schema.optional(Schema.Finite),
}),
])
export const Model = Schema.Struct({
id: Schema.String,
name: Schema.String,
family: Schema.optional(Schema.String),
release_date: Schema.String,
attachment: Schema.Boolean,
reasoning: Schema.Boolean,
temperature: Schema.Boolean,
tool_call: Schema.Boolean,
reasoning_options: Schema.optional(Schema.Array(ReasoningOption)),
interleaved: Schema.optional(
Schema.Union([
Schema.Boolean,
InterleavedField,
Schema.Struct({
field: InterleavedField,
}),
]),
),
cost: Schema.optional(Cost),
limit: Schema.Struct({
context: Schema.Finite,
input: Schema.optional(Schema.Finite),
output: Schema.Finite,
}),
modalities: Schema.optional(
Schema.Struct({
input: Schema.Array(Schema.Literals(["text", "audio", "image", "video", "pdf"])),
output: Schema.Array(Schema.Literals(["text", "audio", "image", "video", "pdf"])),
}),
),
experimental: Schema.optional(
Schema.Struct({
modes: Schema.optional(
Schema.Record(
Schema.String,
Schema.Struct({
cost: Schema.optional(Cost),
provider: Schema.optional(
Schema.Struct({
body: Schema.optional(Schema.Record(Schema.String, Schema.MutableJson)),
headers: Schema.optional(Schema.Record(Schema.String, Schema.String)),
}),
),
}),
),
),
}),
),
status: Schema.optional(CatalogModelStatus),
provider: Schema.optional(
Schema.Struct({ npm: Schema.optional(Schema.String), api: Schema.optional(Schema.String) }),
),
})
export type Model = Schema.Schema.Type<typeof Model>
export const Provider = Schema.Struct({
api: Schema.optional(Schema.String),
name: Schema.String,
env: Schema.Array(Schema.String),
id: Schema.String,
npm: Schema.optional(Schema.String),
models: Schema.Record(Schema.String, Model),
})
export type Provider = Schema.Schema.Type<typeof Provider>
export const Event = ModelsDev.Event
declare const OPENCODE_MODELS_DEV: Record<string, Provider> | undefined
export interface Interface {
readonly get: () => Effect.Effect<Record<string, Provider>>
readonly refresh: (force?: boolean) => Effect.Effect<void>
}
export class Service extends Context.Service<Service, Interface>()("@opencode/ModelsDev") {}
const layer = Layer.effect(
Service,
Effect.gen(function* () {
const fs = yield* FSUtil.Service
const events = yield* EventV2.Service
const http = HttpClient.filterStatusOk(
(yield* HttpClient.HttpClient).pipe(
HttpClient.retryTransient({
retryOn: "errors-and-responses",
times: 2,
schedule: Schedule.exponential(200).pipe(Schedule.jittered),
}),
),
)
const source = Flag.OPENCODE_MODELS_URL || "https://models.opencode.ai"
const filepath = path.join(
Global.Path.cache,
source === "https://models.opencode.ai" ? "models.json" : `models-${Hash.fast(source)}.json`,
)
const ttl = Duration.minutes(5)
const lockKey = `models-dev:${filepath}`
const fresh = Effect.fnUntraced(function* () {
const stat = yield* fs.stat(filepath).pipe(Effect.catch(() => Effect.succeed(undefined)))
if (!stat) return false
const mtime = Option.getOrElse(stat.mtime, () => new Date(0)).getTime()
return Date.now() - mtime < Duration.toMillis(ttl)
})
const fetchApi = Effect.fn("ModelsDev.fetchApi")(function* () {
return yield* HttpClientRequest.get(`${source}/api.json`).pipe(
HttpClientRequest.setHeader("User-Agent", USER_AGENT),
http.execute,
Effect.flatMap((res) => res.text),
Effect.timeout("10 seconds"),
)
})
const loadFromDisk = fs.readJson(Flag.OPENCODE_MODELS_PATH ?? filepath).pipe(
Effect.catch((error) => {
if (
Flag.OPENCODE_MODELS_PATH === undefined &&
error._tag === "FileSystemError" &&
error.method === "readJson"
) {
return fs.remove(filepath, { force: true }).pipe(Effect.ignore, Effect.as(undefined))
}
return Effect.succeed(undefined)
}),
Effect.map((v) => v as Record<string, Provider> | undefined),
)
const loadSnapshot = Effect.sync(() =>
typeof OPENCODE_MODELS_DEV === "undefined" ? undefined : OPENCODE_MODELS_DEV,
)
const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () {
const text = yield* fetchApi()
const tempfile = `${filepath}.${process.pid}.${Date.now()}.tmp`
yield* fs.writeWithDirs(tempfile, text).pipe(
Effect.andThen(fs.rename(tempfile, filepath)),
Effect.catch((error) =>
Effect.gen(function* () {
yield* fs.remove(tempfile, { force: true }).pipe(Effect.ignore)
return yield* Effect.fail(error)
}),
),
)
return text
})
const populate = Effect.gen(function* () {
const fromDisk = yield* loadFromDisk
if (fromDisk) return fromDisk
const snapshot = yield* loadSnapshot
if (snapshot) return snapshot
if (Flag.OPENCODE_DISABLE_MODELS_FETCH) return {}
// Flock is cross-process: concurrent opencode CLIs can race on this cache file.
const text = yield* Effect.scoped(
Effect.gen(function* () {
yield* Flock.effect(lockKey)
return yield* fetchAndWrite()
}),
)
return JSON.parse(text) as Record<string, Provider>
}).pipe(Effect.withSpan("ModelsDev.populate"), Effect.orDie)
const [cachedGet, invalidate] = yield* Effect.cachedInvalidateWithTTL(populate, Duration.infinity)
const get = (): Effect.Effect<Record<string, Provider>> => cachedGet
const refresh = Effect.fn("ModelsDev.refresh")(function* (force = false) {
if (!force && (yield* fresh())) return
yield* Effect.scoped(
Effect.gen(function* () {
yield* Flock.effect(lockKey)
// Re-check under the lock: another process may have refreshed between
// our outer check and lock acquisition.
if (!force && (yield* fresh())) return
yield* fetchAndWrite()
yield* invalidate
yield* events.publish(Event.Refreshed, {})
}),
).pipe(
Effect.tapCause((cause) => Effect.logError("Failed to fetch models.dev", { cause: cause })),
Effect.ignore,
)
})
if (!Flag.OPENCODE_DISABLE_MODELS_FETCH && !process.argv.includes("--get-yargs-completions")) {
// Schedule.spaced runs the effect once, then waits between completions.
yield* Effect.forkScoped(refresh().pipe(Effect.repeat(Schedule.spaced("60 minutes")), Effect.ignore))
}
return Service.of({ get, refresh })
}),
)
export const node = makeGlobalNode({ service: Service, layer: layer, deps: [FSUtil.node, EventV2.node, httpClient] })
export * as ModelsDev from "./models-dev"
Provider option namespaces
Maps package to expected namespace; Azure receives openai and azure; gateway options split routing controls from model publisher options. Provider IDs and model publishers are distinct.
When: ProviderTransform.providerOptions is called before SDK execution.
Source: transform.ts lines 1421–1483 · SHA-256 f2988163dc58…
export function providerOptions(model: Provider.Model, options: { [x: string]: any }) {
const usesOpenAIReasoningGate =
model.api.npm === "@ai-sdk/openai" ||
model.api.npm === "@ai-sdk/azure" ||
model.api.npm === "@ai-sdk/amazon-bedrock/mantle"
const normalized =
usesOpenAIReasoningGate &&
(model.capabilities.reasoning || options.reasoningEffort !== undefined || options.reasoningSummary !== undefined)
? { ...options, forceReasoning: true }
: anthropicBlockBinding(model, options)
if (model.api.npm === "@ai-sdk/gateway") {
// Gateway providerOptions are split across two namespaces:
// - `gateway`: gateway-native routing/caching controls (order, only, byok, etc.)
// - `<upstream slug>`: provider-specific model options (anthropic/openai/...)
// We keep `gateway` as-is and route every other top-level option under the
// model-derived upstream slug.
const i = model.api.id.indexOf("/")
const rawSlug = i > 0 ? model.api.id.slice(0, i) : undefined
const slug = rawSlug ? (SLUG_OVERRIDES[rawSlug] ?? rawSlug) : undefined
const gateway = normalized.gateway
const rest = Object.fromEntries(Object.entries(normalized).filter(([k]) => k !== "gateway"))
const has = Object.keys(rest).length > 0
const result: Record<string, any> = {}
if (gateway !== undefined) result.gateway = gateway
if (has) {
if (slug) {
// Route model-specific options under the provider slug
result[slug] = rest
} else if (gateway && typeof gateway === "object" && !Array.isArray(gateway)) {
result.gateway = { ...gateway, ...rest }
} else {
result.gateway = rest
}
}
return result
}
// AI SDK packages that resolve providerOptionsName by splitting the
// provider name on "." (e.g. "wafer.ai" -> "wafer") need the same
// logic here so the key we write matches the key they read.
// Other SDKs (xai, mistral, groq, cohere, etc.) use hardcoded keys
// like "xai" or "cohere" - applying .split(".")[0] would break those.
const usesDotSplitOptions =
model.api.npm === "@ai-sdk/openai-compatible" ||
model.api.npm === "@ai-sdk/openai" ||
model.api.npm === "@ai-sdk/anthropic"
const key = sdkKey(model.api.npm) ?? (usesDotSplitOptions ? model.providerID.split(".")[0] : model.providerID)
// @ai-sdk/azure delegates to OpenAIChatLanguageModel which reads from
// providerOptions["openai"], but OpenAIResponsesLanguageModel checks
// "azure" first. Pass both so model options work on either code path.
if (model.api.npm === "@ai-sdk/azure") {
return { openai: normalized, azure: normalized }
}
return { [key]: normalized }
}
export function maxOutputTokens(model: Provider.Model, outputTokenMax = OUTPUT_TOKEN_MAX): number {
return Math.min(model.limit.output, outputTokenMax) || outputTokenMax
}
Reasoning, streaming and history
DeepSeek and interleaved reasoning replay
Configured interleaved reasoning fields move assistant reasoning into OpenAI-compatible message options, excluding the OpenRouter SDK path. Empty placeholders are harness compatibility data, not evidence of model reasoning.
When: ProviderTransform normalization applies after preceding provider-specific branches; DeepSeek names receive empty reasoning placeholders when missing.
Source: transform.ts lines 303–351 · SHA-256 b0f39386a3b7…
// Deepseek requires all assistant messages to have reasoning on them
if (model.api.id.toLowerCase().includes("deepseek")) {
msgs = msgs.map((msg) => {
if (msg.role !== "assistant") return msg
if (Array.isArray(msg.content)) {
if (msg.content.some((part) => part.type === "reasoning")) return msg
return { ...msg, content: [...msg.content, { type: "reasoning", text: "" }] }
}
return {
...msg,
content: [
...(msg.content ? [{ type: "text" as const, text: msg.content }] : []),
{ type: "reasoning" as const, text: "" },
],
}
})
}
if (
typeof model.capabilities.interleaved === "object" &&
model.capabilities.interleaved.field &&
model.api.npm !== "@openrouter/ai-sdk-provider"
) {
const field = model.capabilities.interleaved.field
return msgs.map((msg) => {
if (msg.role === "assistant" && Array.isArray(msg.content)) {
const reasoningParts = msg.content.filter((part: any) => part.type === "reasoning")
const reasoningText = reasoningParts.map((part: any) => part.text).join("")
// Filter out reasoning parts from content
const filteredContent = msg.content.filter((part: any) => part.type !== "reasoning")
// Include reasoning_content | reasoning_details directly on the message for all assistant messages.
// Always set the field even when empty — some providers (e.g. DeepSeek) may return empty
// reasoning_content which still needs to be sent back in subsequent requests.
return {
...msg,
content: filteredContent,
providerOptions: {
...msg.providerOptions,
openaiCompatible: {
...msg.providerOptions?.openaiCompatible,
[field]: reasoningText,
},
},
}
}
return msg
Provider reasoning and usage defaults
OpenRouter requests usage inclusion. Kimi Anthropic-compatible reasoning uses adaptive summarized thinking/high effort. Alibaba-cn OpenAI-compatible reasoning enables thinking except kimi-k2-thinking. Small-model options may disable reasoning. Source settings do not establish visible returned reasoning.
When: ProviderTransform.options or smallOptions creates defaults before model/agent/user variant merges.
Source: transform.ts lines 1220–1415 · SHA-256 01c9c64618d7…
export function options(input: {
model: Provider.Model
sessionID: string
providerOptions?: Record<string, any>
}): Record<string, any> {
const result: Record<string, any> = {}
if (
input.model.api.npm === "@ai-sdk/google-vertex/anthropic" ||
(!input.model.api.id.includes("claude") && input.model.api.npm === "@ai-sdk/anthropic")
) {
result["toolStreaming"] = false
}
// openai and providers using openai package should set store to false by default.
if (
input.model.providerID === "openai" ||
input.model.api.npm === "@ai-sdk/openai" ||
input.model.api.npm === "@ai-sdk/github-copilot" ||
input.model.api.npm === "@ai-sdk/amazon-bedrock/mantle" ||
input.model.api.npm === "@ai-sdk/xai"
) {
result["store"] = false
}
if (input.model.api.npm === "@ai-sdk/azure") {
result["store"] = false
}
if (input.model.api.npm === "@openrouter/ai-sdk-provider" || input.model.api.npm === "@llmgateway/ai-sdk-provider") {
result["usage"] = {
include: true,
}
if (input.model.api.id.toLowerCase().includes("gemini") && !isLegacyGemini(input.model.api.id)) {
result["reasoning"] = { effort: "high" }
}
}
if (
input.model.providerID === "baseten" ||
(input.model.providerID === "opencode" && ["kimi-k2-thinking", "glm-4.6"].includes(input.model.api.id))
) {
result["chat_template_args"] = { enable_thinking: true }
}
if (
["zai", "zhipuai"].some((id) => input.model.providerID.includes(id)) &&
input.model.api.npm === "@ai-sdk/openai-compatible"
) {
result["thinking"] = {
type: "enabled",
clear_thinking: false,
}
}
if (input.model.providerID === "meta" && input.model.api.npm === "@ai-sdk/openai") {
result["reasoningSummary"] = "auto"
result["include"] = INCLUDE_ENCRYPTED_REASONING
}
if (input.model.api.npm === "@ai-sdk/google" || input.model.api.npm === "@ai-sdk/google-vertex") {
if (input.model.capabilities.reasoning) {
result["thinkingConfig"] = {
includeThoughts: true,
}
if (!isLegacyGemini(input.model.api.id)) {
result["thinkingConfig"]["thinkingLevel"] = "high"
}
}
}
const modelId = input.model.api.id.toLowerCase()
// MiniMax's Anthropic interface defaults thinking off, unlike Chat Completions.
if (modelId.includes("minimax-m3") && input.model.api.npm === "@ai-sdk/anthropic") {
result["thinking"] = { type: "adaptive" }
}
// Moonshot's Anthropic-compatible API uses adaptive effort rather than token budgets.
// Request summaries so thinking content survives replay on subsequent turns.
if (
["@ai-sdk/anthropic", "@ai-sdk/google-vertex/anthropic"].includes(input.model.api.npm) &&
isKimiFamily(input.model) &&
input.model.capabilities.reasoning
) {
result["thinking"] = { type: "adaptive", display: "summarized" }
result["effort"] = "high"
}
// Enable thinking for reasoning models on alibaba-cn (DashScope).
// DashScope's OpenAI-compatible API requires `enable_thinking: true` in the request body
// to return reasoning_content. Without it, models like kimi-k2.5, qwen-plus, qwen3, qwq,
// deepseek-r1, etc. never output thinking/reasoning tokens.
// Note: kimi-k2-thinking is excluded as it returns reasoning_content by default.
if (
input.model.providerID === "alibaba-cn" &&
input.model.capabilities.reasoning &&
input.model.api.npm === "@ai-sdk/openai-compatible" &&
!modelId.includes("kimi-k2-thinking")
) {
result["enable_thinking"] = true
}
if (input.providerOptions?.setCacheKey !== false) {
if (input.model.api.npm === "@ai-sdk/deepinfra" || input.model.api.npm === "@ai-sdk/cerebras") {
result["prompt_cache_key"] = input.sessionID
} else if (
input.model.api.npm === "@ai-sdk/openai" ||
input.model.api.npm === "@ai-sdk/azure" ||
input.model.api.npm === "@ai-sdk/xai" ||
input.model.api.npm === "@ai-sdk/mistral" ||
input.model.api.npm === "venice-ai-sdk-provider" ||
input.providerOptions?.setCacheKey === true
) {
result["promptCacheKey"] = input.sessionID
}
}
if (input.model.api.npm === "@ai-sdk/gateway") {
result["gateway"] = { caching: "auto" }
}
// Any gpt version above 5.4 in combination with azure does not support reasoningEffort
// so we should return early here.
const [, gptMajorVersion, gptMinorVersion] = input.model.api.id.match(/gpt-(\d+)\.(\d+)/) ?? []
const isGpt55OrNewer = Number(gptMajorVersion) > 5 || (Number(gptMajorVersion) === 5 && Number(gptMinorVersion) >= 5)
if (input.model.api.npm === "@ai-sdk/azure" && input.providerOptions?.useCompletionUrls) {
if (!isGpt55OrNewer) {
result["reasoningEffort"] = "medium"
}
return result
}
if (input.model.api.id.includes("gpt-5") && !input.model.api.id.includes("gpt-5-chat")) {
if (!input.model.api.id.includes("gpt-5-pro")) {
result["reasoningEffort"] = "medium"
if (
input.model.api.npm === "@ai-sdk/openai" ||
input.model.api.npm === "@ai-sdk/azure" ||
input.model.api.npm === "@ai-sdk/github-copilot" ||
input.model.api.npm === "@ai-sdk/amazon-bedrock/mantle"
) {
result["reasoningSummary"] = "auto"
}
if (input.model.api.npm === "@ai-sdk/openai" || input.model.api.npm === "@ai-sdk/amazon-bedrock/mantle") {
result["include"] = INCLUDE_ENCRYPTED_REASONING
}
}
// Generic OpenAI-compatible APIs do not necessarily support OpenAI's verbosity parameter.
// Only enable the default for integrations known to implement it.
if (
input.model.api.id.includes("gpt-5.") &&
!input.model.api.id.includes("codex") &&
!input.model.api.id.includes("-chat") &&
(input.model.api.npm === "@ai-sdk/openai" || input.model.api.npm === "@ai-sdk/amazon-bedrock/mantle")
) {
result["textVerbosity"] = "low"
}
if (input.model.providerID.startsWith("opencode") && input.providerOptions?.setCacheKey !== false) {
result["promptCacheKey"] = input.sessionID
result["include"] = INCLUDE_ENCRYPTED_REASONING
result["reasoningSummary"] = "auto"
}
}
return result
}
export function smallOptions(model: Provider.Model) {
const small = Object.values(model.variants ?? {})[0] ?? {}
if (
model.providerID === "openai" ||
model.api.npm === "@ai-sdk/openai" ||
model.api.npm === "@ai-sdk/github-copilot" ||
model.api.npm === "@ai-sdk/xai"
) {
const base = { store: false }
return mergeDeep(base, small)
}
if (model.providerID === "openrouter" || model.providerID === "llmgateway") {
if (Object.keys(small).length === 0 && model.api.id.includes("google")) {
return { reasoning: { enabled: false } }
}
}
if (model.providerID === "venice") {
if (Object.keys(small).length > 0) return small
return { veniceParameters: { disableThinking: true } }
}
return small
}
// Maps model ID prefix to provider slug used in providerOptions.
Reasoning variant fallback branches
Kimi on Anthropic-compatible transports has adaptive thinking efforts. Earlier DeepSeek/Kimi/Qwen name branches return no generic variants. OpenRouter effort variants only apply if earlier branches did not return.
When: Reasoning capability enabled and no authoritative reasoning_options variants supersede these fallback branches.
Source: transform.ts lines 790–879 · SHA-256 d6c5db464ecc…
export function variants(model: Provider.Model): Record<string, Record<string, any>> {
if (!model.capabilities.reasoning) return {}
const id = model.id.toLowerCase()
const glm52 = ["glm-5.2", "glm-5-2", "glm-5p2"].some(
(name) => id.includes(name) || model.api.id.toLowerCase().includes(name),
)
if (
model.api.id.toLowerCase().includes("minimax-m3") &&
["@ai-sdk/anthropic", "@ai-sdk/openai-compatible"].includes(model.api.npm)
) {
if (["nvidia", "lilac"].includes(model.providerID)) {
return {
none: { chat_template_kwargs: { thinking_mode: "disabled" } },
thinking: { chat_template_kwargs: { thinking_mode: "enabled" } },
}
}
return {
none: { thinking: { type: "disabled" } },
thinking: { thinking: { type: "adaptive" } },
}
}
const adaptiveThinkingOmitted = anthropicOmitsThinking(model.api.id)
const adaptiveEfforts = anthropicAdaptiveEfforts(model.api.id)
if (glm52 && model.api.npm === "@openrouter/ai-sdk-provider") {
// OpenRouter maps xhigh to GLM-5.2's native max effort.
return {
high: { reasoning: { effort: "high" } },
xhigh: { reasoning: { effort: "xhigh" } },
}
}
if (glm52 && model.api.npm === "@ai-sdk/openai-compatible") {
return {
high: { reasoningEffort: "high" },
max: { reasoningEffort: "max" },
}
}
if (glm52 && model.api.npm === "@ai-sdk/anthropic") {
return {
high: { effort: "high" },
max: { effort: "max" },
}
}
// Kimi's Anthropic-compatible transports implement adaptive thinking effort.
if (isKimiFamily(model) && ["@ai-sdk/anthropic", "@ai-sdk/google-vertex/anthropic"].includes(model.api.npm)) {
return Object.fromEntries(
["low", "medium", "high", "xhigh", "max"].map((effort) => [
effort,
{ thinking: { type: "adaptive", display: "summarized" }, effort },
]),
)
}
if (
id.includes("deepseek-chat") ||
id.includes("deepseek-reasoner") ||
id.includes("deepseek-r1") ||
id.includes("deepseek-v3") ||
id.includes("minimax") ||
(id.includes("glm") && !glm52) ||
id.includes("kimi") ||
id.includes("k2p") ||
id.includes("qwen") ||
id.includes("big-pickle")
)
return {}
// see: https://docs.x.ai/docs/guides/reasoning#control-how-hard-the-model-thinks
if (id.includes("grok") && id.includes("grok-3-mini")) {
if (model.api.npm === "@openrouter/ai-sdk-provider") {
return {
low: { reasoning: { effort: "low" } },
high: { reasoning: { effort: "high" } },
}
}
return {
low: { reasoningEffort: "low" },
high: { reasoningEffort: "high" },
}
}
switch (model.api.npm) {
case "@openrouter/ai-sdk-provider":
return Object.fromEntries(
(model.api.id.startsWith("openai/") || id.includes("gpt")
? openaiCompatibleReasoningEfforts(model.api.id)
: WIDELY_SUPPORTED_EFFORTS
).map((effort) => [effort, { reasoning: { effort } }]),
)
case "ai-gateway-provider": {
Catalog-defined reasoning controls
OpenRouter maps effort/budget to reasoning; Alibaba maps toggle/budget to enableThinking/thinkingBudget. This is configuration mapping, not provider response evidence.
When: Model catalog provides reasoning_options; effort, toggle or token-budget variants are mapped to selected SDK settings.
Source: transform.ts lines 1717–1922 · SHA-256 992c24b2158a…
export function reasoningVariants(model: ModelsDev.Model, target: Provider.Model): Provider.Model["variants"] {
const options = model.reasoning_options
if (options === undefined) return
if (options.length === 0) return {}
const effort = options.find((option) => option.type === "effort")
if (effort) return effortVariants(target, effort.values)
const toggle = options.some((option) => option.type === "toggle")
const budget = options.find((option) => option.type === "budget_tokens")
if (!budget) return toggle ? nonEmptyVariants(reasoningToggle(target)) : undefined
return nonEmptyVariants({
...(toggle ? reasoningToggle(target) : {}),
...budgetVariants(target, budget.min, budget.max),
})
}
function effortVariants(model: Provider.Model, values: readonly unknown[]) {
return Object.fromEntries(
values.flatMap((value) => {
const id = (() => {
if (value === null) return "none"
if (typeof value === "string") return value
})()
if (id === undefined) return []
const settings = reasoningEffort(model, id)
return settings ? [[id, settings]] : []
}),
)
}
function budgetVariants(model: Provider.Model, min?: number, max?: number) {
const maximum = Math.min(max ?? OUTPUT_TOKEN_MAX - 1, model.limit.output - 1, OUTPUT_TOKEN_MAX - 1)
if (maximum <= 0) return {}
const high = Math.min(Math.max(min ?? 0, Math.floor((maximum + 1) / 2)), maximum)
return Object.fromEntries(
[
{ id: "high", budget: high },
{ id: "max", budget: maximum },
].flatMap((item) => {
const settings = reasoningBudget(model, item.budget)
return settings ? [[item.id, settings]] : []
}),
)
}
function nonEmptyVariants(variants: NonNullable<Provider.Model["variants"]>): Provider.Model["variants"] {
return Object.keys(variants).length > 0 ? variants : undefined
}
function reasoningToggle(model: Provider.Model): NonNullable<Provider.Model["variants"]> {
if (model.api.npm === "@ai-sdk/alibaba")
return {
none: { enableThinking: false },
high: { enableThinking: true },
}
if (model.api.npm === "@ai-sdk/cohere")
return {
none: { thinking: { type: "disabled" } },
high: { thinking: { type: "enabled" } },
}
return {}
}
function reasoningEffort(model: Provider.Model, effort: string) {
switch (model.api.npm) {
case "@openrouter/ai-sdk-provider":
return { reasoning: { effort } }
case "@ai-sdk/anthropic":
case "@ai-sdk/google-vertex/anthropic":
return anthropicEffort(model, effort) ?? { effort }
case "@ai-sdk/google":
case "@ai-sdk/google-vertex":
return { thinkingConfig: { includeThoughts: true, thinkingLevel: effort } }
case "@ai-sdk/amazon-bedrock":
if (anthropicAdaptiveEfforts(model.api.id))
return {
reasoningConfig: {
type: "adaptive",
maxReasoningEffort: effort,
...(anthropicOmitsThinking(model.api.id) ? { display: "summarized" } : {}),
},
}
if (anthropicOpus45(model.api.id))
return {
reasoningConfig: {
type: "enabled",
budgetTokens: Math.min(16_000, Math.floor(model.limit.output / 2 - 1)),
maxReasoningEffort: effort,
},
}
if (model.api.id.includes("anthropic")) return
return { reasoningConfig: { type: "enabled", maxReasoningEffort: effort } }
case "@ai-sdk/gateway":
if (model.id.includes("anthropic")) return { thinking: { type: "adaptive", display: "summarized" }, effort }
if (model.id.includes("google")) return { thinkingConfig: { includeThoughts: true, thinkingLevel: effort } }
return { reasoningEffort: effort }
case "@ai-sdk/github-copilot":
// OAuth discovery replaces these with variants from Copilot's /models capabilities.
if (model.id.includes("gemini")) return
if (model.id.includes("claude")) return { reasoningEffort: effort }
return { reasoningEffort: effort, reasoningSummary: "auto", include: INCLUDE_ENCRYPTED_REASONING }
case "@ai-sdk/openai":
case "@ai-sdk/amazon-bedrock/mantle":
return { reasoningEffort: effort, reasoningSummary: "auto", include: INCLUDE_ENCRYPTED_REASONING }
case "@ai-sdk/azure":
return { reasoningEffort: effort, reasoningSummary: "auto", include: INCLUDE_ENCRYPTED_REASONING }
case "@jerome-benoit/sap-ai-provider-v2":
if (model.id.includes("anthropic"))
return { modelParams: { thinking: { type: "adaptive", display: "summarized" }, output_config: { effort } } }
return { modelParams: { reasoning_effort: effort } }
case "@ai-sdk/openai-compatible":
case "@ai-sdk/xai":
case "@ai-sdk/mistral":
case "@ai-sdk/groq":
case "@ai-sdk/cerebras":
case "@ai-sdk/deepinfra":
case "@ai-sdk/togetherai":
case "venice-ai-sdk-provider":
case "ai-gateway-provider":
case "merge-gateway-ai-sdk-provider":
return { reasoningEffort: effort }
case "gitlab-ai-provider":
if (model.family?.startsWith("gpt")) return { reasoningEffort: effort }
if (model.family?.startsWith("claude")) return { thinking: { type: "adaptive", effort } }
return
case "@ai-sdk/cohere":
case "@ai-sdk/perplexity":
case "@ai-sdk/vercel":
case "@ai-sdk/alibaba":
return
}
}
function anthropicEffort(model: Provider.Model, effort: string) {
if (anthropicOpus45(model.api.id)) return anthropicOpus45Effort(model, effort)
// Kimi defaults to omitting adaptive thinking text unless summarized display is requested.
if (isKimiFamily(model)) return { thinking: { type: "adaptive", display: "summarized" }, effort }
if (!anthropicAdaptiveEfforts(model.api.id)) return
return {
thinking: {
type: "adaptive",
...(anthropicOmitsThinking(model.api.id) ? { display: "summarized" } : {}),
},
effort,
}
}
function anthropicOpus45Effort(model: Provider.Model, effort: string) {
return {
thinking: {
type: "enabled",
budgetTokens: Math.min(16_000, Math.floor(model.limit.output / 2 - 1)),
},
effort,
}
}
function reasoningBudget(model: Provider.Model, budget: number) {
switch (model.api.npm) {
case "@openrouter/ai-sdk-provider":
return { reasoning: { max_tokens: budget } }
case "@ai-sdk/anthropic":
case "@ai-sdk/google-vertex/anthropic":
return { thinking: { type: "enabled", budgetTokens: budget } }
case "@ai-sdk/google":
case "@ai-sdk/google-vertex":
return { thinkingConfig: { includeThoughts: true, thinkingBudget: budget } }
case "@ai-sdk/amazon-bedrock":
return { reasoningConfig: { type: "enabled", budgetTokens: budget } }
case "@ai-sdk/gateway":
if (model.id.includes("anthropic")) return { thinking: { type: "enabled", budgetTokens: budget } }
if (model.id.includes("google")) return { thinkingConfig: { includeThoughts: true, thinkingBudget: budget } }
return
case "@ai-sdk/cohere":
return { thinking: { type: "enabled", tokenBudget: budget } }
case "@ai-sdk/alibaba":
return { enableThinking: true, thinkingBudget: budget }
case "@jerome-benoit/sap-ai-provider-v2":
if (model.id.includes("anthropic"))
return { modelParams: { thinking: { type: "enabled", budget_tokens: budget } } }
if (model.id.includes("gemini"))
return { modelParams: { thinkingConfig: { includeThoughts: true, thinkingBudget: budget } } }
return
case "@ai-sdk/amazon-bedrock/mantle":
case "@ai-sdk/azure":
case "@ai-sdk/cerebras":
case "@ai-sdk/deepinfra":
case "@ai-sdk/github-copilot":
case "@ai-sdk/groq":
case "@ai-sdk/mistral":
case "@ai-sdk/openai":
case "@ai-sdk/openai-compatible":
case "@ai-sdk/perplexity":
case "@ai-sdk/togetherai":
case "@ai-sdk/vercel":
case "@ai-sdk/xai":
case "ai-gateway-provider":
case "gitlab-ai-provider":
case "venice-ai-sdk-provider":
return
}
}
export * as ProviderTransform from "./transform"
AI SDK stream normalization
Reasoning start/delta/end remain separate from text; usage and finish metadata are normalized; errors fail the stream. This code does not reconstruct hidden/encrypted reasoning.
When: Default AI SDK runtime fullStream is converted into canonical LLMEvents.
Source: ai-sdk.ts lines 1–291 · SHA-256 3d2e653811ab…
import { FinishReason, LLMEvent, ProviderMetadata, ToolResultValue } from "@opencode-ai/llm"
import { Effect, Schema } from "effect"
import { type streamText } from "ai"
import { errorMessage } from "@/util/error"
import { ProviderError } from "@/provider/error"
type Result = Awaited<ReturnType<typeof streamText>>
type AISDKEvent = Result["fullStream"] extends AsyncIterable<infer T> ? T : never
export function adapterState() {
return {
step: 0,
text: 0,
reasoning: 0,
currentTextID: undefined as string | undefined,
currentReasoningID: undefined as string | undefined,
toolNames: {} as Record<string, string>,
copilotTotalNanoAiu: undefined as number | undefined,
}
}
function finishReason(value: string | undefined): FinishReason {
return Schema.is(FinishReason)(value) ? value : "unknown"
}
function providerMetadata(value: unknown): ProviderMetadata | undefined {
if (value == null) return undefined
return Schema.is(ProviderMetadata)(value) ? value : undefined
}
// Temporary AI SDK bridge: Copilot billing survives only in raw provider chunks here.
// Move this extraction into @opencode-ai/llm when Copilot is handled by the native runtime.
function copilotTotalNanoAiu(value: unknown) {
if (!value || typeof value !== "object") return
const raw = value as Record<string, unknown>
const response =
raw.response && typeof raw.response === "object" ? (raw.response as Record<string, unknown>) : undefined
const usage = raw.copilot_usage ?? response?.copilot_usage
if (!usage || typeof usage !== "object") return
const total = (usage as Record<string, unknown>).total_nano_aiu
if (typeof total !== "number" || !Number.isFinite(total) || total < 0) return
return total
}
function usage(value: unknown) {
if (!value || typeof value !== "object") return undefined
const item = value as {
inputTokens?: number
outputTokens?: number
totalTokens?: number
reasoningTokens?: number
cachedInputTokens?: number
inputTokenDetails?: { cacheReadTokens?: number; cacheWriteTokens?: number }
outputTokenDetails?: { reasoningTokens?: number }
}
const entries = Object.entries({
inputTokens: item.inputTokens,
outputTokens: item.outputTokens,
totalTokens: item.totalTokens,
reasoningTokens: item.outputTokenDetails?.reasoningTokens ?? item.reasoningTokens,
cacheReadInputTokens: item.inputTokenDetails?.cacheReadTokens ?? item.cachedInputTokens,
cacheWriteInputTokens: item.inputTokenDetails?.cacheWriteTokens,
}).filter((entry) => entry[1] !== undefined)
return entries.length === 0 ? undefined : Object.fromEntries(entries)
}
function currentTextID(state: ReturnType<typeof adapterState>, id: string | undefined) {
state.currentTextID = id ?? state.currentTextID ?? `text-${state.text++}`
return state.currentTextID
}
function currentReasoningID(state: ReturnType<typeof adapterState>, id: string | undefined) {
state.currentReasoningID = id ?? state.currentReasoningID ?? `reasoning-${state.reasoning++}`
return state.currentReasoningID
}
export function toLLMEvents(
state: ReturnType<typeof adapterState>,
event: AISDKEvent,
): Effect.Effect<ReadonlyArray<LLMEvent>, unknown> {
switch (event.type) {
case "start":
return Effect.succeed([])
case "start-step":
return Effect.succeed([LLMEvent.stepStart({ index: state.step })])
case "finish-step":
if (event.rawFinishReason === "network_error")
return Effect.fail(new ProviderError.ResponseStreamError("Provider finish_reason: network_error"))
return Effect.sync(() => {
const original = providerMetadata(event.providerMetadata)
const metadata =
state.copilotTotalNanoAiu === undefined
? original
: {
...original,
copilot: {
...original?.copilot,
totalNanoAiu: state.copilotTotalNanoAiu,
},
}
state.copilotTotalNanoAiu = undefined
return [
LLMEvent.stepFinish({
index: state.step++,
reason: finishReason(event.finishReason),
usage: usage(event.usage),
providerMetadata: metadata,
}),
]
})
case "finish":
return Effect.sync(() => {
const events = [
LLMEvent.finish({
reason: finishReason(event.finishReason),
usage: usage(event.totalUsage),
providerMetadata: "providerMetadata" in event ? providerMetadata(event.providerMetadata) : undefined,
}),
]
// Reset so the adapter can be reused for a follow-up stream without leaking
// counters or block IDs. adapterState() is the single source of truth for shape.
Object.assign(state, adapterState())
return events
})
case "text-start":
return Effect.sync(() => {
state.currentTextID = currentTextID(state, event.id)
return [
LLMEvent.textStart({
id: state.currentTextID,
providerMetadata: providerMetadata(event.providerMetadata),
}),
]
})
case "text-delta":
return Effect.succeed([
LLMEvent.textDelta({
id: currentTextID(state, event.id),
text: event.text,
providerMetadata: providerMetadata(event.providerMetadata),
}),
])
case "text-end":
return Effect.sync(() => {
const id = currentTextID(state, event.id)
state.currentTextID = undefined
return [
LLMEvent.textEnd({
id,
providerMetadata: providerMetadata(event.providerMetadata),
}),
]
})
case "reasoning-start":
return Effect.sync(() => {
state.currentReasoningID = currentReasoningID(state, event.id)
return [
LLMEvent.reasoningStart({
id: state.currentReasoningID,
providerMetadata: providerMetadata(event.providerMetadata),
}),
]
})
case "reasoning-delta":
return Effect.succeed([
LLMEvent.reasoningDelta({
id: currentReasoningID(state, event.id),
text: event.text,
providerMetadata: providerMetadata(event.providerMetadata),
}),
])
case "reasoning-end":
return Effect.sync(() => {
const id = currentReasoningID(state, event.id)
state.currentReasoningID = undefined
return [
LLMEvent.reasoningEnd({
id,
providerMetadata: providerMetadata(event.providerMetadata),
}),
]
})
case "tool-input-start":
return Effect.sync(() => {
state.toolNames[event.id] = event.toolName
return [
LLMEvent.toolInputStart({
id: event.id,
name: event.toolName,
providerMetadata: providerMetadata(event.providerMetadata),
}),
]
})
case "tool-input-delta":
return Effect.succeed([
LLMEvent.toolInputDelta({
id: event.id,
name: state.toolNames[event.id] ?? "unknown",
text: event.delta ?? "",
}),
])
case "tool-input-end":
return Effect.succeed([
LLMEvent.toolInputEnd({
id: event.id,
name: state.toolNames[event.id] ?? "unknown",
providerMetadata: providerMetadata(event.providerMetadata),
}),
])
case "tool-call":
return Effect.sync(() => {
state.toolNames[event.toolCallId] = event.toolName
return [
LLMEvent.toolCall({
id: event.toolCallId,
name: event.toolName,
input: event.input,
providerExecuted: "providerExecuted" in event ? event.providerExecuted : undefined,
providerMetadata: providerMetadata(event.providerMetadata),
}),
]
})
case "tool-result":
return Effect.sync(() => {
const name = state.toolNames[event.toolCallId] ?? "unknown"
delete state.toolNames[event.toolCallId]
return [
LLMEvent.toolResult({
id: event.toolCallId,
name,
result: ToolResultValue.make(event.output),
providerExecuted: "providerExecuted" in event ? event.providerExecuted : undefined,
providerMetadata: providerMetadata(event.providerMetadata),
}),
]
})
case "tool-error":
return Effect.sync(() => {
const name = state.toolNames[event.toolCallId] ?? ("toolName" in event ? event.toolName : "unknown")
delete state.toolNames[event.toolCallId]
return [
LLMEvent.toolError({
id: event.toolCallId,
name,
message: errorMessage(event.error),
error: event.error,
providerMetadata: providerMetadata(event.providerMetadata),
}),
]
})
case "error":
return Effect.fail(event.error)
case "abort":
case "source":
case "file":
case "tool-output-denied":
case "tool-approval-request":
return Effect.succeed([])
case "raw":
return Effect.sync(() => {
state.copilotTotalNanoAiu = copilotTotalNanoAiu(event.rawValue) ?? state.copilotTotalNanoAiu
return []
})
default: {
const _exhaustive: never = event
void _exhaustive
return Effect.succeed([])
}
}
}
export * as LLMAISDK from "./ai-sdk"
Visible reasoning persistence
Persists distinct reasoning parts and provider metadata. Orphan reasoning deltas are dropped, so stored exports can differ from raw wire streams.
When: Reasoning events arrive from the selected runtime with a preceding reasoning-start.
Source: processor.ts lines 278–315 · SHA-256 a4f082ea43e0…
const handleEvent = Effect.fnUntraced(function* (value: StreamEvent) {
switch (value.type) {
case "reasoning-start":
if (value.id in ctx.reasoningMap) return
ctx.reasoningMap[value.id] = {
id: PartID.ascending(),
messageID: ctx.assistantMessage.id,
sessionID: ctx.assistantMessage.sessionID,
type: "reasoning",
text: "",
time: { start: Date.now() },
metadata: value.providerMetadata,
}
yield* session.updatePart(ctx.reasoningMap[value.id])
return
case "reasoning-delta":
// Match dev: silently drop orphan deltas (no preceding reasoning-start).
if (!(value.id in ctx.reasoningMap)) return
ctx.reasoningMap[value.id].text += value.text
if (value.providerMetadata) ctx.reasoningMap[value.id].metadata = value.providerMetadata
yield* session.updatePartDelta({
sessionID: ctx.reasoningMap[value.id].sessionID,
messageID: ctx.reasoningMap[value.id].messageID,
partID: ctx.reasoningMap[value.id].id,
field: "text",
delta: value.text,
})
return
case "reasoning-end":
if (value.providerMetadata && value.id in ctx.reasoningMap) {
ctx.reasoningMap[value.id].metadata = value.providerMetadata
}
yield* finishReasoning(value.id)
return
case "tool-input-start":
Step finish, usage and provider transformations
Stores finish reason and normalized tokens/cost; logs provider-reported dropped thinking blocks and closes remaining reasoning parts. Raw capture is needed to inspect metadata that does not survive export.
When: Session processor receives step-finish from the selected runtime.
Source: processor.ts lines 435–468 · SHA-256 59efe7713cec…
case "step-finish": {
const completedSnapshot = yield* snapshot.track()
yield* Effect.forEach(Object.keys(ctx.reasoningMap), finishReasoning)
// Anthropic reports thinking blocks it removed before the model saw the
// prompt. Prefix mismatches mean opencode changed history behind a signed
// block; log them so the churn can be tracked down.
const dropped = isRecord(value.providerMetadata?.anthropic)
? value.providerMetadata.anthropic.inputTransformations
: undefined
if (Array.isArray(dropped) && dropped.length > 0) {
yield* Effect.logWarning("thinking blocks dropped by provider", {
sessionID: ctx.sessionID,
messageID: ctx.assistantMessage.id,
model: ctx.model.id,
transformations: JSON.stringify(dropped),
})
}
const usage = Session.getUsage({
model: ctx.model,
usage: value.usage ?? new Usage({}),
metadata: value.providerMetadata,
})
ctx.assistantMessage.finish = value.reason
ctx.assistantMessage.cost += usage.cost
ctx.assistantMessage.tokens = usage.tokens
yield* session.updatePart({
id: PartID.ascending(),
reason: value.reason,
snapshot: completedSnapshot,
messageID: ctx.assistantMessage.id,
sessionID: ctx.assistantMessage.sessionID,
type: "step-finish",
tokens: usage.tokens,
cost: usage.cost,
Opaque reasoning replay metadata
Visible part text becomes summary_text; reasoningEncryptedContent is passed separately as opaque encrypted_content or null. Encrypted bytes are not readable reasoning text.
When: Native OpenAI Responses lowering sees a reasoning part with valid provider item ID metadata.
Source: openai-responses.ts lines 283–300 · SHA-256 b48097c72da0…
const lowerReasoning = (part: ReasoningPart): OpenAIResponsesReasoningInput | undefined => {
const openai = part.providerMetadata?.openai
if (!ProviderShared.isRecord(openai) || typeof openai.itemId !== "string" || openai.itemId.length === 0)
return undefined
const encryptedContent =
typeof openai.reasoningEncryptedContent === "string"
? openai.reasoningEncryptedContent
: openai.reasoningEncryptedContent === null
? null
: undefined
return {
type: "reasoning",
id: openai.itemId,
summary: part.text.length > 0 ? [{ type: "summary_text", text: part.text }] : [],
encrypted_content: encryptedContent,
}
}
Stored assistant history replay and model changes
For the same model, reasoning parts preserve provider metadata. When model changes, nonempty reasoning becomes text and metadata is omitted. Completed, failed and interrupted tools have separate replay paths.
When: MessageV2 converts stored session parts for a new selected provider/model.
Source: message-v2.ts lines 249–386 · SHA-256 e01ecbdc054a…
const differentModel = `${model.providerID}/${model.id}` !== `${msg.info.providerID}/${msg.info.modelID}`
const media: Array<{ mime: string; url: string; filename?: string }> = []
if (
msg.info.error &&
!(
AbortedError.isInstance(msg.info.error) &&
msg.parts.some((part) => part.type !== "step-start" && part.type !== "reasoning")
)
) {
continue
}
const assistantMessage: UIMessage = {
id: msg.info.id,
role: "assistant",
parts: [],
}
// Anthropic adaptive thinking can persist assistant turns like:
// step-start, reasoning(signature), text(""), step-start,
// reasoning(signature). The empty text part is a structural separator,
// but it does not carry the signature metadata itself. Dropping it shifts
// signed thinking positions after step-start splitting/provider regrouping;
// keeping it as "" is filtered by the AI SDK and rejected by Anthropic.
// It is unclear whether this shape originates in our stream processing,
// a proxy, or a lower-level library, but preserving a non-empty separator
// here is the only safe replay point we have.
// Use a single space so the separator survives replay without changing
// the neighboring signed reasoning blocks.
const hasSignedReasoning = msg.parts.some((part) => {
if (part.type !== "reasoning") return false
return part.metadata?.anthropic?.signature != null
})
for (const part of msg.parts) {
if (part.type === "text") {
const text = part.text === "" && hasSignedReasoning ? " " : part.text
assistantMessage.parts.push({
type: "text",
text,
...(differentModel ? {} : { providerMetadata: part.metadata }),
})
}
if (part.type === "step-start")
assistantMessage.parts.push({
type: "step-start",
})
if (part.type === "tool") {
toolNames.add(part.tool)
if (part.state.status === "completed") {
const outputText = part.state.time.compacted
? "[Old tool result content cleared]"
: truncateToolOutput(part.state.output, options?.toolOutputMaxChars)
const attachments = part.state.time.compacted || options?.stripMedia ? [] : (part.state.attachments ?? [])
// For providers that don't support media in tool results, extract media files
// (images, PDFs) to be sent as a separate user message
const mediaAttachments = attachments.filter((a) => isMedia(a.mime))
const extractedMedia = mediaAttachments.filter((a) => !supportsMediaInToolResult(a))
if (extractedMedia.length > 0) {
media.push(...extractedMedia)
}
const finalAttachments = attachments.filter((a) => !isMedia(a.mime) || supportsMediaInToolResult(a))
const output =
finalAttachments.length > 0
? {
text: outputText,
attachments: finalAttachments,
}
: outputText
assistantMessage.parts.push({
type: ("tool-" + part.tool) as `tool-${string}`,
state: "output-available",
toolCallId: part.callID,
input: part.state.input,
output,
...(part.metadata?.providerExecuted ? { providerExecuted: true } : {}),
...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }),
})
}
if (part.state.status === "error") {
const output = part.state.metadata?.interrupted === true ? part.state.metadata.output : undefined
if (typeof output === "string") {
assistantMessage.parts.push({
type: ("tool-" + part.tool) as `tool-${string}`,
state: "output-available",
toolCallId: part.callID,
input: part.state.input,
output,
...(part.metadata?.providerExecuted ? { providerExecuted: true } : {}),
...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }),
})
} else {
assistantMessage.parts.push({
type: ("tool-" + part.tool) as `tool-${string}`,
state: "output-error",
toolCallId: part.callID,
input: part.state.input,
errorText: part.state.error,
...(part.metadata?.providerExecuted ? { providerExecuted: true } : {}),
...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }),
})
}
}
// Handle pending/running tool calls to prevent dangling tool_use blocks
// Anthropic/Claude APIs require every tool_use to have a corresponding tool_result
if (part.state.status === "pending" || part.state.status === "running")
assistantMessage.parts.push({
type: ("tool-" + part.tool) as `tool-${string}`,
state: "output-error",
toolCallId: part.callID,
input: part.state.input,
errorText: "[Tool execution was interrupted]",
...(part.metadata?.providerExecuted ? { providerExecuted: true } : {}),
...(differentModel ? {} : { callProviderMetadata: providerMeta(part.metadata) }),
})
}
if (part.type === "reasoning") {
if (differentModel) {
if (part.text.trim().length > 0)
assistantMessage.parts.push({
type: "text",
text: part.text,
})
continue
}
assistantMessage.parts.push({
type: "reasoning",
text: part.text,
providerMetadata: part.metadata,
})
}
}
if (assistantMessage.parts.length > 0) {
result.push(assistantMessage)
// Inject pending media as a user message for providers that don't support
// media (images, PDFs) in tool results
if (media.length > 0) {