Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
231 changes: 40 additions & 191 deletions packages/opencode/src/session/llm.ts
Original file line number Diff line number Diff line change
@@ -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<string, any>, source: Record<string, any> | undefined): Record<string, any> =>
mergeDeep(target, source ?? {}) as Record<string, any>

export type StreamInput = {
user: MessageV2.User
sessionID: string
Expand Down Expand Up @@ -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
Expand All @@ -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}` }
}
Expand All @@ -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"
})
Expand Down Expand Up @@ -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) {
Expand All @@ -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") {
Expand Down Expand Up @@ -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,
Expand All @@ -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: [
Expand All @@ -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
},
Expand Down Expand Up @@ -517,24 +384,6 @@ export const defaultLayer = Layer.suspend(() =>
),
)

function resolveTools(input: Pick<StreamInput, "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))
}

// 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"
Loading
Loading