request.ts 6.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208
  1. import type { Auth } from "@/auth"
  2. import type { RuntimeFlags } from "@/effect/runtime-flags"
  3. import { InstanceState } from "@/effect/instance-state"
  4. import { Permission } from "@/permission"
  5. import type { Agent } from "@/agent/agent"
  6. import type { MessageV2 } from "../message-v2"
  7. import type { Provider } from "@/provider/provider"
  8. import { ProviderTransform } from "@/provider/transform"
  9. import { SystemPrompt } from "../system"
  10. import { InstallationVersion } from "@opencode-ai/core/installation/version"
  11. import { Effect, Record } from "effect"
  12. import { jsonSchema, tool as aiTool, type ModelMessage, type Tool } from "ai"
  13. import type { Plugin } from "@/plugin"
  14. import { mergeDeep } from "remeda"
  15. const USER_AGENT = `opencode/${InstallationVersion}`
  16. type PrepareInput = {
  17. readonly user: MessageV2.User
  18. readonly sessionID: string
  19. readonly parentSessionID?: string
  20. readonly model: Provider.Model
  21. readonly agent: Agent.Info
  22. readonly permission?: Permission.Ruleset
  23. readonly system: string[]
  24. readonly messages: ModelMessage[]
  25. readonly small?: boolean
  26. readonly tools: Record<string, Tool>
  27. readonly provider: Provider.Info
  28. readonly auth: Auth.Info | undefined
  29. readonly plugin: Plugin.Interface
  30. readonly flags: RuntimeFlags.Info
  31. readonly isWorkflow: boolean
  32. }
  33. export type Prepared = {
  34. readonly isOpenaiOauth: boolean
  35. readonly system: string[]
  36. readonly messages: ModelMessage[]
  37. readonly tools: Record<string, Tool>
  38. readonly params: {
  39. readonly temperature?: number
  40. readonly topP?: number
  41. readonly topK?: number
  42. readonly maxOutputTokens?: number
  43. readonly options: Record<string, any>
  44. }
  45. readonly messageTransformOptions: Record<string, any>
  46. readonly headers: Record<string, string>
  47. }
  48. const mergeOptions = (target: Record<string, any>, source: Record<string, any> | undefined): Record<string, any> =>
  49. mergeDeep(target, source ?? {}) as Record<string, any>
  50. export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: PrepareInput) {
  51. const isOpenaiOauth = input.provider.id === "openai" && input.auth?.type === "oauth"
  52. const system = [
  53. [
  54. ...(input.agent.prompt ? [input.agent.prompt] : SystemPrompt.provider(input.model)),
  55. ...input.system,
  56. ...(input.user.system ? [input.user.system] : []),
  57. ]
  58. .filter((x) => x)
  59. .join("\n"),
  60. ]
  61. const header = system[0]
  62. yield* input.plugin.trigger(
  63. "experimental.chat.system.transform",
  64. { sessionID: input.sessionID, model: input.model },
  65. { system },
  66. )
  67. if (system.length > 2 && system[0] === header) {
  68. const rest = system.slice(1)
  69. system.length = 0
  70. system.push(header, rest.join("\n"))
  71. }
  72. const variant =
  73. !input.small && input.model.variants && input.user.model.variant
  74. ? input.model.variants[input.user.model.variant]
  75. : {}
  76. const base = input.small
  77. ? ProviderTransform.smallOptions(input.model)
  78. : ProviderTransform.options({
  79. model: input.model,
  80. sessionID: input.sessionID,
  81. providerOptions: input.provider.options,
  82. })
  83. const options = mergeOptions(mergeOptions(mergeOptions(base, input.model.options), input.agent.options), variant)
  84. if (isOpenaiOauth) options.instructions = system.join("\n")
  85. const messages =
  86. isOpenaiOauth || input.isWorkflow
  87. ? input.messages
  88. : [
  89. ...system.map(
  90. (x): ModelMessage => ({
  91. role: "system",
  92. content: x,
  93. }),
  94. ),
  95. ...input.messages,
  96. ]
  97. const params = yield* input.plugin.trigger(
  98. "chat.params",
  99. {
  100. sessionID: input.sessionID,
  101. agent: input.agent.name,
  102. model: input.model,
  103. provider: input.provider,
  104. message: input.user,
  105. },
  106. {
  107. temperature: input.model.capabilities.temperature
  108. ? (input.agent.temperature ?? ProviderTransform.temperature(input.model))
  109. : undefined,
  110. topP: input.agent.topP ?? ProviderTransform.topP(input.model),
  111. topK: ProviderTransform.topK(input.model),
  112. maxOutputTokens: ProviderTransform.maxOutputTokens(input.model, input.flags.outputTokenMax),
  113. options,
  114. },
  115. )
  116. const { headers } = yield* input.plugin.trigger(
  117. "chat.headers",
  118. {
  119. sessionID: input.sessionID,
  120. agent: input.agent.name,
  121. model: input.model,
  122. provider: input.provider,
  123. message: input.user,
  124. },
  125. {
  126. headers: {},
  127. },
  128. )
  129. const tools = resolveTools(input)
  130. if (
  131. input.model.providerID.includes("github-copilot") &&
  132. Object.keys(tools).length === 0 &&
  133. hasToolCalls(input.messages)
  134. ) {
  135. // Copilot needs a tools field when replaying prior tool calls, even if no tools are currently enabled.
  136. tools["_noop"] = aiTool({
  137. description: "Do not call this tool. It exists only for API compatibility and must never be invoked.",
  138. inputSchema: jsonSchema({
  139. type: "object",
  140. properties: {
  141. reason: { type: "string", description: "Unused" },
  142. },
  143. }),
  144. execute: async () => ({ output: "", title: "", metadata: {} }),
  145. })
  146. }
  147. const opencodeProjectID = input.model.providerID.startsWith("opencode")
  148. ? (yield* InstanceState.context).project.id
  149. : undefined
  150. return {
  151. isOpenaiOauth,
  152. system,
  153. messages,
  154. tools: Object.fromEntries(Object.entries(tools).toSorted(([a], [b]) => a.localeCompare(b))),
  155. params,
  156. messageTransformOptions: options,
  157. headers: {
  158. ...(input.model.providerID.startsWith("opencode")
  159. ? {
  160. ...(opencodeProjectID ? { "x-opencode-project": opencodeProjectID } : {}),
  161. "x-opencode-session": input.sessionID,
  162. "x-opencode-request": input.user.id,
  163. "x-opencode-client": input.flags.client,
  164. "User-Agent": USER_AGENT,
  165. }
  166. : {
  167. "x-session-affinity": input.sessionID,
  168. ...(input.parentSessionID ? { "x-parent-session-id": input.parentSessionID } : {}),
  169. "User-Agent": USER_AGENT,
  170. }),
  171. ...input.model.headers,
  172. ...headers,
  173. },
  174. }
  175. })
  176. function resolveTools(input: Pick<PrepareInput, "tools" | "agent" | "permission" | "user">) {
  177. const disabled = Permission.disabled(
  178. Object.keys(input.tools),
  179. Permission.merge(input.agent.permission, input.permission ?? []),
  180. )
  181. return Record.filter(input.tools, (_, k) => input.user.tools?.[k] !== false && !disabled.has(k))
  182. }
  183. export function hasToolCalls(messages: ModelMessage[]): boolean {
  184. for (const msg of messages) {
  185. if (!Array.isArray(msg.content)) continue
  186. for (const part of msg.content) {
  187. if (part.type === "tool-call" || part.type === "tool-result") return true
  188. }
  189. }
  190. return false
  191. }
  192. export * as LLMRequestPrep from "./request"