llm.ts 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289
  1. import { Installation } from "@/installation"
  2. import { Provider } from "@/provider/provider"
  3. import { Log } from "@/util/log"
  4. import {
  5. streamText,
  6. wrapLanguageModel,
  7. type ModelMessage,
  8. type StreamTextResult,
  9. type Tool,
  10. type ToolSet,
  11. tool,
  12. jsonSchema,
  13. } from "ai"
  14. import { clone, mergeDeep, pipe } from "remeda"
  15. import { ProviderTransform } from "@/provider/transform"
  16. import { Config } from "@/config/config"
  17. import { Instance } from "@/project/instance"
  18. import type { Agent } from "@/agent/agent"
  19. import type { MessageV2 } from "./message-v2"
  20. import { Plugin } from "@/plugin"
  21. import { SystemPrompt } from "./system"
  22. import { Flag } from "@/flag/flag"
  23. import { PermissionNext } from "@/permission/next"
  24. import { Auth } from "@/auth"
  25. export namespace LLM {
  26. const log = Log.create({ service: "llm" })
  27. export const OUTPUT_TOKEN_MAX = Flag.OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX || 32_000
  28. export type StreamInput = {
  29. user: MessageV2.User
  30. sessionID: string
  31. model: Provider.Model
  32. agent: Agent.Info
  33. system: string[]
  34. abort: AbortSignal
  35. messages: ModelMessage[]
  36. small?: boolean
  37. tools: Record<string, Tool>
  38. retries?: number
  39. }
  40. export type StreamOutput = StreamTextResult<ToolSet, unknown>
  41. export async function stream(input: StreamInput) {
  42. const l = log
  43. .clone()
  44. .tag("providerID", input.model.providerID)
  45. .tag("modelID", input.model.id)
  46. .tag("sessionID", input.sessionID)
  47. .tag("small", (input.small ?? false).toString())
  48. .tag("agent", input.agent.name)
  49. .tag("mode", input.agent.mode)
  50. l.info("stream", {
  51. modelID: input.model.id,
  52. providerID: input.model.providerID,
  53. })
  54. const [language, cfg, provider, auth] = await Promise.all([
  55. Provider.getLanguage(input.model),
  56. Config.get(),
  57. Provider.getProvider(input.model.providerID),
  58. Auth.get(input.model.providerID),
  59. ])
  60. const isCodex = provider.id === "openai" && auth?.type === "oauth"
  61. const system = []
  62. system.push(
  63. [
  64. // use agent prompt otherwise provider prompt
  65. // For Codex sessions, skip SystemPrompt.provider() since it's sent via options.instructions
  66. ...(input.agent.prompt ? [input.agent.prompt] : isCodex ? [] : SystemPrompt.provider(input.model)),
  67. // any custom prompt passed into this call
  68. ...input.system,
  69. // any custom prompt from last user message
  70. ...(input.user.system ? [input.user.system] : []),
  71. ]
  72. .filter((x) => x)
  73. .join("\n"),
  74. )
  75. const header = system[0]
  76. const original = clone(system)
  77. await Plugin.trigger(
  78. "experimental.chat.system.transform",
  79. { sessionID: input.sessionID, model: input.model },
  80. { system },
  81. )
  82. if (system.length === 0) {
  83. system.push(...original)
  84. }
  85. // rejoin to maintain 2-part structure for caching if header unchanged
  86. if (system.length > 2 && system[0] === header) {
  87. const rest = system.slice(1)
  88. system.length = 0
  89. system.push(header, rest.join("\n"))
  90. }
  91. const variant =
  92. !input.small && input.model.variants && input.user.variant ? input.model.variants[input.user.variant] : {}
  93. const base = input.small
  94. ? ProviderTransform.smallOptions(input.model)
  95. : ProviderTransform.options({
  96. model: input.model,
  97. sessionID: input.sessionID,
  98. providerOptions: provider.options,
  99. })
  100. const options: Record<string, any> = pipe(
  101. base,
  102. mergeDeep(input.model.options),
  103. mergeDeep(input.agent.options),
  104. mergeDeep(variant),
  105. )
  106. if (isCodex) {
  107. options.instructions = SystemPrompt.instructions()
  108. }
  109. const params = await Plugin.trigger(
  110. "chat.params",
  111. {
  112. sessionID: input.sessionID,
  113. agent: input.agent,
  114. model: input.model,
  115. provider,
  116. message: input.user,
  117. },
  118. {
  119. temperature: input.model.capabilities.temperature
  120. ? (input.agent.temperature ?? ProviderTransform.temperature(input.model))
  121. : undefined,
  122. topP: input.agent.topP ?? ProviderTransform.topP(input.model),
  123. topK: ProviderTransform.topK(input.model),
  124. options,
  125. },
  126. )
  127. const { headers } = await Plugin.trigger(
  128. "chat.headers",
  129. {
  130. sessionID: input.sessionID,
  131. agent: input.agent,
  132. model: input.model,
  133. provider,
  134. message: input.user,
  135. },
  136. {
  137. headers: {},
  138. },
  139. )
  140. const maxOutputTokens =
  141. isCodex || provider.id.includes("github-copilot")
  142. ? undefined
  143. : ProviderTransform.maxOutputTokens(
  144. input.model.api.npm,
  145. params.options,
  146. input.model.limit.output,
  147. OUTPUT_TOKEN_MAX,
  148. )
  149. const tools = await resolveTools(input)
  150. // LiteLLM and some Anthropic proxies require the tools parameter to be present
  151. // when message history contains tool calls, even if no tools are being used.
  152. // Add a dummy tool that is never called to satisfy this validation.
  153. // This is enabled for:
  154. // 1. Providers with "litellm" in their ID or API ID (auto-detected)
  155. // 2. Providers with explicit "litellmProxy: true" option (opt-in for custom gateways)
  156. const isLiteLLMProxy =
  157. provider.options?.["litellmProxy"] === true ||
  158. input.model.providerID.toLowerCase().includes("litellm") ||
  159. input.model.api.id.toLowerCase().includes("litellm")
  160. if (isLiteLLMProxy && Object.keys(tools).length === 0 && hasToolCalls(input.messages)) {
  161. tools["_noop"] = tool({
  162. description:
  163. "Placeholder for LiteLLM/Anthropic proxy compatibility - required when message history contains tool calls but no active tools are needed",
  164. inputSchema: jsonSchema({ type: "object", properties: {} }),
  165. execute: async () => ({ output: "", title: "", metadata: {} }),
  166. })
  167. }
  168. return streamText({
  169. onError(error) {
  170. l.error("stream error", {
  171. error,
  172. })
  173. },
  174. async experimental_repairToolCall(failed) {
  175. const lower = failed.toolCall.toolName.toLowerCase()
  176. if (lower !== failed.toolCall.toolName && tools[lower]) {
  177. l.info("repairing tool call", {
  178. tool: failed.toolCall.toolName,
  179. repaired: lower,
  180. })
  181. return {
  182. ...failed.toolCall,
  183. toolName: lower,
  184. }
  185. }
  186. return {
  187. ...failed.toolCall,
  188. input: JSON.stringify({
  189. tool: failed.toolCall.toolName,
  190. error: failed.error.message,
  191. }),
  192. toolName: "invalid",
  193. }
  194. },
  195. temperature: params.temperature,
  196. topP: params.topP,
  197. topK: params.topK,
  198. providerOptions: ProviderTransform.providerOptions(input.model, params.options),
  199. activeTools: Object.keys(tools).filter((x) => x !== "invalid"),
  200. tools,
  201. maxOutputTokens,
  202. abortSignal: input.abort,
  203. headers: {
  204. ...(input.model.providerID.startsWith("opencode")
  205. ? {
  206. "x-opencode-project": Instance.project.id,
  207. "x-opencode-session": input.sessionID,
  208. "x-opencode-request": input.user.id,
  209. "x-opencode-client": Flag.OPENCODE_CLIENT,
  210. }
  211. : input.model.providerID !== "anthropic"
  212. ? {
  213. "User-Agent": `opencode/${Installation.VERSION}`,
  214. }
  215. : undefined),
  216. ...input.model.headers,
  217. ...headers,
  218. },
  219. maxRetries: input.retries ?? 0,
  220. messages: [
  221. ...system.map(
  222. (x): ModelMessage => ({
  223. role: "system",
  224. content: x,
  225. }),
  226. ),
  227. ...input.messages,
  228. ],
  229. model: wrapLanguageModel({
  230. model: language,
  231. middleware: [
  232. {
  233. async transformParams(args) {
  234. if (args.type === "stream") {
  235. // @ts-expect-error
  236. args.params.prompt = ProviderTransform.message(args.params.prompt, input.model, options)
  237. }
  238. return args.params
  239. },
  240. },
  241. ],
  242. }),
  243. experimental_telemetry: {
  244. isEnabled: cfg.experimental?.openTelemetry,
  245. metadata: {
  246. userId: cfg.username ?? "unknown",
  247. sessionId: input.sessionID,
  248. },
  249. },
  250. })
  251. }
  252. async function resolveTools(input: Pick<StreamInput, "tools" | "agent" | "user">) {
  253. const disabled = PermissionNext.disabled(Object.keys(input.tools), input.agent.permission)
  254. for (const tool of Object.keys(input.tools)) {
  255. if (input.user.tools?.[tool] === false || disabled.has(tool)) {
  256. delete input.tools[tool]
  257. }
  258. }
  259. return input.tools
  260. }
  261. // Check if messages contain any tool-call content
  262. // Used to determine if a dummy tool should be added for LiteLLM proxy compatibility
  263. export function hasToolCalls(messages: ModelMessage[]): boolean {
  264. for (const msg of messages) {
  265. if (!Array.isArray(msg.content)) continue
  266. for (const part of msg.content) {
  267. if (part.type === "tool-call" || part.type === "tool-result") return true
  268. }
  269. }
  270. return false
  271. }
  272. }