refactor(session): add native LLM request adapter
This commit is contained in:
parent
4481d46071
commit
d79e8ba701
2 changed files with 403 additions and 0 deletions
184
packages/opencode/src/session/llm-native.ts
Normal file
184
packages/opencode/src/session/llm-native.ts
Normal file
|
|
@ -0,0 +1,184 @@
|
||||||
|
import type { JsonSchema, LLMRequest, ProviderMetadata, ToolDefinition } from "@opencode-ai/llm"
|
||||||
|
import { LLM } from "@opencode-ai/llm"
|
||||||
|
import type { ModelMessage } from "ai"
|
||||||
|
import type { Provider } from "@/provider/provider"
|
||||||
|
|
||||||
|
type ToolInput = {
|
||||||
|
readonly description?: string
|
||||||
|
readonly inputSchema?: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RequestInput = {
|
||||||
|
readonly model: Provider.Model
|
||||||
|
readonly system?: readonly string[]
|
||||||
|
readonly messages: readonly ModelMessage[]
|
||||||
|
readonly tools?: Record<string, ToolInput>
|
||||||
|
readonly toolChoice?: "auto" | "required" | "none"
|
||||||
|
readonly temperature?: number
|
||||||
|
readonly topP?: number
|
||||||
|
readonly topK?: number
|
||||||
|
readonly maxOutputTokens?: number
|
||||||
|
readonly providerOptions?: LLMRequest["providerOptions"]
|
||||||
|
readonly headers?: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_BASE_URL: Record<string, string> = {
|
||||||
|
"@ai-sdk/openai": "https://api.openai.com/v1",
|
||||||
|
"@ai-sdk/anthropic": "https://api.anthropic.com/v1",
|
||||||
|
"@ai-sdk/google": "https://generativelanguage.googleapis.com/v1beta",
|
||||||
|
"@ai-sdk/amazon-bedrock": "https://bedrock-runtime.us-east-1.amazonaws.com",
|
||||||
|
}
|
||||||
|
|
||||||
|
const ROUTE: Record<string, string> = {
|
||||||
|
"@ai-sdk/openai": "openai-responses",
|
||||||
|
"@ai-sdk/azure": "azure-openai-responses",
|
||||||
|
"@ai-sdk/anthropic": "anthropic-messages",
|
||||||
|
"@ai-sdk/google": "gemini",
|
||||||
|
"@ai-sdk/amazon-bedrock": "bedrock-converse",
|
||||||
|
"@ai-sdk/openai-compatible": "openai-compatible-chat",
|
||||||
|
"@openrouter/ai-sdk-provider": "openai-compatible-chat",
|
||||||
|
}
|
||||||
|
|
||||||
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||||
|
typeof value === "object" && value !== null && !Array.isArray(value)
|
||||||
|
|
||||||
|
const providerMetadata = (value: unknown): ProviderMetadata | undefined => {
|
||||||
|
if (!isRecord(value)) return undefined
|
||||||
|
const result = Object.fromEntries(
|
||||||
|
Object.entries(value).filter((entry): entry is [string, Record<string, unknown>] => isRecord(entry[1])),
|
||||||
|
)
|
||||||
|
return Object.keys(result).length === 0 ? undefined : result
|
||||||
|
}
|
||||||
|
|
||||||
|
const textPart = (part: Record<string, unknown>) => ({
|
||||||
|
type: "text" as const,
|
||||||
|
text: typeof part.text === "string" ? part.text : "",
|
||||||
|
providerMetadata: providerMetadata(part.providerOptions),
|
||||||
|
})
|
||||||
|
|
||||||
|
const mediaPart = (part: Record<string, unknown>) => {
|
||||||
|
if (typeof part.data !== "string" && !(part.data instanceof Uint8Array))
|
||||||
|
throw new Error("Native LLM request adapter only supports file parts with string or Uint8Array data")
|
||||||
|
return {
|
||||||
|
type: "media" as const,
|
||||||
|
mediaType: typeof part.mediaType === "string" ? part.mediaType : "application/octet-stream",
|
||||||
|
data: part.data,
|
||||||
|
filename: typeof part.filename === "string" ? part.filename : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolResult = (part: Record<string, unknown>) => {
|
||||||
|
const output = isRecord(part.output) ? part.output : { type: "json", value: part.output }
|
||||||
|
const type = output.type === "text" ? "text" : output.type === "error-text" ? "error" : "json"
|
||||||
|
return LLM.toolResult({
|
||||||
|
id: typeof part.toolCallId === "string" ? part.toolCallId : "",
|
||||||
|
name: typeof part.toolName === "string" ? part.toolName : "",
|
||||||
|
result: "value" in output ? output.value : output,
|
||||||
|
resultType: type,
|
||||||
|
providerExecuted: typeof part.providerExecuted === "boolean" ? part.providerExecuted : undefined,
|
||||||
|
providerMetadata: providerMetadata(part.providerOptions),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentPart = (part: unknown) => {
|
||||||
|
if (!isRecord(part)) throw new Error("Native LLM request adapter only supports object content parts")
|
||||||
|
if (part.type === "text") return textPart(part)
|
||||||
|
if (part.type === "file") return mediaPart(part)
|
||||||
|
if (part.type === "reasoning")
|
||||||
|
return {
|
||||||
|
type: "reasoning" as const,
|
||||||
|
text: typeof part.text === "string" ? part.text : "",
|
||||||
|
providerMetadata: providerMetadata(part.providerOptions),
|
||||||
|
}
|
||||||
|
if (part.type === "tool-call")
|
||||||
|
return LLM.toolCall({
|
||||||
|
id: typeof part.toolCallId === "string" ? part.toolCallId : "",
|
||||||
|
name: typeof part.toolName === "string" ? part.toolName : "",
|
||||||
|
input: part.input,
|
||||||
|
providerExecuted: typeof part.providerExecuted === "boolean" ? part.providerExecuted : undefined,
|
||||||
|
providerMetadata: providerMetadata(part.providerOptions),
|
||||||
|
})
|
||||||
|
if (part.type === "tool-result") return toolResult(part)
|
||||||
|
throw new Error(`Native LLM request adapter does not support ${String(part.type)} content parts`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = (value: ModelMessage["content"]) =>
|
||||||
|
typeof value === "string" ? [LLM.text(value)] : value.map(contentPart)
|
||||||
|
|
||||||
|
const messages = (input: readonly ModelMessage[]) => {
|
||||||
|
const system = input.flatMap((message) => (message.role === "system" ? [LLM.system(message.content)] : []))
|
||||||
|
const messages = input.flatMap((message) => {
|
||||||
|
if (message.role === "system") return []
|
||||||
|
return [
|
||||||
|
LLM.message({
|
||||||
|
role: message.role,
|
||||||
|
content: content(message.content),
|
||||||
|
native: isRecord(message.providerOptions) ? { providerOptions: message.providerOptions } : undefined,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
return { system, messages }
|
||||||
|
}
|
||||||
|
|
||||||
|
const schema = (value: unknown): JsonSchema => {
|
||||||
|
if (!isRecord(value)) return { type: "object", properties: {} }
|
||||||
|
if (isRecord(value.jsonSchema)) return value.jsonSchema
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
const tools = (input: Record<string, ToolInput> | undefined): ToolDefinition[] =>
|
||||||
|
Object.entries(input ?? {}).map(([name, item]) =>
|
||||||
|
LLM.toolDefinition({
|
||||||
|
name,
|
||||||
|
description: item.description ?? "",
|
||||||
|
inputSchema: schema(item.inputSchema),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
const generation = (input: RequestInput) => {
|
||||||
|
const result = {
|
||||||
|
temperature: input.temperature,
|
||||||
|
topP: input.topP,
|
||||||
|
topK: input.topK,
|
||||||
|
maxTokens: input.maxOutputTokens,
|
||||||
|
}
|
||||||
|
return Object.values(result).some((value) => value !== undefined) ? result : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseURL = (model: Provider.Model) => {
|
||||||
|
if (model.api.url) return model.api.url
|
||||||
|
const fallback = DEFAULT_BASE_URL[model.api.npm]
|
||||||
|
if (fallback) return fallback
|
||||||
|
throw new Error(`Native LLM request adapter requires a base URL for ${model.providerID}/${model.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const model = (model: Provider.Model, headers?: Record<string, string>) => {
|
||||||
|
const route = ROUTE[model.api.npm]
|
||||||
|
if (!route) throw new Error(`Native LLM request adapter does not support provider package ${model.api.npm}`)
|
||||||
|
return LLM.model({
|
||||||
|
id: model.api.id,
|
||||||
|
provider: model.providerID,
|
||||||
|
route,
|
||||||
|
baseURL: baseURL(model),
|
||||||
|
headers: Object.keys({ ...model.headers, ...headers }).length === 0 ? undefined : { ...model.headers, ...headers },
|
||||||
|
limits: {
|
||||||
|
context: model.limit.context,
|
||||||
|
output: model.limit.output,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const request = (input: RequestInput) => {
|
||||||
|
const converted = messages(input.messages)
|
||||||
|
return LLM.request({
|
||||||
|
model: model(input.model, input.headers),
|
||||||
|
system: [...(input.system ?? []).map(LLM.system), ...converted.system],
|
||||||
|
messages: converted.messages,
|
||||||
|
tools: tools(input.tools),
|
||||||
|
toolChoice: input.toolChoice,
|
||||||
|
generation: generation(input),
|
||||||
|
providerOptions: input.providerOptions,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export * as LLMNative from "./llm-native"
|
||||||
219
packages/opencode/test/session/llm-native.test.ts
Normal file
219
packages/opencode/test/session/llm-native.test.ts
Normal file
|
|
@ -0,0 +1,219 @@
|
||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { jsonSchema, tool, type ModelMessage } from "ai"
|
||||||
|
import { LLMNative } from "@/session/llm-native"
|
||||||
|
import type { Provider } from "@/provider/provider"
|
||||||
|
import { ModelID, ProviderID } from "@/provider/schema"
|
||||||
|
|
||||||
|
const baseModel: Provider.Model = {
|
||||||
|
id: ModelID.make("gpt-5-mini"),
|
||||||
|
providerID: ProviderID.make("openai"),
|
||||||
|
api: {
|
||||||
|
id: "gpt-5-mini",
|
||||||
|
url: "https://api.openai.com/v1",
|
||||||
|
npm: "@ai-sdk/openai",
|
||||||
|
},
|
||||||
|
name: "GPT-5 Mini",
|
||||||
|
capabilities: {
|
||||||
|
temperature: true,
|
||||||
|
reasoning: true,
|
||||||
|
attachment: true,
|
||||||
|
toolcall: true,
|
||||||
|
input: {
|
||||||
|
text: true,
|
||||||
|
audio: false,
|
||||||
|
image: true,
|
||||||
|
video: false,
|
||||||
|
pdf: false,
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
text: true,
|
||||||
|
audio: false,
|
||||||
|
image: false,
|
||||||
|
video: false,
|
||||||
|
pdf: false,
|
||||||
|
},
|
||||||
|
interleaved: false,
|
||||||
|
},
|
||||||
|
cost: {
|
||||||
|
input: 0,
|
||||||
|
output: 0,
|
||||||
|
cache: {
|
||||||
|
read: 0,
|
||||||
|
write: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
limit: {
|
||||||
|
context: 128_000,
|
||||||
|
input: 128_000,
|
||||||
|
output: 32_000,
|
||||||
|
},
|
||||||
|
status: "active",
|
||||||
|
options: {},
|
||||||
|
headers: {
|
||||||
|
"x-model": "model-header",
|
||||||
|
},
|
||||||
|
release_date: "2026-01-01",
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("session.llm-native.request", () => {
|
||||||
|
test("maps normalized stream inputs to a native LLM request", () => {
|
||||||
|
const messages: ModelMessage[] = [
|
||||||
|
{
|
||||||
|
role: "system",
|
||||||
|
content: "system from messages",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: "hello", providerOptions: { openai: { cacheControl: { type: "ephemeral" } } } },
|
||||||
|
{ type: "file", mediaType: "image/png", filename: "img.png", data: "data:image/png;base64,Zm9v" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{ type: "reasoning", text: "thinking", providerOptions: { openai: { encryptedContent: "secret" } } },
|
||||||
|
{ type: "text", text: "I'll run it" },
|
||||||
|
{
|
||||||
|
type: "tool-call",
|
||||||
|
toolCallId: "call-1",
|
||||||
|
toolName: "bash",
|
||||||
|
input: { command: "ls" },
|
||||||
|
providerOptions: { openai: { itemId: "item-1" } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "tool",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "tool-result",
|
||||||
|
toolCallId: "call-1",
|
||||||
|
toolName: "bash",
|
||||||
|
output: { type: "text", value: "ok" },
|
||||||
|
providerOptions: { openai: { outputId: "output-1" } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const request = LLMNative.request({
|
||||||
|
model: baseModel,
|
||||||
|
system: ["agent system"],
|
||||||
|
messages,
|
||||||
|
tools: {
|
||||||
|
bash: tool({
|
||||||
|
description: "Run a shell command",
|
||||||
|
inputSchema: jsonSchema({
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
command: { type: "string" },
|
||||||
|
},
|
||||||
|
required: ["command"],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
toolChoice: "required",
|
||||||
|
temperature: 0.2,
|
||||||
|
topP: 0.9,
|
||||||
|
topK: 40,
|
||||||
|
maxOutputTokens: 1024,
|
||||||
|
providerOptions: { openai: { store: false } },
|
||||||
|
headers: { "x-request": "request-header" },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(request.model).toMatchObject({
|
||||||
|
id: "gpt-5-mini",
|
||||||
|
provider: "openai",
|
||||||
|
route: "openai-responses",
|
||||||
|
baseURL: "https://api.openai.com/v1",
|
||||||
|
headers: {
|
||||||
|
"x-model": "model-header",
|
||||||
|
"x-request": "request-header",
|
||||||
|
},
|
||||||
|
limits: {
|
||||||
|
context: 128_000,
|
||||||
|
output: 32_000,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(request.system).toEqual([
|
||||||
|
{ type: "text", text: "agent system" },
|
||||||
|
{ type: "text", text: "system from messages" },
|
||||||
|
])
|
||||||
|
expect(request.generation).toMatchObject({
|
||||||
|
temperature: 0.2,
|
||||||
|
topP: 0.9,
|
||||||
|
topK: 40,
|
||||||
|
maxTokens: 1024,
|
||||||
|
})
|
||||||
|
expect(request.providerOptions).toEqual({ openai: { store: false } })
|
||||||
|
expect(request.toolChoice).toMatchObject({ type: "required" })
|
||||||
|
expect(request.tools).toMatchObject([
|
||||||
|
{
|
||||||
|
name: "bash",
|
||||||
|
description: "Run a shell command",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
command: { type: "string" },
|
||||||
|
},
|
||||||
|
required: ["command"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
expect(request.messages).toMatchObject([
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: "hello", providerMetadata: { openai: { cacheControl: { type: "ephemeral" } } } },
|
||||||
|
{ type: "media", mediaType: "image/png", filename: "img.png", data: "data:image/png;base64,Zm9v" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{ type: "reasoning", text: "thinking", providerMetadata: { openai: { encryptedContent: "secret" } } },
|
||||||
|
{ type: "text", text: "I'll run it" },
|
||||||
|
{
|
||||||
|
type: "tool-call",
|
||||||
|
id: "call-1",
|
||||||
|
name: "bash",
|
||||||
|
input: { command: "ls" },
|
||||||
|
providerMetadata: { openai: { itemId: "item-1" } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "tool",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "tool-result",
|
||||||
|
id: "call-1",
|
||||||
|
name: "bash",
|
||||||
|
result: { type: "text", value: "ok" },
|
||||||
|
providerMetadata: { openai: { outputId: "output-1" } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("selects native routes from existing provider packages", () => {
|
||||||
|
expect(LLMNative.model({ ...baseModel, api: { ...baseModel.api, npm: "@ai-sdk/anthropic" } }).route).toBe(
|
||||||
|
"anthropic-messages",
|
||||||
|
)
|
||||||
|
expect(LLMNative.model({ ...baseModel, api: { ...baseModel.api, npm: "@ai-sdk/google" } }).route).toBe("gemini")
|
||||||
|
expect(LLMNative.model({ ...baseModel, api: { ...baseModel.api, npm: "@ai-sdk/openai-compatible" } }).route).toBe(
|
||||||
|
"openai-compatible-chat",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("fails fast for unsupported provider packages", () => {
|
||||||
|
expect(() =>
|
||||||
|
LLMNative.request({
|
||||||
|
model: { ...baseModel, api: { ...baseModel.api, npm: "unknown-provider" } },
|
||||||
|
messages: [],
|
||||||
|
}),
|
||||||
|
).toThrow("Native LLM request adapter does not support provider package unknown-provider")
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue