diff --git a/packages/opencode/src/session/llm-native.ts b/packages/opencode/src/session/llm-native.ts new file mode 100644 index 0000000000..c519b65439 --- /dev/null +++ b/packages/opencode/src/session/llm-native.ts @@ -0,0 +1,74 @@ +import * as LLMCore from "@opencode-ai/llm/llm" +import type { Message as CoreMessage } from "@opencode-ai/llm/schema" +import { Effect, Schema } from "effect" +import { ProviderLLMBridge } from "@/provider/llm-bridge" +import type { Provider } from "@/provider" +import type { MessageV2 } from "./message-v2" + +export class UnsupportedModelError extends Schema.TaggedErrorClass()( + "LLMNative.UnsupportedModelError", + { + providerID: Schema.String, + modelID: Schema.String, + }, +) { + override get message() { + return `No native LLM route for ${this.providerID}/${this.modelID}` + } +} + +export type RequestInput = { + readonly id?: string + readonly provider: Provider.Info + readonly model: Provider.Model + readonly system?: ReadonlyArray + readonly messages: ReadonlyArray + readonly generation?: LLMCore.RequestInput["generation"] + readonly metadata?: Record + readonly native?: Record +} + +const isDefined = (value: T | undefined): value is T => value !== undefined + +const textContent = (message: MessageV2.WithParts) => + message.parts.flatMap((part) => (part.type === "text" && !part.ignored ? [LLMCore.text(part.text)] : [])) + +const message = (input: MessageV2.WithParts): CoreMessage | undefined => { + const content = textContent(input) + if (content.length === 0) return undefined + return LLMCore.message({ + id: input.info.id, + role: input.info.role, + content, + native: { + opencodeMessageID: input.info.id, + }, + }) +} + +export const request = Effect.fn("LLMNative.request")(function* (input: RequestInput) { + const model = ProviderLLMBridge.toModelRef({ provider: input.provider, model: input.model }) + if (!model) { + return yield* new UnsupportedModelError({ + providerID: input.provider.id, + modelID: input.model.id, + }) + } + + return LLMCore.request({ + id: input.id, + model, + system: input.system?.filter((part) => part.trim() !== "").map(LLMCore.system) ?? [], + messages: input.messages.map(message).filter(isDefined), + tools: [], + generation: input.generation, + metadata: input.metadata, + native: { + opencodeProviderID: input.provider.id, + opencodeModelID: input.model.id, + ...input.native, + }, + }) +}) + +export * as LLMNative from "./llm-native" diff --git a/packages/opencode/test/session/llm-native.test.ts b/packages/opencode/test/session/llm-native.test.ts new file mode 100644 index 0000000000..bf6320b603 --- /dev/null +++ b/packages/opencode/test/session/llm-native.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, test } from "bun:test" +import { Effect } from "effect" +import { ModelID, ProviderID } from "../../src/provider/schema" +import { LLMNative } from "../../src/session/llm-native" +import { MessageID, PartID, SessionID } from "../../src/session/schema" +import { ProviderTest } from "../fake/provider" +import type { MessageV2 } from "../../src/session/message-v2" +import type { Provider } from "../../src/provider" + +const sessionID = SessionID.descending() + +const model = (input: Partial = {}) => + ProviderTest.model({ + id: ModelID.make("gpt-5"), + providerID: ProviderID.openai, + api: { id: "gpt-5", url: "https://api.openai.com/v1", npm: "@ai-sdk/openai" }, + ...input, + }) + +const textPart = (messageID: MessageID, text: string, input: Partial = {}): MessageV2.TextPart => ({ + id: PartID.ascending(), + sessionID, + messageID, + type: "text", + text, + ...input, +}) + +const userMessage = (mdl: Provider.Model, id: MessageID, parts: MessageV2.Part[]): MessageV2.WithParts => { + return { + info: { + id, + sessionID, + role: "user", + time: { created: 1 }, + agent: "build", + model: { providerID: mdl.providerID, modelID: mdl.id }, + }, + parts, + } +} + +const assistantMessage = ( + mdl: Provider.Model, + id: MessageID, + parentID: MessageID, + parts: MessageV2.Part[], +): MessageV2.WithParts => { + return { + info: { + id, + sessionID, + role: "assistant", + time: { created: 2 }, + parentID, + modelID: mdl.id, + providerID: mdl.providerID, + mode: "build", + agent: "build", + path: { cwd: "/tmp/project", root: "/tmp/project" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }, + parts, + } +} + +describe("LLMNative.request", () => { + test("builds a text-only native LLM request", async () => { + const mdl = model() + const provider = ProviderTest.info({ id: ProviderID.openai, key: "openai-key" }, mdl) + const userID = MessageID.ascending() + const assistantID = MessageID.ascending() + + const request = await Effect.runPromise( + LLMNative.request({ + id: "request-1", + provider, + model: mdl, + system: ["You are concise.", ""], + generation: { maxTokens: 123, temperature: 0.2, topP: 0.9 }, + messages: [ + userMessage(mdl, userID, [textPart(userID, "ignored", { ignored: true }), textPart(userID, "Hello")]), + assistantMessage(mdl, assistantID, userID, [textPart(assistantID, "Hi")]), + ], + }), + ) + + expect(request).toMatchObject({ + id: "request-1", + model: { + id: "gpt-5", + provider: "openai", + protocol: "openai-responses", + headers: { authorization: "Bearer openai-key" }, + }, + system: [{ type: "text", text: "You are concise." }], + generation: { maxTokens: 123, temperature: 0.2, topP: 0.9 }, + tools: [], + }) + expect(request.messages.map((message) => ({ id: message.id, role: message.role, content: message.content }))).toEqual([ + { id: userID, role: "user", content: [{ type: "text", text: "Hello" }] }, + { id: assistantID, role: "assistant", content: [{ type: "text", text: "Hi" }] }, + ]) + }) +})