Files
anomalyco_opencode/packages/opencode/src/session/llm-native.ts
T
2026-05-05 17:58:58 -04:00

260 lines
9.2 KiB
TypeScript

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<UnsupportedModelError>()(
"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<UnsupportedContentError>()(
"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<string>
readonly messages: ReadonlyArray<MessageV2.WithParts>
readonly tools?: ReadonlyArray<Tool.Def>
readonly toolChoice?: LLM.RequestInput["toolChoice"]
readonly generation?: LLM.RequestInput["generation"]
readonly headers?: Record<string, string>
readonly metadata?: Record<string, unknown>
readonly native?: Record<string, unknown>
}
const isDefined = <T>(value: T | undefined): value is T => value !== undefined
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value)
// Match `data:<mediaType>[;param=value]*[;base64],<payload>`. 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<string, unknown> | undefined) => {
if (!metadata) return undefined
const { providerExecuted: _, ...rest } = metadata
return Object.keys(rest).length > 0 ? rest : undefined
}
const providerExecuted = (metadata: Record<string, unknown> | undefined) =>
metadata?.providerExecuted === true ? true : undefined
const encryptedReasoning = (metadata: Record<string, unknown> | 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<ContentPart> => {
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"