refactor(opencode): extract session LLM request prep (#28560)
This commit is contained in:
@@ -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<string, Tool>
|
||||
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<string, Tool>
|
||||
readonly params: {
|
||||
readonly temperature?: number
|
||||
readonly topP?: number
|
||||
readonly topK?: number
|
||||
readonly maxOutputTokens?: number
|
||||
readonly options: Record<string, any>
|
||||
}
|
||||
readonly messageTransformOptions: Record<string, any>
|
||||
readonly headers: Record<string, string>
|
||||
}
|
||||
|
||||
const mergeOptions = (target: Record<string, any>, source: Record<string, any> | undefined): Record<string, any> =>
|
||||
mergeDeep(target, source ?? {}) as Record<string, any>
|
||||
|
||||
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<PrepareInput, "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))
|
||||
}
|
||||
|
||||
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"
|
||||
Reference in New Issue
Block a user