llm.ts 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298
  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 ? undefined : undefined
  143. log.info("max_output_tokens", {
  144. tokens: ProviderTransform.maxOutputTokens(
  145. input.model.api.npm,
  146. params.options,
  147. input.model.limit.output,
  148. OUTPUT_TOKEN_MAX,
  149. ),
  150. modelOptions: params.options,
  151. outputLimit: input.model.limit.output,
  152. })
  153. // tokens = 32000
  154. // outputLimit = 64000
  155. // modelOptions={"reasoningEffort":"minimal"}
  156. const tools = await resolveTools(input)
  157. // LiteLLM and some Anthropic proxies require the tools parameter to be present
  158. // when message history contains tool calls, even if no tools are being used.
  159. // Add a dummy tool that is never called to satisfy this validation.
  160. // This is enabled for:
  161. // 1. Providers with "litellm" in their ID or API ID (auto-detected)
  162. // 2. Providers with explicit "litellmProxy: true" option (opt-in for custom gateways)
  163. const isLiteLLMProxy =
  164. provider.options?.["litellmProxy"] === true ||
  165. input.model.providerID.toLowerCase().includes("litellm") ||
  166. input.model.api.id.toLowerCase().includes("litellm")
  167. if (isLiteLLMProxy && Object.keys(tools).length === 0 && hasToolCalls(input.messages)) {
  168. tools["_noop"] = tool({
  169. description:
  170. "Placeholder for LiteLLM/Anthropic proxy compatibility - required when message history contains tool calls but no active tools are needed",
  171. inputSchema: jsonSchema({ type: "object", properties: {} }),
  172. execute: async () => ({ output: "", title: "", metadata: {} }),
  173. })
  174. }
  175. return streamText({
  176. onError(error) {
  177. l.error("stream error", {
  178. error,
  179. })
  180. },
  181. async experimental_repairToolCall(failed) {
  182. const lower = failed.toolCall.toolName.toLowerCase()
  183. if (lower !== failed.toolCall.toolName && tools[lower]) {
  184. l.info("repairing tool call", {
  185. tool: failed.toolCall.toolName,
  186. repaired: lower,
  187. })
  188. return {
  189. ...failed.toolCall,
  190. toolName: lower,
  191. }
  192. }
  193. return {
  194. ...failed.toolCall,
  195. input: JSON.stringify({
  196. tool: failed.toolCall.toolName,
  197. error: failed.error.message,
  198. }),
  199. toolName: "invalid",
  200. }
  201. },
  202. temperature: params.temperature,
  203. topP: params.topP,
  204. topK: params.topK,
  205. providerOptions: ProviderTransform.providerOptions(input.model, params.options),
  206. activeTools: Object.keys(tools).filter((x) => x !== "invalid"),
  207. tools,
  208. maxOutputTokens,
  209. abortSignal: input.abort,
  210. headers: {
  211. ...(input.model.providerID.startsWith("opencode")
  212. ? {
  213. "x-opencode-project": Instance.project.id,
  214. "x-opencode-session": input.sessionID,
  215. "x-opencode-request": input.user.id,
  216. "x-opencode-client": Flag.OPENCODE_CLIENT,
  217. }
  218. : input.model.providerID !== "anthropic"
  219. ? {
  220. "User-Agent": `opencode/${Installation.VERSION}`,
  221. }
  222. : undefined),
  223. ...input.model.headers,
  224. ...headers,
  225. },
  226. maxRetries: input.retries ?? 0,
  227. messages: [
  228. ...(isCodex
  229. ? [
  230. {
  231. role: "user",
  232. content: system.join("\n\n"),
  233. } as ModelMessage,
  234. ]
  235. : system.map(
  236. (x): ModelMessage => ({
  237. role: "system",
  238. content: x,
  239. }),
  240. )),
  241. ...input.messages,
  242. ],
  243. model: wrapLanguageModel({
  244. model: language,
  245. middleware: [
  246. {
  247. async transformParams(args) {
  248. if (args.type === "stream") {
  249. // @ts-expect-error
  250. args.params.prompt = ProviderTransform.message(args.params.prompt, input.model, options)
  251. }
  252. return args.params
  253. },
  254. },
  255. extractReasoningMiddleware({ tagName: "think", startWithReasoning: false }),
  256. ],
  257. }),
  258. experimental_telemetry: { isEnabled: cfg.experimental?.openTelemetry },
  259. })
  260. }
  261. async function resolveTools(input: Pick<StreamInput, "tools" | "agent" | "user">) {
  262. const disabled = PermissionNext.disabled(Object.keys(input.tools), input.agent.permission)
  263. for (const tool of Object.keys(input.tools)) {
  264. if (input.user.tools?.[tool] === false || disabled.has(tool)) {
  265. delete input.tools[tool]
  266. }
  267. }
  268. return input.tools
  269. }
  270. // Check if messages contain any tool-call content
  271. // Used to determine if a dummy tool should be added for LiteLLM proxy compatibility
  272. export function hasToolCalls(messages: ModelMessage[]): boolean {
  273. for (const msg of messages) {
  274. if (!Array.isArray(msg.content)) continue
  275. for (const part of msg.content) {
  276. if (part.type === "tool-call" || part.type === "tool-result") return true
  277. }
  278. }
  279. return false
  280. }
  281. }