mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-06 09:10:47 -04:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ecf27f389 | |||
| 548446a990 |
@@ -1,41 +1,34 @@
|
|||||||
import { Provider } from "@/provider/provider"
|
import { Provider } from "@/provider/provider"
|
||||||
import * as Log from "@opencode-ai/core/util/log"
|
import * as Log from "@opencode-ai/core/util/log"
|
||||||
import { Context, Effect, Layer, Record } from "effect"
|
import { Context, Effect, Layer } from "effect"
|
||||||
import * as Stream from "effect/Stream"
|
import * as Stream from "effect/Stream"
|
||||||
import { streamText, wrapLanguageModel, type ModelMessage, type Tool, tool as aiTool, jsonSchema } from "ai"
|
import { streamText, wrapLanguageModel, type ModelMessage, type Tool } from "ai"
|
||||||
import type { LLMEvent } from "@opencode-ai/llm"
|
import type { LLMEvent } from "@opencode-ai/llm"
|
||||||
import { LLMClient, RequestExecutor, WebSocketExecutor } from "@opencode-ai/llm/route"
|
import { LLMClient, RequestExecutor, WebSocketExecutor } from "@opencode-ai/llm/route"
|
||||||
import type { LLMClientService } from "@opencode-ai/llm/route"
|
import type { LLMClientService } from "@opencode-ai/llm/route"
|
||||||
import { mergeDeep } from "remeda"
|
|
||||||
import { GitLabWorkflowLanguageModel } from "gitlab-ai-provider"
|
import { GitLabWorkflowLanguageModel } from "gitlab-ai-provider"
|
||||||
import { ProviderTransform } from "@/provider/transform"
|
import { ProviderTransform } from "@/provider/transform"
|
||||||
import { Config } from "@/config/config"
|
import { Config } from "@/config/config"
|
||||||
import { InstanceState } from "@/effect/instance-state"
|
|
||||||
import type { Agent } from "@/agent/agent"
|
import type { Agent } from "@/agent/agent"
|
||||||
import type { MessageV2 } from "./message-v2"
|
import type { MessageV2 } from "./message-v2"
|
||||||
import { Plugin } from "@/plugin"
|
import { Plugin } from "@/plugin"
|
||||||
import { SystemPrompt } from "./system"
|
|
||||||
import { Permission } from "@/permission"
|
import { Permission } from "@/permission"
|
||||||
import { PermissionID } from "@/permission/schema"
|
import { PermissionID } from "@/permission/schema"
|
||||||
import { Bus } from "@/bus"
|
import { Bus } from "@/bus"
|
||||||
import { Wildcard } from "@/util/wildcard"
|
import { Wildcard } from "@/util/wildcard"
|
||||||
import { SessionID } from "@/session/schema"
|
import { SessionID } from "@/session/schema"
|
||||||
import { Auth } from "@/auth"
|
import { Auth } from "@/auth"
|
||||||
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
|
||||||
import { EffectBridge } from "@/effect/bridge"
|
import { EffectBridge } from "@/effect/bridge"
|
||||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||||
import * as Option from "effect/Option"
|
import * as Option from "effect/Option"
|
||||||
import * as OtelTracer from "@effect/opentelemetry/Tracer"
|
import * as OtelTracer from "@effect/opentelemetry/Tracer"
|
||||||
import { LLMAISDK } from "./llm/ai-sdk"
|
import { LLMAISDK } from "./llm/ai-sdk"
|
||||||
import { LLMNativeRuntime } from "./llm/native-runtime"
|
import { LLMNativeRuntime } from "./llm/native-runtime"
|
||||||
|
import { LLMRequestPrep } from "./llm/request"
|
||||||
|
|
||||||
const log = Log.create({ service: "llm" })
|
const log = Log.create({ service: "llm" })
|
||||||
export const OUTPUT_TOKEN_MAX = ProviderTransform.OUTPUT_TOKEN_MAX
|
export const OUTPUT_TOKEN_MAX = ProviderTransform.OUTPUT_TOKEN_MAX
|
||||||
|
|
||||||
// Avoid re-instantiating remeda's deep merge types in this hot LLM path; the runtime behavior is still mergeDeep.
|
|
||||||
const mergeOptions = (target: Record<string, any>, source: Record<string, any> | undefined): Record<string, any> =>
|
|
||||||
mergeDeep(target, source ?? {}) as Record<string, any>
|
|
||||||
|
|
||||||
export type StreamInput = {
|
export type StreamInput = {
|
||||||
user: MessageV2.User
|
user: MessageV2.User
|
||||||
sessionID: string
|
sessionID: string
|
||||||
@@ -106,123 +99,15 @@ const live: Layer.Layer<
|
|||||||
{ concurrency: "unbounded" },
|
{ concurrency: "unbounded" },
|
||||||
)
|
)
|
||||||
|
|
||||||
// TODO: move this to a proper hook
|
|
||||||
const isOpenaiOauth = item.id === "openai" && info?.type === "oauth"
|
|
||||||
|
|
||||||
const system: string[] = []
|
|
||||||
system.push(
|
|
||||||
[
|
|
||||||
// use agent prompt otherwise provider prompt
|
|
||||||
...(input.agent.prompt ? [input.agent.prompt] : 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]
|
|
||||||
yield* plugin.trigger(
|
|
||||||
"experimental.chat.system.transform",
|
|
||||||
{ sessionID: input.sessionID, model: input.model },
|
|
||||||
{ system },
|
|
||||||
)
|
|
||||||
// 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.model.variant
|
|
||||||
? input.model.variants[input.user.model.variant]
|
|
||||||
: {}
|
|
||||||
const base = input.small
|
|
||||||
? ProviderTransform.smallOptions(input.model)
|
|
||||||
: ProviderTransform.options({
|
|
||||||
model: input.model,
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
providerOptions: item.options,
|
|
||||||
})
|
|
||||||
const options = mergeOptions(mergeOptions(mergeOptions(base, input.model.options), input.agent.options), variant)
|
|
||||||
if (isOpenaiOauth) {
|
|
||||||
options.instructions = system.join("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
const isWorkflow = language instanceof GitLabWorkflowLanguageModel
|
const isWorkflow = language instanceof GitLabWorkflowLanguageModel
|
||||||
const messages = isOpenaiOauth
|
const prepared = yield* LLMRequestPrep.prepare({
|
||||||
? input.messages
|
...input,
|
||||||
: isWorkflow
|
provider: item,
|
||||||
? input.messages
|
auth: info,
|
||||||
: [
|
plugin,
|
||||||
...system.map(
|
flags,
|
||||||
(x): ModelMessage => ({
|
isWorkflow,
|
||||||
role: "system",
|
})
|
||||||
content: x,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
...input.messages,
|
|
||||||
]
|
|
||||||
|
|
||||||
const params = yield* plugin.trigger(
|
|
||||||
"chat.params",
|
|
||||||
{
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
agent: input.agent.name,
|
|
||||||
model: input.model,
|
|
||||||
provider: item,
|
|
||||||
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),
|
|
||||||
maxOutputTokens: ProviderTransform.maxOutputTokens(input.model, flags.outputTokenMax),
|
|
||||||
options,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const { headers } = yield* plugin.trigger(
|
|
||||||
"chat.headers",
|
|
||||||
{
|
|
||||||
sessionID: input.sessionID,
|
|
||||||
agent: input.agent.name,
|
|
||||||
model: input.model,
|
|
||||||
provider: item,
|
|
||||||
message: input.user,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headers: {},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
const tools = resolveTools(input)
|
|
||||||
|
|
||||||
// GitHub Copilot may require the tools parameter when message history contains
|
|
||||||
// tool calls but no tools are active (e.g. compaction). Inject a stub tool that
|
|
||||||
// is never meant to be invoked. LiteLLM-backed providers are excluded.
|
|
||||||
if (
|
|
||||||
input.model.providerID.includes("github-copilot") &&
|
|
||||||
Object.keys(tools).length === 0 &&
|
|
||||||
hasToolCalls(input.messages)
|
|
||||||
) {
|
|
||||||
tools["_noop"] = aiTool({
|
|
||||||
description: "Do not call this tool. It exists only for API compatibility and must never be invoked.",
|
|
||||||
inputSchema: jsonSchema({
|
|
||||||
type: "object",
|
|
||||||
properties: {
|
|
||||||
reason: { type: "string", description: "Unused" },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
execute: async () => ({ output: "", title: "", metadata: {} }),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
const sortedTools = Object.fromEntries(Object.entries(tools).toSorted(([a], [b]) => a.localeCompare(b)))
|
|
||||||
|
|
||||||
// Wire up toolExecutor for DWS workflow models so that tool calls
|
// Wire up toolExecutor for DWS workflow models so that tool calls
|
||||||
// from the workflow service are executed via opencode's tool system
|
// from the workflow service are executed via opencode's tool system
|
||||||
@@ -234,9 +119,9 @@ const live: Layer.Layer<
|
|||||||
approvalHandler?: (approvalTools: { name: string; args: string }[]) => Promise<{ approved: boolean }>
|
approvalHandler?: (approvalTools: { name: string; args: string }[]) => Promise<{ approved: boolean }>
|
||||||
}
|
}
|
||||||
workflowModel.sessionID = input.sessionID
|
workflowModel.sessionID = input.sessionID
|
||||||
workflowModel.systemPrompt = system.join("\n")
|
workflowModel.systemPrompt = prepared.system.join("\n")
|
||||||
workflowModel.toolExecutor = async (toolName, argsJson, _requestID) => {
|
workflowModel.toolExecutor = async (toolName, argsJson, _requestID) => {
|
||||||
const t = sortedTools[toolName]
|
const t = prepared.tools[toolName]
|
||||||
if (!t || !t.execute) {
|
if (!t || !t.execute) {
|
||||||
return { result: "", error: `Unknown tool: ${toolName}` }
|
return { result: "", error: `Unknown tool: ${toolName}` }
|
||||||
}
|
}
|
||||||
@@ -258,7 +143,7 @@ const live: Layer.Layer<
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ruleset = Permission.merge(input.agent.permission ?? [], input.permission ?? [])
|
const ruleset = Permission.merge(input.agent.permission ?? [], input.permission ?? [])
|
||||||
workflowModel.sessionPreapprovedTools = Object.keys(sortedTools).filter((name) => {
|
workflowModel.sessionPreapprovedTools = Object.keys(prepared.tools).filter((name) => {
|
||||||
const match = ruleset.findLast((rule) => Wildcard.match(name, rule.permission))
|
const match = ruleset.findLast((rule) => Wildcard.match(name, rule.permission))
|
||||||
return !match || match.action !== "ask"
|
return !match || match.action !== "ask"
|
||||||
})
|
})
|
||||||
@@ -327,28 +212,6 @@ const live: Layer.Layer<
|
|||||||
})
|
})
|
||||||
: undefined
|
: undefined
|
||||||
|
|
||||||
const opencodeProjectID = input.model.providerID.startsWith("opencode")
|
|
||||||
? (yield* InstanceState.context).project.id
|
|
||||||
: undefined
|
|
||||||
|
|
||||||
const requestHeaders = {
|
|
||||||
...(input.model.providerID.startsWith("opencode")
|
|
||||||
? {
|
|
||||||
...(opencodeProjectID ? { "x-opencode-project": opencodeProjectID } : {}),
|
|
||||||
"x-opencode-session": input.sessionID,
|
|
||||||
"x-opencode-request": input.user.id,
|
|
||||||
"x-opencode-client": flags.client,
|
|
||||||
"User-Agent": `opencode/${InstallationVersion}`,
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
"x-session-affinity": input.sessionID,
|
|
||||||
...(input.parentSessionID ? { "x-parent-session-id": input.parentSessionID } : {}),
|
|
||||||
"User-Agent": `opencode/${InstallationVersion}`,
|
|
||||||
}),
|
|
||||||
...input.model.headers,
|
|
||||||
...headers,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Runtime seam: native is an opt-in adapter over @opencode-ai/llm. It
|
// Runtime seam: native is an opt-in adapter over @opencode-ai/llm. It
|
||||||
// either returns a ready LLMEvent stream or a concrete fallback reason.
|
// either returns a ready LLMEvent stream or a concrete fallback reason.
|
||||||
if (flags.experimentalNativeLlm) {
|
if (flags.experimentalNativeLlm) {
|
||||||
@@ -357,17 +220,8 @@ const live: Layer.Layer<
|
|||||||
provider: item,
|
provider: item,
|
||||||
auth: info,
|
auth: info,
|
||||||
llmClient,
|
llmClient,
|
||||||
isOpenaiOauth,
|
request: prepared,
|
||||||
system,
|
|
||||||
messages,
|
|
||||||
tools: sortedTools,
|
|
||||||
toolChoice: input.toolChoice,
|
toolChoice: input.toolChoice,
|
||||||
temperature: params.temperature,
|
|
||||||
topP: params.topP,
|
|
||||||
topK: params.topK,
|
|
||||||
maxOutputTokens: params.maxOutputTokens,
|
|
||||||
providerOptions: params.options,
|
|
||||||
headers: requestHeaders,
|
|
||||||
abort: input.abort,
|
abort: input.abort,
|
||||||
})
|
})
|
||||||
if (native.type === "supported") {
|
if (native.type === "supported") {
|
||||||
@@ -413,7 +267,7 @@ const live: Layer.Layer<
|
|||||||
},
|
},
|
||||||
async experimental_repairToolCall(failed) {
|
async experimental_repairToolCall(failed) {
|
||||||
const lower = failed.toolCall.toolName.toLowerCase()
|
const lower = failed.toolCall.toolName.toLowerCase()
|
||||||
if (lower !== failed.toolCall.toolName && sortedTools[lower]) {
|
if (lower !== failed.toolCall.toolName && prepared.tools[lower]) {
|
||||||
l.info("repairing tool call", {
|
l.info("repairing tool call", {
|
||||||
tool: failed.toolCall.toolName,
|
tool: failed.toolCall.toolName,
|
||||||
repaired: lower,
|
repaired: lower,
|
||||||
@@ -432,18 +286,18 @@ const live: Layer.Layer<
|
|||||||
toolName: "invalid",
|
toolName: "invalid",
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
temperature: params.temperature,
|
temperature: prepared.params.temperature,
|
||||||
topP: params.topP,
|
topP: prepared.params.topP,
|
||||||
topK: params.topK,
|
topK: prepared.params.topK,
|
||||||
providerOptions: ProviderTransform.providerOptions(input.model, params.options),
|
providerOptions: ProviderTransform.providerOptions(input.model, prepared.params.options),
|
||||||
activeTools: Object.keys(sortedTools).filter((x) => x !== "invalid"),
|
activeTools: Object.keys(prepared.tools).filter((x) => x !== "invalid"),
|
||||||
tools: sortedTools,
|
tools: prepared.tools,
|
||||||
toolChoice: input.toolChoice,
|
toolChoice: input.toolChoice,
|
||||||
maxOutputTokens: params.maxOutputTokens,
|
maxOutputTokens: prepared.params.maxOutputTokens,
|
||||||
abortSignal: input.abort,
|
abortSignal: input.abort,
|
||||||
headers: requestHeaders,
|
headers: prepared.headers,
|
||||||
maxRetries: input.retries ?? 0,
|
maxRetries: input.retries ?? 0,
|
||||||
messages,
|
messages: prepared.messages,
|
||||||
model: wrapLanguageModel({
|
model: wrapLanguageModel({
|
||||||
model: language,
|
model: language,
|
||||||
middleware: [
|
middleware: [
|
||||||
@@ -452,7 +306,11 @@ const live: Layer.Layer<
|
|||||||
async transformParams(args) {
|
async transformParams(args) {
|
||||||
if (args.type === "stream") {
|
if (args.type === "stream") {
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
args.params.prompt = ProviderTransform.message(args.params.prompt, input.model, options)
|
args.params.prompt = ProviderTransform.message(
|
||||||
|
args.params.prompt,
|
||||||
|
input.model,
|
||||||
|
prepared.messageTransformOptions,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return args.params
|
return args.params
|
||||||
},
|
},
|
||||||
@@ -517,24 +375,6 @@ export const defaultLayer = Layer.suspend(() =>
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
function resolveTools(input: Pick<StreamInput, "tools" | "agent" | "permission" | "user">) {
|
export const hasToolCalls = LLMRequestPrep.hasToolCalls
|
||||||
const disabled = Permission.disabled(
|
|
||||||
Object.keys(input.tools),
|
|
||||||
Permission.merge(input.agent.permission, input.permission ?? []),
|
|
||||||
)
|
|
||||||
return Record.filter(input.tools, (_, k) => input.user.tools?.[k] !== false && !disabled.has(k))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if messages contain any tool-call content
|
|
||||||
// Used to determine if a dummy tool should be added (GitHub Copilot only; see stream()).
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
export * as LLM from "./llm"
|
export * as LLM from "./llm"
|
||||||
|
|||||||
@@ -12,26 +12,25 @@ import {
|
|||||||
import type { ModelMessage } from "ai"
|
import type { ModelMessage } from "ai"
|
||||||
import type { Provider } from "@/provider/provider"
|
import type { Provider } from "@/provider/provider"
|
||||||
import { isRecord } from "@/util/record"
|
import { isRecord } from "@/util/record"
|
||||||
|
import type { Prepared as PreparedRequest } from "./request"
|
||||||
|
|
||||||
type ToolInput = {
|
type ToolInput = {
|
||||||
readonly description?: string
|
readonly description?: string
|
||||||
readonly inputSchema?: unknown
|
readonly inputSchema?: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RequestInput = {
|
type GenerationInput = Omit<PreparedRequest["params"], "options">
|
||||||
|
|
||||||
|
export type RequestInput = GenerationInput & {
|
||||||
readonly model: Provider.Model
|
readonly model: Provider.Model
|
||||||
readonly apiKey?: string
|
readonly apiKey?: string
|
||||||
readonly baseURL?: string
|
readonly baseURL?: string
|
||||||
readonly system?: readonly string[]
|
readonly system?: PreparedRequest["system"]
|
||||||
readonly messages: readonly ModelMessage[]
|
readonly messages: PreparedRequest["messages"]
|
||||||
readonly tools?: Record<string, ToolInput>
|
readonly tools?: Record<string, ToolInput>
|
||||||
readonly toolChoice?: "auto" | "required" | "none"
|
readonly toolChoice?: "auto" | "required" | "none"
|
||||||
readonly temperature?: number
|
|
||||||
readonly topP?: number
|
|
||||||
readonly topK?: number
|
|
||||||
readonly maxOutputTokens?: number
|
|
||||||
readonly providerOptions?: LLMRequest["providerOptions"]
|
readonly providerOptions?: LLMRequest["providerOptions"]
|
||||||
readonly headers?: Record<string, string>
|
readonly headers?: PreparedRequest["headers"]
|
||||||
}
|
}
|
||||||
|
|
||||||
const providerMetadata = (value: unknown): ProviderMetadata | undefined => {
|
const providerMetadata = (value: unknown): ProviderMetadata | undefined => {
|
||||||
|
|||||||
@@ -3,12 +3,13 @@ import type { Provider } from "@/provider/provider"
|
|||||||
import { ProviderTransform } from "@/provider/transform"
|
import { ProviderTransform } from "@/provider/transform"
|
||||||
import { errorMessage } from "@/util/error"
|
import { errorMessage } from "@/util/error"
|
||||||
import { isRecord } from "@/util/record"
|
import { isRecord } from "@/util/record"
|
||||||
import { asSchema, type ModelMessage, type Tool } from "ai"
|
import { asSchema, type Tool } from "ai"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import * as Stream from "effect/Stream"
|
import * as Stream from "effect/Stream"
|
||||||
import { tool as nativeTool, ToolFailure, type JsonSchema, type LLMEvent } from "@opencode-ai/llm"
|
import { tool as nativeTool, ToolFailure, type JsonSchema, type LLMEvent } from "@opencode-ai/llm"
|
||||||
import type { LLMClientShape } from "@opencode-ai/llm/route"
|
import type { LLMClientShape } from "@opencode-ai/llm/route"
|
||||||
import { LLMNative } from "./native-request"
|
import { LLMNative } from "./native-request"
|
||||||
|
import type { Prepared as PreparedRequest } from "./request"
|
||||||
|
|
||||||
export type RuntimeStatus =
|
export type RuntimeStatus =
|
||||||
| { readonly type: "supported"; readonly apiKey: string; readonly baseURL?: string }
|
| { readonly type: "supported"; readonly apiKey: string; readonly baseURL?: string }
|
||||||
@@ -22,17 +23,13 @@ type StreamInput = {
|
|||||||
readonly provider: Provider.Info
|
readonly provider: Provider.Info
|
||||||
readonly auth: Auth.Info | undefined
|
readonly auth: Auth.Info | undefined
|
||||||
readonly llmClient: LLMClientShape
|
readonly llmClient: LLMClientShape
|
||||||
readonly isOpenaiOauth: boolean
|
readonly request: PreparedRequest
|
||||||
readonly system: string[]
|
|
||||||
readonly messages: ModelMessage[]
|
|
||||||
readonly tools: Record<string, Tool>
|
|
||||||
readonly toolChoice?: "auto" | "required" | "none"
|
readonly toolChoice?: "auto" | "required" | "none"
|
||||||
readonly temperature?: number
|
readonly abort: AbortSignal
|
||||||
readonly topP?: number
|
}
|
||||||
readonly topK?: number
|
|
||||||
readonly maxOutputTokens?: number
|
type ToolContext = {
|
||||||
readonly providerOptions?: Record<string, any>
|
readonly messages: PreparedRequest["messages"]
|
||||||
readonly headers: Record<string, string>
|
|
||||||
readonly abort: AbortSignal
|
readonly abort: AbortSignal
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,17 +65,18 @@ export function stream(input: StreamInput): StreamResult {
|
|||||||
model: input.model,
|
model: input.model,
|
||||||
apiKey: current.apiKey,
|
apiKey: current.apiKey,
|
||||||
baseURL: current.baseURL,
|
baseURL: current.baseURL,
|
||||||
system: input.isOpenaiOauth ? input.system : [],
|
system: [],
|
||||||
messages: ProviderTransform.message(input.messages, input.model, input.providerOptions ?? {}),
|
messages: ProviderTransform.message(input.request.messages, input.model, input.request.params.options),
|
||||||
|
tools: input.request.tools,
|
||||||
toolChoice: input.toolChoice,
|
toolChoice: input.toolChoice,
|
||||||
temperature: input.temperature,
|
temperature: input.request.params.temperature,
|
||||||
topP: input.topP,
|
topP: input.request.params.topP,
|
||||||
topK: input.topK,
|
topK: input.request.params.topK,
|
||||||
maxOutputTokens: input.maxOutputTokens,
|
maxOutputTokens: input.request.params.maxOutputTokens,
|
||||||
providerOptions: ProviderTransform.providerOptions(input.model, input.providerOptions ?? {}),
|
providerOptions: ProviderTransform.providerOptions(input.model, input.request.params.options),
|
||||||
headers: { ...providerHeaders(input.provider.options.headers), ...input.headers },
|
headers: { ...providerHeaders(input.provider.options.headers), ...input.request.headers },
|
||||||
}),
|
}),
|
||||||
tools: nativeTools(input.tools, input),
|
tools: nativeTools(input.request.tools, { messages: input.request.messages, abort: input.abort }),
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -97,7 +95,7 @@ function nativeSchema(value: unknown): JsonSchema {
|
|||||||
return asSchema(value as Parameters<typeof asSchema>[0]).jsonSchema as JsonSchema
|
return asSchema(value as Parameters<typeof asSchema>[0]).jsonSchema as JsonSchema
|
||||||
}
|
}
|
||||||
|
|
||||||
export function nativeTools(tools: Record<string, Tool>, input: Pick<StreamInput, "messages" | "abort">) {
|
export function nativeTools(tools: Record<string, Tool>, input: ToolContext) {
|
||||||
return Object.fromEntries(
|
return Object.fromEntries(
|
||||||
Object.entries(tools).map(([name, item]) => [
|
Object.entries(tools).map(([name, item]) => [
|
||||||
name,
|
name,
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import type { Auth } from "@/auth"
|
||||||
|
import type { RuntimeFlags } from "@/effect/runtime-flags"
|
||||||
|
import { InstanceState } from "@/effect/instance-state"
|
||||||
|
import { Permission } from "@/permission"
|
||||||
|
import type { Agent } from "@/agent/agent"
|
||||||
|
import type { MessageV2 } from "../message-v2"
|
||||||
|
import type { Provider } from "@/provider/provider"
|
||||||
|
import { ProviderTransform } from "@/provider/transform"
|
||||||
|
import { SystemPrompt } from "../system"
|
||||||
|
import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
||||||
|
import { Effect, Record } from "effect"
|
||||||
|
import { jsonSchema, tool as aiTool, type ModelMessage, type Tool } from "ai"
|
||||||
|
import type { Plugin } from "@/plugin"
|
||||||
|
import { mergeDeep } from "remeda"
|
||||||
|
|
||||||
|
const USER_AGENT = `opencode/${InstallationVersion}`
|
||||||
|
|
||||||
|
type PrepareInput = {
|
||||||
|
readonly user: MessageV2.User
|
||||||
|
readonly sessionID: string
|
||||||
|
readonly parentSessionID?: string
|
||||||
|
readonly model: Provider.Model
|
||||||
|
readonly agent: Agent.Info
|
||||||
|
readonly permission?: Permission.Ruleset
|
||||||
|
readonly system: string[]
|
||||||
|
readonly messages: ModelMessage[]
|
||||||
|
readonly small?: boolean
|
||||||
|
readonly tools: Record<string, Tool>
|
||||||
|
readonly provider: Provider.Info
|
||||||
|
readonly auth: Auth.Info | undefined
|
||||||
|
readonly plugin: Plugin.Interface
|
||||||
|
readonly flags: RuntimeFlags.Info
|
||||||
|
readonly isWorkflow: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Prepared = {
|
||||||
|
readonly system: string[]
|
||||||
|
readonly messages: ModelMessage[]
|
||||||
|
readonly tools: Record<string, Tool>
|
||||||
|
readonly params: {
|
||||||
|
readonly temperature?: number
|
||||||
|
readonly topP?: number
|
||||||
|
readonly topK?: number
|
||||||
|
readonly maxOutputTokens?: number
|
||||||
|
readonly options: Record<string, any>
|
||||||
|
}
|
||||||
|
readonly messageTransformOptions: Record<string, any>
|
||||||
|
readonly headers: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergeOptions = (target: Record<string, any>, source: Record<string, any> | undefined): Record<string, any> =>
|
||||||
|
mergeDeep(target, source ?? {}) as Record<string, any>
|
||||||
|
|
||||||
|
export const prepare = Effect.fn("LLMRequestPrep.prepare")(function* (input: PrepareInput) {
|
||||||
|
const isOpenaiOauth = input.provider.id === "openai" && input.auth?.type === "oauth"
|
||||||
|
const system = [
|
||||||
|
[
|
||||||
|
...(input.agent.prompt ? [input.agent.prompt] : SystemPrompt.provider(input.model)),
|
||||||
|
...input.system,
|
||||||
|
...(input.user.system ? [input.user.system] : []),
|
||||||
|
]
|
||||||
|
.filter((x) => x)
|
||||||
|
.join("\n"),
|
||||||
|
]
|
||||||
|
|
||||||
|
const header = system[0]
|
||||||
|
yield* input.plugin.trigger(
|
||||||
|
"experimental.chat.system.transform",
|
||||||
|
{ sessionID: input.sessionID, model: input.model },
|
||||||
|
{ system },
|
||||||
|
)
|
||||||
|
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.model.variant
|
||||||
|
? input.model.variants[input.user.model.variant]
|
||||||
|
: {}
|
||||||
|
const base = input.small
|
||||||
|
? ProviderTransform.smallOptions(input.model)
|
||||||
|
: ProviderTransform.options({
|
||||||
|
model: input.model,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
providerOptions: input.provider.options,
|
||||||
|
})
|
||||||
|
const options = mergeOptions(mergeOptions(mergeOptions(base, input.model.options), input.agent.options), variant)
|
||||||
|
if (isOpenaiOauth) options.instructions = system.join("\n")
|
||||||
|
|
||||||
|
const messages =
|
||||||
|
isOpenaiOauth || input.isWorkflow
|
||||||
|
? input.messages
|
||||||
|
: [
|
||||||
|
...system.map(
|
||||||
|
(x): ModelMessage => ({
|
||||||
|
role: "system",
|
||||||
|
content: x,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
...input.messages,
|
||||||
|
]
|
||||||
|
|
||||||
|
const params = yield* input.plugin.trigger(
|
||||||
|
"chat.params",
|
||||||
|
{
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
agent: input.agent.name,
|
||||||
|
model: input.model,
|
||||||
|
provider: input.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),
|
||||||
|
maxOutputTokens: ProviderTransform.maxOutputTokens(input.model, input.flags.outputTokenMax),
|
||||||
|
options,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const { headers: pluginHeaders } = yield* input.plugin.trigger(
|
||||||
|
"chat.headers",
|
||||||
|
{
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
agent: input.agent.name,
|
||||||
|
model: input.model,
|
||||||
|
provider: input.provider,
|
||||||
|
message: input.user,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headers: {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const tools = resolveTools(input)
|
||||||
|
if (
|
||||||
|
input.model.providerID.includes("github-copilot") &&
|
||||||
|
Object.keys(tools).length === 0 &&
|
||||||
|
hasToolCalls(input.messages)
|
||||||
|
) {
|
||||||
|
// Copilot needs a tools field when replaying prior tool calls, even if no tools are currently enabled.
|
||||||
|
tools["_noop"] = aiTool({
|
||||||
|
description: "Do not call this tool. It exists only for API compatibility and must never be invoked.",
|
||||||
|
inputSchema: jsonSchema({
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
reason: { type: "string", description: "Unused" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
execute: async () => ({ output: "", title: "", metadata: {} }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const opencodeProjectID = input.model.providerID.startsWith("opencode")
|
||||||
|
? (yield* InstanceState.context).project.id
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
return {
|
||||||
|
system,
|
||||||
|
messages,
|
||||||
|
tools: Object.fromEntries(Object.entries(tools).toSorted(([a], [b]) => a.localeCompare(b))),
|
||||||
|
params,
|
||||||
|
messageTransformOptions: options,
|
||||||
|
headers: {
|
||||||
|
...(input.model.providerID.startsWith("opencode")
|
||||||
|
? {
|
||||||
|
...(opencodeProjectID ? { "x-opencode-project": opencodeProjectID } : {}),
|
||||||
|
"x-opencode-session": input.sessionID,
|
||||||
|
"x-opencode-request": input.user.id,
|
||||||
|
"x-opencode-client": input.flags.client,
|
||||||
|
"User-Agent": USER_AGENT,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
"x-session-affinity": input.sessionID,
|
||||||
|
...(input.parentSessionID ? { "x-parent-session-id": input.parentSessionID } : {}),
|
||||||
|
"User-Agent": USER_AGENT,
|
||||||
|
}),
|
||||||
|
...input.model.headers,
|
||||||
|
...pluginHeaders,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function resolveTools(input: Pick<PrepareInput, "tools" | "agent" | "permission" | "user">) {
|
||||||
|
const disabled = Permission.disabled(
|
||||||
|
Object.keys(input.tools),
|
||||||
|
Permission.merge(input.agent.permission, input.permission ?? []),
|
||||||
|
)
|
||||||
|
return Record.filter(input.tools, (_, k) => input.user.tools?.[k] !== false && !disabled.has(k))
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
export * as LLMRequestPrep from "./request"
|
||||||
Reference in New Issue
Block a user