| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292 |
- import os from "os"
- import { Installation } from "@/installation"
- import { Provider } from "@/provider/provider"
- import { Log } from "@/util/log"
- import {
- streamText,
- wrapLanguageModel,
- type ModelMessage,
- type StreamTextResult,
- type Tool,
- type ToolSet,
- extractReasoningMiddleware,
- tool,
- jsonSchema,
- } from "ai"
- import { clone, mergeDeep, pipe } from "remeda"
- import { ProviderTransform } from "@/provider/transform"
- import { Config } from "@/config/config"
- import { Instance } from "@/project/instance"
- import type { Agent } from "@/agent/agent"
- import type { MessageV2 } from "./message-v2"
- import { Plugin } from "@/plugin"
- import { SystemPrompt } from "./system"
- import { Flag } from "@/flag/flag"
- import { PermissionNext } from "@/permission/next"
- import { Auth } from "@/auth"
- export namespace LLM {
- const log = Log.create({ service: "llm" })
- export const OUTPUT_TOKEN_MAX = Flag.OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX || 32_000
- export type StreamInput = {
- user: MessageV2.User
- sessionID: string
- model: Provider.Model
- agent: Agent.Info
- system: string[]
- abort: AbortSignal
- messages: ModelMessage[]
- small?: boolean
- tools: Record<string, Tool>
- retries?: number
- }
- export type StreamOutput = StreamTextResult<ToolSet, unknown>
- export async function stream(input: StreamInput) {
- const l = log
- .clone()
- .tag("providerID", input.model.providerID)
- .tag("modelID", input.model.id)
- .tag("sessionID", input.sessionID)
- .tag("small", (input.small ?? false).toString())
- .tag("agent", input.agent.name)
- .tag("mode", input.agent.mode)
- l.info("stream", {
- modelID: input.model.id,
- providerID: input.model.providerID,
- })
- const [language, cfg, provider, auth] = await Promise.all([
- Provider.getLanguage(input.model),
- Config.get(),
- Provider.getProvider(input.model.providerID),
- Auth.get(input.model.providerID),
- ])
- const isCodex = provider.id === "openai" && auth?.type === "oauth"
- const system = []
- system.push(
- [
- // use agent prompt otherwise provider prompt
- // For Codex sessions, skip SystemPrompt.provider() since it's sent via options.instructions
- ...(input.agent.prompt ? [input.agent.prompt] : isCodex ? [] : SystemPrompt.provider(input.model)),
- // any custom prompt passed into this call
- ...input.system,
- // any custom prompt from last user message
- ...(input.user.system ? [input.user.system] : []),
- ]
- .filter((x) => x)
- .join("\n"),
- )
- const header = system[0]
- const original = clone(system)
- await Plugin.trigger(
- "experimental.chat.system.transform",
- { sessionID: input.sessionID, model: input.model },
- { system },
- )
- if (system.length === 0) {
- system.push(...original)
- }
- // rejoin to maintain 2-part structure for caching if header unchanged
- 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.variant ? input.model.variants[input.user.variant] : {}
- const base = input.small
- ? ProviderTransform.smallOptions(input.model)
- : ProviderTransform.options({
- model: input.model,
- sessionID: input.sessionID,
- providerOptions: provider.options,
- })
- const options: Record<string, any> = pipe(
- base,
- mergeDeep(input.model.options),
- mergeDeep(input.agent.options),
- mergeDeep(variant),
- )
- if (isCodex) {
- options.instructions = SystemPrompt.instructions()
- }
- const params = await Plugin.trigger(
- "chat.params",
- {
- sessionID: input.sessionID,
- agent: input.agent,
- model: input.model,
- 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),
- options,
- },
- )
- const { headers } = await Plugin.trigger(
- "chat.headers",
- {
- sessionID: input.sessionID,
- agent: input.agent,
- model: input.model,
- provider,
- message: input.user,
- },
- {
- headers: {},
- },
- )
- const maxOutputTokens = isCodex
- ? undefined
- : ProviderTransform.maxOutputTokens(
- input.model.api.npm,
- params.options,
- input.model.limit.output,
- OUTPUT_TOKEN_MAX,
- )
- const tools = await resolveTools(input)
- // LiteLLM and some Anthropic proxies require the tools parameter to be present
- // when message history contains tool calls, even if no tools are being used.
- // Add a dummy tool that is never called to satisfy this validation.
- // This is enabled for:
- // 1. Providers with "litellm" in their ID or API ID (auto-detected)
- // 2. Providers with explicit "litellmProxy: true" option (opt-in for custom gateways)
- const isLiteLLMProxy =
- provider.options?.["litellmProxy"] === true ||
- input.model.providerID.toLowerCase().includes("litellm") ||
- input.model.api.id.toLowerCase().includes("litellm")
- if (isLiteLLMProxy && Object.keys(tools).length === 0 && hasToolCalls(input.messages)) {
- tools["_noop"] = tool({
- description:
- "Placeholder for LiteLLM/Anthropic proxy compatibility - required when message history contains tool calls but no active tools are needed",
- inputSchema: jsonSchema({ type: "object", properties: {} }),
- execute: async () => ({ output: "", title: "", metadata: {} }),
- })
- }
- return streamText({
- onError(error) {
- l.error("stream error", {
- error,
- })
- },
- async experimental_repairToolCall(failed) {
- const lower = failed.toolCall.toolName.toLowerCase()
- if (lower !== failed.toolCall.toolName && tools[lower]) {
- l.info("repairing tool call", {
- tool: failed.toolCall.toolName,
- repaired: lower,
- })
- return {
- ...failed.toolCall,
- toolName: lower,
- }
- }
- return {
- ...failed.toolCall,
- input: JSON.stringify({
- tool: failed.toolCall.toolName,
- error: failed.error.message,
- }),
- toolName: "invalid",
- }
- },
- temperature: params.temperature,
- topP: params.topP,
- topK: params.topK,
- providerOptions: ProviderTransform.providerOptions(input.model, params.options),
- activeTools: Object.keys(tools).filter((x) => x !== "invalid"),
- tools,
- maxOutputTokens,
- abortSignal: input.abort,
- headers: {
- ...(input.model.providerID.startsWith("opencode")
- ? {
- "x-opencode-project": Instance.project.id,
- "x-opencode-session": input.sessionID,
- "x-opencode-request": input.user.id,
- "x-opencode-client": Flag.OPENCODE_CLIENT,
- }
- : input.model.providerID !== "anthropic"
- ? {
- "User-Agent": `opencode/${Installation.VERSION}`,
- }
- : undefined),
- ...input.model.headers,
- ...headers,
- },
- maxRetries: input.retries ?? 0,
- messages: [
- ...(isCodex
- ? [
- {
- role: "user",
- content: system.join("\n\n"),
- } as ModelMessage,
- ]
- : system.map(
- (x): ModelMessage => ({
- role: "system",
- content: x,
- }),
- )),
- ...input.messages,
- ],
- model: wrapLanguageModel({
- model: language,
- middleware: [
- {
- async transformParams(args) {
- if (args.type === "stream") {
- // @ts-expect-error
- args.params.prompt = ProviderTransform.message(args.params.prompt, input.model, options)
- }
- return args.params
- },
- },
- extractReasoningMiddleware({ tagName: "think", startWithReasoning: false }),
- ],
- }),
- experimental_telemetry: { isEnabled: cfg.experimental?.openTelemetry },
- })
- }
- async function resolveTools(input: Pick<StreamInput, "tools" | "agent" | "user">) {
- const disabled = PermissionNext.disabled(Object.keys(input.tools), input.agent.permission)
- for (const tool of Object.keys(input.tools)) {
- if (input.user.tools?.[tool] === false || disabled.has(tool)) {
- delete input.tools[tool]
- }
- }
- return input.tools
- }
- // Check if messages contain any tool-call content
- // Used to determine if a dummy tool should be added for LiteLLM proxy compatibility
- 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
- }
- }
|