diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index a73c7a2da1..23cefe1181 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -1,41 +1,34 @@ import { Provider } from "@/provider/provider" import * as Log from "@opencode-ai/core/util/log" -import { Context, Effect, Layer, Record } from "effect" +import { Context, Effect, Layer } from "effect" import * as Stream from "effect/Stream" -import { streamText, wrapLanguageModel, type ModelMessage, type Tool, tool as aiTool, jsonSchema } from "ai" +import { streamText, wrapLanguageModel, type ModelMessage, type Tool } from "ai" import type { LLMEvent } from "@opencode-ai/llm" import { LLMClient, RequestExecutor, WebSocketExecutor } from "@opencode-ai/llm/route" import type { LLMClientService } from "@opencode-ai/llm/route" -import { mergeDeep } from "remeda" import { GitLabWorkflowLanguageModel } from "gitlab-ai-provider" import { ProviderTransform } from "@/provider/transform" import { Config } from "@/config/config" -import { InstanceState } from "@/effect/instance-state" import type { Agent } from "@/agent/agent" import type { MessageV2 } from "./message-v2" import { Plugin } from "@/plugin" -import { SystemPrompt } from "./system" import { Permission } from "@/permission" import { PermissionID } from "@/permission/schema" import { Bus } from "@/bus" import { Wildcard } from "@/util/wildcard" import { SessionID } from "@/session/schema" import { Auth } from "@/auth" -import { InstallationVersion } from "@opencode-ai/core/installation/version" import { EffectBridge } from "@/effect/bridge" import { RuntimeFlags } from "@/effect/runtime-flags" import * as Option from "effect/Option" import * as OtelTracer from "@effect/opentelemetry/Tracer" import { LLMAISDK } from "./llm/ai-sdk" import { LLMNativeRuntime } from "./llm/native-runtime" +import { LLMRequestPrep } from "./llm/request" const log = Log.create({ service: "llm" }) export const OUTPUT_TOKEN_MAX = ProviderTransform.OUTPUT_TOKEN_MAX -// Avoid re-instantiating remeda's deep merge types in this hot LLM path; the runtime behavior is still mergeDeep. -const mergeOptions = (target: Record, source: Record | undefined): Record => - mergeDeep(target, source ?? {}) as Record - export type StreamInput = { user: MessageV2.User sessionID: string @@ -106,123 +99,15 @@ const live: Layer.Layer< { concurrency: "unbounded" }, ) - // TODO: move this to a proper hook - const isOpenaiOauth = item.id === "openai" && info?.type === "oauth" - - const system: string[] = [] - system.push( - [ - // use agent prompt otherwise provider prompt - ...(input.agent.prompt ? [input.agent.prompt] : SystemPrompt.provider(input.model)), - // any custom prompt passed into this call - ...input.system, - // any custom prompt from last user message - ...(input.user.system ? [input.user.system] : []), - ] - .filter((x) => x) - .join("\n"), - ) - - const header = system[0] - yield* plugin.trigger( - "experimental.chat.system.transform", - { sessionID: input.sessionID, model: input.model }, - { system }, - ) - // rejoin to maintain 2-part structure for caching if header unchanged - 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: item.options, - }) - const options = mergeOptions(mergeOptions(mergeOptions(base, input.model.options), input.agent.options), variant) - if (isOpenaiOauth) { - options.instructions = system.join("\n") - } - const isWorkflow = language instanceof GitLabWorkflowLanguageModel - const messages = isOpenaiOauth - ? input.messages - : isWorkflow - ? input.messages - : [ - ...system.map( - (x): ModelMessage => ({ - role: "system", - content: x, - }), - ), - ...input.messages, - ] - - const params = yield* plugin.trigger( - "chat.params", - { - sessionID: input.sessionID, - agent: input.agent.name, - model: input.model, - provider: item, - 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, flags.outputTokenMax), - options, - }, - ) - - const { headers } = yield* plugin.trigger( - "chat.headers", - { - sessionID: input.sessionID, - agent: input.agent.name, - model: input.model, - provider: item, - message: input.user, - }, - { - headers: {}, - }, - ) - - const tools = resolveTools(input) - - // GitHub Copilot may require the tools parameter when message history contains - // tool calls but no tools are active (e.g. compaction). Inject a stub tool that - // is never meant to be invoked. LiteLLM-backed providers are excluded. - if ( - input.model.providerID.includes("github-copilot") && - Object.keys(tools).length === 0 && - hasToolCalls(input.messages) - ) { - 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 sortedTools = Object.fromEntries(Object.entries(tools).toSorted(([a], [b]) => a.localeCompare(b))) + const prepared = yield* LLMRequestPrep.prepare({ + ...input, + provider: item, + auth: info, + plugin, + flags, + isWorkflow, + }) // Wire up toolExecutor for DWS workflow models so that tool calls // from the workflow service are executed via opencode's tool system @@ -234,9 +119,9 @@ const live: Layer.Layer< approvalHandler?: (approvalTools: { name: string; args: string }[]) => Promise<{ approved: boolean }> } workflowModel.sessionID = input.sessionID - workflowModel.systemPrompt = system.join("\n") + workflowModel.systemPrompt = prepared.system.join("\n") workflowModel.toolExecutor = async (toolName, argsJson, _requestID) => { - const t = sortedTools[toolName] + const t = prepared.tools[toolName] if (!t || !t.execute) { return { result: "", error: `Unknown tool: ${toolName}` } } @@ -258,7 +143,7 @@ const live: Layer.Layer< } const ruleset = Permission.merge(input.agent.permission ?? [], input.permission ?? []) - workflowModel.sessionPreapprovedTools = Object.keys(sortedTools).filter((name) => { + workflowModel.sessionPreapprovedTools = Object.keys(prepared.tools).filter((name) => { const match = ruleset.findLast((rule) => Wildcard.match(name, rule.permission)) return !match || match.action !== "ask" }) @@ -327,28 +212,6 @@ const live: Layer.Layer< }) : undefined - const opencodeProjectID = input.model.providerID.startsWith("opencode") - ? (yield* InstanceState.context).project.id - : undefined - - const requestHeaders = { - ...(input.model.providerID.startsWith("opencode") - ? { - ...(opencodeProjectID ? { "x-opencode-project": opencodeProjectID } : {}), - "x-opencode-session": input.sessionID, - "x-opencode-request": input.user.id, - "x-opencode-client": flags.client, - "User-Agent": `opencode/${InstallationVersion}`, - } - : { - "x-session-affinity": input.sessionID, - ...(input.parentSessionID ? { "x-parent-session-id": input.parentSessionID } : {}), - "User-Agent": `opencode/${InstallationVersion}`, - }), - ...input.model.headers, - ...headers, - } - // 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) { @@ -357,17 +220,17 @@ const live: Layer.Layer< provider: item, auth: info, llmClient, - isOpenaiOauth, - system, - messages, - tools: sortedTools, + isOpenaiOauth: prepared.isOpenaiOauth, + system: prepared.system, + messages: prepared.messages, + tools: prepared.tools, toolChoice: input.toolChoice, - temperature: params.temperature, - topP: params.topP, - topK: params.topK, - maxOutputTokens: params.maxOutputTokens, - providerOptions: params.options, - headers: requestHeaders, + 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") { @@ -413,7 +276,7 @@ const live: Layer.Layer< }, async experimental_repairToolCall(failed) { const lower = failed.toolCall.toolName.toLowerCase() - if (lower !== failed.toolCall.toolName && sortedTools[lower]) { + if (lower !== failed.toolCall.toolName && prepared.tools[lower]) { l.info("repairing tool call", { tool: failed.toolCall.toolName, repaired: lower, @@ -432,18 +295,18 @@ const live: Layer.Layer< toolName: "invalid", } }, - temperature: params.temperature, - topP: params.topP, - topK: params.topK, - providerOptions: ProviderTransform.providerOptions(input.model, params.options), - activeTools: Object.keys(sortedTools).filter((x) => x !== "invalid"), - tools: sortedTools, + 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: params.maxOutputTokens, + maxOutputTokens: prepared.params.maxOutputTokens, abortSignal: input.abort, - headers: requestHeaders, + headers: prepared.headers, maxRetries: input.retries ?? 0, - messages, + messages: prepared.messages, model: wrapLanguageModel({ model: language, middleware: [ @@ -452,7 +315,11 @@ const live: Layer.Layer< async transformParams(args) { if (args.type === "stream") { // @ts-expect-error - args.params.prompt = ProviderTransform.message(args.params.prompt, input.model, options) + args.params.prompt = ProviderTransform.message( + args.params.prompt, + input.model, + prepared.messageTransformOptions, + ) } return args.params }, @@ -517,24 +384,6 @@ export const defaultLayer = Layer.suspend(() => ), ) -function resolveTools(input: Pick) { - 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)) -} - -// Check if messages contain any tool-call content -// Used to determine if a dummy tool should be added (GitHub Copilot only; see stream()). -export function hasToolCalls(messages: ModelMessage[]): boolean { - for (const msg of messages) { - if (!Array.isArray(msg.content)) continue - for (const part of msg.content) { - if (part.type === "tool-call" || part.type === "tool-result") return true - } - } - return false -} +export const hasToolCalls = LLMRequestPrep.hasToolCalls export * as LLM from "./llm" diff --git a/packages/opencode/src/session/llm/request.ts b/packages/opencode/src/session/llm/request.ts new file mode 100644 index 0000000000..97a539eadc --- /dev/null +++ b/packages/opencode/src/session/llm/request.ts @@ -0,0 +1,208 @@ +import type { Auth } from "@/auth" +import type { RuntimeFlags } from "@/effect/runtime-flags" +import { InstanceState } from "@/effect/instance-state" +import { Permission } from "@/permission" +import type { Agent } from "@/agent/agent" +import type { MessageV2 } from "../message-v2" +import type { Provider } from "@/provider/provider" +import { ProviderTransform } from "@/provider/transform" +import { SystemPrompt } from "../system" +import { InstallationVersion } from "@opencode-ai/core/installation/version" +import { Effect, Record } from "effect" +import { jsonSchema, tool as aiTool, type ModelMessage, type Tool } from "ai" +import type { Plugin } from "@/plugin" +import { mergeDeep } from "remeda" + +const USER_AGENT = `opencode/${InstallationVersion}` + +type PrepareInput = { + readonly user: MessageV2.User + readonly sessionID: string + readonly parentSessionID?: string + readonly model: Provider.Model + readonly agent: Agent.Info + readonly permission?: Permission.Ruleset + readonly system: string[] + readonly messages: ModelMessage[] + readonly small?: boolean + readonly tools: Record + readonly provider: Provider.Info + readonly auth: Auth.Info | undefined + readonly plugin: Plugin.Interface + readonly flags: RuntimeFlags.Info + readonly isWorkflow: boolean +} + +export type Prepared = { + readonly isOpenaiOauth: boolean + readonly system: string[] + readonly messages: ModelMessage[] + readonly tools: Record + readonly params: { + readonly temperature?: number + readonly topP?: number + readonly topK?: number + readonly maxOutputTokens?: number + readonly options: Record + } + readonly messageTransformOptions: Record + readonly headers: Record +} + +const mergeOptions = (target: Record, source: Record | undefined): Record => + mergeDeep(target, source ?? {}) as Record + +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 (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: {}, + }, + ) + + const tools = resolveTools(input) + 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 + : undefined + + return { + isOpenaiOauth, + system, + messages, + tools: Object.fromEntries(Object.entries(tools).toSorted(([a], [b]) => a.localeCompare(b))), + params, + messageTransformOptions: options, + headers: { + ...(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, + ...(input.parentSessionID ? { "x-parent-session-id": input.parentSessionID } : {}), + "User-Agent": USER_AGENT, + }), + ...input.model.headers, + ...headers, + }, + } +}) + +function resolveTools(input: Pick) { + 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)) +} + +export function hasToolCalls(messages: ModelMessage[]): boolean { + for (const msg of messages) { + if (!Array.isArray(msg.content)) continue + for (const part of msg.content) { + if (part.type === "tool-call" || part.type === "tool-result") return true + } + } + return false +} + +export * as LLMRequestPrep from "./request"