import { LLM, type ContentPart, type MediaPart } from "@opencode-ai/llm" import { Effect, Schema } from "effect" import { ProviderLLMBridge } from "@/provider/llm-bridge" import * as EffectZod from "@/util/effect-zod" import type { Provider } from "@/provider/provider" import type { Tool } from "@/tool/tool" import type { MessageV2 } from "./message-v2" export class UnsupportedModelError extends Schema.TaggedErrorClass()( "LLMNative.UnsupportedModelError", { providerID: Schema.String, modelID: Schema.String, }, ) { override get message() { return `No native LLM route for ${this.providerID}/${this.modelID}` } } export class UnsupportedContentError extends Schema.TaggedErrorClass()( "LLMNative.UnsupportedContentError", { messageID: Schema.String, partType: Schema.String, reason: Schema.optional(Schema.String), }, ) { override get message() { const base = `Native LLM request conversion does not support ${this.partType} parts in message ${this.messageID}` return this.reason ? `${base}: ${this.reason}` : base } } export type RequestInput = { readonly id?: string readonly provider: Provider.Info readonly model: Provider.Model readonly system?: ReadonlyArray readonly messages: ReadonlyArray readonly tools?: ReadonlyArray readonly toolChoice?: LLM.RequestInput["toolChoice"] readonly generation?: LLM.RequestInput["generation"] readonly headers?: Record readonly metadata?: Record readonly native?: Record } const isDefined = (value: T | undefined): value is T => value !== undefined const isRecord = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value) // Match `data:[;param=value]*[;base64],`. Captures only the // payload — the bridge passes it through to `MediaPart.data` (already-base64 // per the convention `ProviderShared.mediaBytes` follows). Non-data URLs // (http(s):, file:, relative paths) are out of scope for now and rejected // upstream so a future fetch / filesystem-read path can plug in cleanly. const DATA_URL_PATTERN = /^data:[^,]*,(.*)$/s const lowerFilePart = (message: MessageV2.WithParts, part: MessageV2.FilePart) => Effect.gen(function* () { const match = DATA_URL_PATTERN.exec(part.url) if (!match) { return yield* new UnsupportedContentError({ messageID: message.info.id, partType: "file", reason: `file URL must be a data: URL (got ${part.url})`, }) } return { type: "media", mediaType: part.mime, data: match[1], filename: part.filename, } satisfies MediaPart }) const nativeMessage = (message: MessageV2.WithParts) => ({ opencodeMessageID: message.info.id, }) const providerMeta = (metadata: Record | undefined) => { if (!metadata) return undefined const { providerExecuted: _, ...rest } = metadata return Object.keys(rest).length > 0 ? rest : undefined } const providerExecuted = (metadata: Record | undefined) => metadata?.providerExecuted === true ? true : undefined const encryptedReasoning = (metadata: Record | undefined) => { if (!metadata) return undefined if (typeof metadata.encrypted === "string") return metadata.encrypted if (isRecord(metadata.anthropic) && typeof metadata.anthropic.signature === "string") return metadata.anthropic.signature if (isRecord(metadata.openai) && typeof metadata.openai.reasoningEncryptedContent === "string") { return metadata.openai.reasoningEncryptedContent } return undefined } const isToolPart = (part: MessageV2.Part): part is MessageV2.ToolPart => part.type === "tool" const supportsPart = (message: MessageV2.WithParts, part: MessageV2.Part) => { if (part.type === "text") return true if (part.type === "file") return message.info.role === "user" if (message.info.role !== "assistant") return false return part.type === "reasoning" || part.type === "tool" } const unsupportedPart = (input: RequestInput) => input.messages .flatMap((message) => message.parts.map((part) => ({ message, part }))) .find((entry) => !supportsPart(entry.message, entry.part)) const toolResultValue = (part: MessageV2.ToolPart) => { if (part.state.status === "completed") { return { type: "text" as const, value: part.state.time.compacted ? "[Old tool result content cleared]" : part.state.output, } } if (part.state.status === "error") { const output = part.state.metadata?.interrupted === true ? part.state.metadata.output : undefined if (typeof output === "string") return { type: "text" as const, value: output } return { type: "error" as const, value: part.state.error } } return { type: "error" as const, value: "[Tool execution was interrupted]" } } const assistantContent = (part: MessageV2.Part): ReadonlyArray => { if (part.type === "text" && !part.ignored) return [LLM.text(part.text)] if (part.type === "reasoning") return [{ type: "reasoning", text: part.text, encrypted: encryptedReasoning(part.metadata), metadata: part.metadata }] if (part.type !== "tool") return [] return [ LLM.toolCall({ id: part.callID, name: part.tool, input: part.state.input, providerExecuted: providerExecuted(part.metadata), metadata: providerMeta(part.metadata), }), ...(providerExecuted(part.metadata) ? [toolResultPart(part)] : []), ] } const toolResultMessage = (part: MessageV2.ToolPart) => LLM.toolMessage({ id: part.callID, name: part.tool, result: toolResultValue(part), providerExecuted: providerExecuted(part.metadata), metadata: providerMeta(part.metadata), }) const toolResultPart = (part: MessageV2.ToolPart) => LLM.toolResult({ id: part.callID, name: part.tool, result: toolResultValue(part), providerExecuted: true, metadata: providerMeta(part.metadata), }) const assistantMessages = (input: MessageV2.WithParts) => { const content = input.parts.flatMap(assistantContent) const assistant = content.length ? LLM.message({ id: input.info.id, role: "assistant", content, native: nativeMessage(input), }) : undefined return [ assistant, ...input.parts.filter(isToolPart).filter((part) => !providerExecuted(part.metadata)).map(toolResultMessage), ].filter(isDefined) } // User-role parts that pass the static gate: text and file. Text becomes a // `LLM.text(...)` ContentPart; file becomes a `MediaPart` via `lowerFilePart`, // which can yield `UnsupportedContentError` for non-data URLs. const lowerUserPart = (message: MessageV2.WithParts, part: MessageV2.Part) => Effect.gen(function* () { if (part.type === "text") return part.ignored ? [] : [LLM.text(part.text)] if (part.type === "file") return [yield* lowerFilePart(message, part)] return [] }) const userMessage = Effect.fnUntraced(function* (input: MessageV2.WithParts) { const content: ContentPart[] = [] for (const part of input.parts) { content.push(...(yield* lowerUserPart(input, part))) } if (content.length === 0) return [] return [ LLM.message({ id: input.info.id, role: input.info.role, content, native: nativeMessage(input), }), ] }) const lowerMessage = Effect.fnUntraced(function* (input: MessageV2.WithParts) { if (input.info.role === "assistant") return assistantMessages(input) return yield* userMessage(input) }) export const toolDefinition = (input: { readonly model: Provider.Model; readonly tool: Tool.Def }) => LLM.toolDefinition({ name: input.tool.id, description: input.tool.description, inputSchema: EffectZod.toJsonSchema(input.tool.parameters), native: { opencodeToolID: input.tool.id, }, }) export const request = Effect.fn("LLMNative.request")(function* (input: RequestInput) { const unsupported = unsupportedPart(input) if (unsupported) { return yield* new UnsupportedContentError({ messageID: unsupported.message.info.id, partType: unsupported.part.type, }) } const model = ProviderLLMBridge.toModelRef({ provider: input.provider, model: input.model }) if (!model) { return yield* new UnsupportedModelError({ providerID: input.provider.id, modelID: input.model.id, }) } const headers = { ...model.headers, ...input.headers } const requestModel = Object.keys(headers).length === 0 ? model : LLM.model({ ...model, headers }) // Cache hints, tool-id scrubbing, and other adapter-aware transforms live in // `@opencode-ai/llm`'s `ProviderTransform` registry. Callers wire them in at // `client({ adapters, transforms: ProviderTransform.defaults })` time so the // bridge stays focused on shape conversion. return LLM.request({ id: input.id, model: requestModel, system: input.system?.filter((part) => part.trim() !== "").map(LLM.system) ?? [], messages: (yield* Effect.forEach(input.messages, lowerMessage)).flat(), tools: input.tools?.map((tool) => toolDefinition({ model: input.model, tool })) ?? [], toolChoice: input.toolChoice, generation: input.generation, metadata: input.metadata, native: input.native, }) }) export * as LLMNative from "./llm-native"