llm.ts 9.2 KB

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