Something went wrong. Try again.
opencode v2 mirror
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392export * as SessionModelRequest from "./model-request.js"
import { GenerationOptions, type GenerationOptionsFields, HttpOptions, LanguageModel, LLM, LLMRequest, type Media, Message, SystemPart,} from "@opencode/ai"import type { StreamOptions } from "@opencode/ai/route"import type { SessionCompaction, SessionContext, SessionGenerate, SessionRequest, SessionRequestKind, SessionTitle,} from "@opencode/plugin/effect/session"import type { Agent } from "@opencode/schema/agent"import type { Model } from "@opencode/schema/model"import type { Content } from "@opencode/schema/tool"import { Cause, Context, Effect, Layer, Result, Stream } from "effect"import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"import { makeLocationNode } from "@opencode/util/effect/app-node"import { App } from "../app.js"import { Permission } from "../permission.js"import { PluginHooks } from "../plugin/hooks.js"import { QuestionTool } from "../tool/plugin/question.js"import { Tool } from "../tool.js"import { SessionModelTransport } from "./model-transport.js"import { SessionProviderContext } from "./provider-context.js"import { SessionRunnerModel } from "./runner/model.js"import { SessionSchema } from "./schema.js"import { SessionSystemPrompt } from "./system-prompt.js"import { toLLMMessages } from "./runner/to-llm-message.js"import type { SessionMessage } from "./message.js"
const IMAGE_BYTES_TRIGGER = 25 * 1024 * 1024 // 25 MiBconst IMAGE_BYTES_TARGET = 15 * 1024 * 1024 // 15 MiBconst IMAGE_REMOVED = "[This image was removed to reduce the request size and is no longer visible. Do not make claims about its contents from memory. If needed, retrieve it again with an available tool or ask the user to attach it again.]"const GENERATION_KEYS = new Set(Object.keys(GenerationOptions.fields))
/** Tool errors, plus the user declining a permission or dismissing a question. */export type ExecuteError = Tool.Error | Permission.DeclinedError | QuestionTool.CancelledError
export interface Prepared<Event = SessionRequest> { readonly event: Event readonly request: LLMRequest readonly options: StreamOptions readonly retry: (event: PluginHooks.Domains["session"]["retry"]) => Effect.Effect<void> /** Runs a tool call against the tools this request advertised. */ readonly executeTool: ( input: Parameters<Tool.Snapshot["execute"]>[0], ) => Effect.Effect<Tool.NormalizedResult, ExecuteError>}
export interface Input { readonly session: SessionSchema.Info readonly agent: Agent.ID readonly model: SessionRunnerModel.Resolved readonly tools?: Tool.Snapshot readonly system: Array<SystemPart> readonly messages: Array<Message> readonly toolChoice?: LLM.RequestInput["toolChoice"] /** Only the durable runner may use a stateful WebSocket. */ readonly webSocket?: "session"}
export const baseTranscript = (input: { readonly agent: Agent.Info readonly model: SessionRunnerModel.Resolved readonly tools: Tool.Snapshot readonly initial: string readonly messages: ReadonlyArray<SessionMessage.Info>}) => { const providerMetadataKey = input.model.model.route.providerMetadataKey ?? input.model.model.provider return { providerMetadataKey, system: [ input.agent.system ? input.agent.system : SessionSystemPrompt.make(input.tools.definitions.map((tool) => tool.name)), input.initial, ] .filter((part) => part.length > 0) .map(SystemPart.make), messages: toLLMMessages(input.messages, input.model.ref, providerMetadataKey), }}
const mimeToModality = (mime: string) => { if (mime.startsWith("image/")) return "image" if (mime.startsWith("audio/")) return "audio" if (mime.startsWith("video/")) return "video" if (mime === "application/pdf") return "pdf"}
const unsupportedMedia = (mime: string, name: string | undefined, capabilities: Model.Capabilities) => { const modality = mimeToModality(mime) if (!modality || capabilities.input.some((item) => item.startsWith(modality))) return return { type: "text" as const, text: `ERROR: Cannot read ${name ? `"${name}"` : modality} (this model does not support ${modality} input). Inform the user.`, }}
export const unsupportedParts = (messages: LLMRequest["messages"], capabilities: Model.Capabilities) => messages.map((message) => Message.make({ ...message, content: message.content.map((part) => { if (part.type === "media") { return unsupportedMedia(part.media.mediaType, part.filename, capabilities) ?? part } if (part.type !== "tool-result" || part.result.type !== "content") return part return { ...part, result: { ...part.result, value: part.result.value.map((item: Content) => { if (item.type !== "file") return item return unsupportedMedia(item.mime, item.name, capabilities) ?? item }), }, } }), }), )
export const boundImages = (messages: LLMRequest["messages"]) => { const isImage = (mime: string) => mime.toLowerCase().startsWith("image/") // Remote and provider-referenced media carry no local payload and never count toward the inline budget. const size = (media: Media.Asset) => { if (media.source.type === "base64") return Buffer.byteLength(media.source.data) if (media.source.type === "bytes") return Math.ceil(media.source.data.byteLength / 3) * 4 return 0 } const imageBytes = messages.reduce( (total, message) => total + message.content.reduce((sum, part) => { if (part.type === "media" && isImage(part.media.mediaType)) return sum + size(part.media) if (part.type !== "tool-result" || part.result.type !== "content") return sum return ( sum + part.result.value.reduce( (bytes: number, item: Content) => bytes + (item.type === "file" && isImage(item.mime) ? Buffer.byteLength(item.uri) : 0), 0, ) ) }, 0), 0, ) if (imageBytes <= IMAGE_BYTES_TRIGGER) return messages
let removed = 0 return messages.map((message) => Message.make({ ...message, content: message.content.map((part) => { if (part.type === "media" && isImage(part.media.mediaType) && imageBytes - removed > IMAGE_BYTES_TARGET) { removed += size(part.media) return Message.text(IMAGE_REMOVED) } if (part.type !== "tool-result" || part.result.type !== "content") return part return { ...part, result: { ...part.result, value: part.result.value.map((item: Content) => { if (item.type !== "file" || !isImage(item.mime) || imageBytes - removed <= IMAGE_BYTES_TARGET) return item removed += Buffer.byteLength(item.uri) return { type: "text" as const, text: IMAGE_REMOVED } }), }, } }), }), )}
type Definitions = PluginHooks.Domains["session"]["context"]["tools"]
/** Builds the model request for each session flow. Each entry runs its own plugin hook. */export interface Interface { readonly primary: (input: Input) => Effect.Effect<Prepared<SessionContext>> readonly compaction: (input: Input) => Effect.Effect<Prepared<SessionCompaction>> readonly generate: (input: Input) => Effect.Effect<Prepared<SessionGenerate>> readonly title: (input: Input) => Effect.Effect<Prepared<SessionTitle>>}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionModelRequest") {}
export const layer = Layer.effect( Service, Effect.gen(function* () { const hooks = yield* PluginHooks.Service const transport = yield* SessionModelTransport.Service const app = yield* App.Metadata const prepare = Effect.fn("SessionModelRequest.prepare")(function* < S extends SessionRequest & { tools?: Definitions }, >(kind: SessionRequestKind, input: Input, shape: (draft: SessionRequest, tools: Definitions) => Effect.Effect<S>) { const session = input.session const model = input.model const scope = { sessionID: session.id, agent: input.agent, model: model.ref, kind } const tools = input.tools ?? { definitions: [], execute: () => new Tool.Error({ message: "Tools are not available for this request" }), } // Remember which tool each definition object came from. Hooks rename a tool by moving // its definition to a new key, so after the hook we find the tool by object identity. const given = new Map( tools.definitions.map((t) => [{ description: t.description, input: { ...t.inputSchema } }, t] as const), ) const shaped = yield* shape( { sessionID: session.id, model: model.ref, system: input.system, messages: input.messages, options: {} }, Object.fromEntries(Array.from(given, ([d, t]) => [t.name, d])), ) // Match by identity first, then by key. Entries matching neither were invented by a // hook and are dropped. `t.name` stays the real name so execution can map renames back. const byName = new Map(tools.definitions.map((t) => [t.name, t])) const hooked = new Map( Object.entries(shaped.tools ?? {}).flatMap(([name, d]) => { const t = given.get(d) ?? byName.get(name) return t ? [[name, { ...t, description: d.description, inputSchema: d.input }] as const] : [] }), ) const entries = Object.entries(shaped.options) const generation = Object.fromEntries(entries.filter(([k]) => GENERATION_KEYS.has(k))) as GenerationOptionsFields const providerOptions = Object.fromEntries(entries.filter(([k]) => !GENERATION_KEYS.has(k))) const affinity = session.parentID ?? session.fork?.sessionID ?? session.id const base = LLM.request({ model: model.model, http: { headers: { "x-session-affinity": session.id, "X-Session-Id": session.id, ...(session.parentID ? { "x-parent-session-id": session.parentID } : {}), "User-Agent": App.useragent(app), "x-opencode-project": session.projectID, "x-opencode-session": session.id, "x-opencode-client": app.name, }, }, // TODO: Persist cache lineage so nested forks reuse the root session's cache key. promptCacheKey: /^ses_[0-9a-f]{64}$/.test(affinity) ? affinity.slice(4) : affinity, system: shaped.system, messages: boundImages(unsupportedParts(shaped.messages, model.capabilities)), tools: Array.from(hooked, ([name, t]) => ({ ...t, name })), toolChoice: input.toolChoice, generation: Object.keys(generation).length === 0 ? undefined : generation, providerOptions: Object.keys(providerOptions).length === 0 ? undefined : providerOptions, })
const baseURL = base.model.route.endpoint.baseURL const modelHook = yield* hooks.trigger("session", "model.request", { ...scope, baseURL: typeof baseURL === "string" ? baseURL : undefined, headers: { ...base.http?.headers }, }) const route = modelHook.baseURL !== undefined && modelHook.baseURL !== baseURL ? base.model.route.with({ endpoint: { baseURL: modelHook.baseURL } }) : base.model.route const request = LLMRequest.update(base, { model: route === base.model.route ? base.model : LanguageModel.update(base.model, { route }), http: new HttpOptions({ body: base.http?.body, headers: Object.keys(modelHook.headers).length === 0 ? undefined : modelHook.headers, query: base.http?.query, }), }) // History selects native windows against the catalog route before hooks run. A newly installed // routing hook must not send an existing opaque window to another deployment; `prepare` has no // error channel, so like hook failures this surfaces as a defect. const selected = SessionProviderContext.provenance(model) if ( selected && !SessionProviderContext.compatible( selected, SessionProviderContext.provenance({ model: request.model, ref: model.ref }), ) && request.messages.some((message) => message.content.some((part) => part.type === "compaction")) ) return yield* Effect.die( new Error("Provider context is incompatible with the route selected by model request hooks"), )
const hasHttpHooks = (yield* hooks.has("session", "http.request", model.ref.providerID)) || (yield* hooks.has("session", "http.response", model.ref.providerID)) const http: StreamOptions["http"] = hasHttpHooks ? (req, handler) => Effect.gen(function* () { const before = yield* hooks.trigger("session", "http.request", { ...scope, request: yield* HttpClientRequest.toWeb(req), }) let sent = HttpClientRequest.fromWeb(before.request) if (before.request.body) sent = HttpClientRequest.bodyUint8Array( sent, new Uint8Array(yield* Effect.promise(() => before.request.clone().arrayBuffer())), before.request.headers.get("content-type") ?? undefined, ) const res = yield* handler(sent) const after = yield* hooks.trigger("session", "http.response", { ...scope, request: before.request, response: new Response( [204, 205, 304].includes(res.status) ? null : yield* Stream.toReadableStreamEffect(res.stream), { status: res.status, headers: res.headers }, ), }) return HttpClientResponse.fromWeb(sent, after.response) }).pipe(Effect.mapError((cause) => (cause instanceof Error ? cause : new Error(String(cause))))) : undefined // HTTP hooks wrap every HTTP request, including the WebSocket fallback path. The route decides // which transport actually carries the request, so both hook families are always offered. const webSocket = input.webSocket === "session" && model.transport === "websocket" ? transport.bind(session.id, { handshake: (connect) => hooks .trigger("session", "experimental.ws.handshake", { ...scope, url: connect.url, headers: connect.headers, }) .pipe(Effect.map((event) => ({ url: event.url, headers: event.headers }))), send: (frame) => hooks .trigger("session", "experimental.ws.send", { ...scope, frame }) .pipe(Effect.map((event) => event.frame)), receive: (frame) => hooks .trigger("session", "experimental.ws.receive", { ...scope, frame }) .pipe(Effect.map((event) => event.frame)), }) : undefined
return { event: shaped, request, options: { ...(http ? { http } : {}), ...(webSocket ? { webSocket } : {}) }, retry: (event: Parameters<Prepared["retry"]>[0]) => hooks.trigger("session", "retry", event).pipe(Effect.asVoid), // Permission.assert and the question tool throw declines as defects so tools cannot // catch them and turn a "no" into model-visible output. Recover them here as failures. executeTool: (call: Parameters<Prepared["executeTool"]>[0]) => tools.execute({ ...call, definitions: hooked }).pipe( Effect.catchCauseFilter( (cause) => { const decline = cause.reasons.flatMap((r) => Cause.isDieReason(r) && (r.defect instanceof Permission.DeclinedError || r.defect instanceof QuestionTool.CancelledError) ? [r.defect] : [], )[0] return decline ? Result.succeed(decline) : Result.fail(cause) }, (decline) => Effect.fail(decline), ), ), } })
const agentHook = (name: "context" | "compaction" | "generate", agent: Agent.ID) => (draft: SessionRequest, tools: Definitions) => hooks.trigger("session", name, { ...draft, agent, tools })
return Service.of({ primary: (input) => prepare("primary", input, agentHook("context", input.agent)), compaction: (input) => prepare("compaction", input, agentHook("compaction", input.agent)), generate: (input) => prepare("generate", input, agentHook("generate", input.agent)), title: (input) => prepare("title", input, (draft) => hooks.trigger("session", "title", draft)), }) }),)
export const node = makeLocationNode({ service: Service, layer, deps: [PluginHooks.node, SessionModelTransport.node, App.node],})