refactor(llm): simplify adapter shared logic
This commit is contained in:
@@ -206,15 +206,10 @@ const decodeTarget = Schema.decodeUnknownEffect(AnthropicMessagesDraft.pipe(Sche
|
||||
|
||||
const invalid = ProviderShared.invalidRequest
|
||||
|
||||
const baseUrl = (request: LLMRequest) => (request.model.baseURL ?? "https://api.anthropic.com/v1").replace(/\/+$/, "")
|
||||
const baseUrl = (request: LLMRequest) => ProviderShared.trimBaseUrl(request.model.baseURL ?? "https://api.anthropic.com/v1")
|
||||
|
||||
const cacheControl = (cache: CacheHint | undefined) => cache?.type === "ephemeral" ? { type: "ephemeral" as const } : undefined
|
||||
|
||||
const resultText = (part: ToolResultPart) => {
|
||||
if (part.result.type === "text" || part.result.type === "error") return String(part.result.value)
|
||||
return ProviderShared.encodeJson(part.result.value)
|
||||
}
|
||||
|
||||
const lowerTool = (tool: ToolDefinition): AnthropicTool => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
@@ -306,7 +301,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (re
|
||||
content.push({
|
||||
type: "tool_result",
|
||||
tool_use_id: part.id,
|
||||
content: resultText(part),
|
||||
content: ProviderShared.toolResultText(part),
|
||||
is_error: part.result.type === "error" ? true : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { Buffer } from "node:buffer"
|
||||
import { Effect, Schema, Stream } from "effect"
|
||||
import type { HttpClientResponse } from "effect/unstable/http"
|
||||
import { Adapter } from "../adapter"
|
||||
@@ -13,7 +12,6 @@ import {
|
||||
type TextPart,
|
||||
type ToolCallPart,
|
||||
type ToolDefinition,
|
||||
type ToolResultPart,
|
||||
} from "../schema"
|
||||
import { ProviderShared } from "./shared"
|
||||
|
||||
@@ -153,14 +151,9 @@ const decodeTarget = Schema.decodeUnknownEffect(GeminiDraft.pipe(Schema.decodeTo
|
||||
const invalid = ProviderShared.invalidRequest
|
||||
|
||||
const baseUrl = (request: LLMRequest) =>
|
||||
(request.model.baseURL ?? "https://generativelanguage.googleapis.com/v1beta").replace(/\/+$/, "")
|
||||
ProviderShared.trimBaseUrl(request.model.baseURL ?? "https://generativelanguage.googleapis.com/v1beta")
|
||||
|
||||
const mediaData = (part: MediaPart) => typeof part.data === "string" ? part.data : Buffer.from(part.data).toString("base64")
|
||||
|
||||
const resultText = (part: ToolResultPart) => {
|
||||
if (part.result.type === "text" || part.result.type === "error") return String(part.result.value)
|
||||
return ProviderShared.encodeJson(part.result.value)
|
||||
}
|
||||
const mediaData = ProviderShared.mediaBytes
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
@@ -269,7 +262,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
|
||||
name: part.name,
|
||||
response: {
|
||||
name: part.name,
|
||||
content: resultText(part),
|
||||
content: ProviderShared.toolResultText(part),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
type TextPart,
|
||||
type ToolCallPart,
|
||||
type ToolDefinition,
|
||||
type ToolResultPart,
|
||||
} from "../schema"
|
||||
import { ProviderShared } from "./shared"
|
||||
|
||||
@@ -165,12 +164,7 @@ const decodeTarget = Schema.decodeUnknownEffect(OpenAIChatDraft.pipe(Schema.deco
|
||||
|
||||
const invalid = ProviderShared.invalidRequest
|
||||
|
||||
const baseUrl = (request: LLMRequest) => (request.model.baseURL ?? "https://api.openai.com/v1").replace(/\/+$/, "")
|
||||
|
||||
const resultText = (part: ToolResultPart) => {
|
||||
if (part.result.type === "text" || part.result.type === "error") return String(part.result.value)
|
||||
return ProviderShared.encodeJson(part.result.value)
|
||||
}
|
||||
const baseUrl = (request: LLMRequest) => ProviderShared.trimBaseUrl(request.model.baseURL ?? "https://api.openai.com/v1")
|
||||
|
||||
const lowerTool = (tool: ToolDefinition): OpenAIChatTool => ({
|
||||
type: "function",
|
||||
@@ -239,7 +233,7 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
|
||||
for (const part of message.content) {
|
||||
if (part.type !== "tool-result")
|
||||
return yield* invalid(`OpenAI Chat tool messages only support tool-result content`)
|
||||
messages.push({ role: "tool", tool_call_id: part.id, content: resultText(part) })
|
||||
messages.push({ role: "tool", tool_call_id: part.id, content: ProviderShared.toolResultText(part) })
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ const queryParams = (request: LLMRequest) => {
|
||||
|
||||
const completionUrl = (request: LLMRequest) => {
|
||||
if (!request.model.baseURL) return undefined
|
||||
const url = new URL(`${request.model.baseURL.replace(/\/+$/, "")}/chat/completions`)
|
||||
const url = new URL(`${ProviderShared.trimBaseUrl(request.model.baseURL)}/chat/completions`)
|
||||
for (const [key, value] of Object.entries(queryParams(request) ?? {})) url.searchParams.set(key, value)
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
type TextPart,
|
||||
type ToolCallPart,
|
||||
type ToolDefinition,
|
||||
type ToolResultPart,
|
||||
} from "../schema"
|
||||
import { ProviderShared } from "./shared"
|
||||
|
||||
@@ -150,12 +149,7 @@ interface ParserState {
|
||||
|
||||
const invalid = ProviderShared.invalidRequest
|
||||
|
||||
const baseUrl = (request: LLMRequest) => (request.model.baseURL ?? "https://api.openai.com/v1").replace(/\/+$/, "")
|
||||
|
||||
const resultText = (part: ToolResultPart) => {
|
||||
if (part.result.type === "text" || part.result.type === "error") return String(part.result.value)
|
||||
return ProviderShared.encodeJson(part.result.value)
|
||||
}
|
||||
const baseUrl = (request: LLMRequest) => ProviderShared.trimBaseUrl(request.model.baseURL ?? "https://api.openai.com/v1")
|
||||
|
||||
const lowerTool = (tool: ToolDefinition): OpenAIResponsesTool => ({
|
||||
type: "function",
|
||||
@@ -216,7 +210,7 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
|
||||
for (const part of message.content) {
|
||||
if (part.type !== "tool-result")
|
||||
return yield* invalid(`OpenAI Responses tool messages only support tool-result content`)
|
||||
input.push({ type: "function_call_output", call_id: part.id, output: resultText(part) })
|
||||
input.push({ type: "function_call_output", call_id: part.id, output: ProviderShared.toolResultText(part) })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,6 +275,9 @@ const finishToolCall = (tools: Record<string, ToolAccumulator>, item: NonNullabl
|
||||
return [{ type: "tool-call" as const, id: item.call_id, name: item.name, input }]
|
||||
})
|
||||
|
||||
const withoutTool = (tools: Record<string, ToolAccumulator>, id: string | undefined) =>
|
||||
id === undefined ? tools : Object.fromEntries(Object.entries(tools).filter(([key]) => key !== id))
|
||||
|
||||
// Hosted tool items (provider-executed) ship their typed input + status + result
|
||||
// fields all in one item. We expose them as a `tool-call` + `tool-result` pair
|
||||
// so consumers can treat them uniformly with client tools, only differentiated
|
||||
@@ -360,7 +357,7 @@ const processChunk = (state: ParserState, chunk: OpenAIResponsesChunk) =>
|
||||
|
||||
if (chunk.type === "response.output_item.done" && chunk.item?.type === "function_call") {
|
||||
const events = yield* finishToolCall(state.tools, chunk.item)
|
||||
return [state, events] as const
|
||||
return [{ tools: withoutTool(state.tools, chunk.item.id) }, events] as const
|
||||
}
|
||||
|
||||
if (chunk.type === "response.output_item.done" && chunk.item && isHostedToolItem(chunk.item)) {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Buffer } from "node:buffer"
|
||||
import { Cause, Effect, Schema, Stream } from "effect"
|
||||
import * as Sse from "effect/unstable/encoding/Sse"
|
||||
import { HttpClientRequest, type HttpClientResponse } from "effect/unstable/http"
|
||||
import { InvalidRequestError, ProviderChunkError } from "../schema"
|
||||
import { InvalidRequestError, ProviderChunkError, type MediaPart, type ToolResultPart } from "../schema"
|
||||
|
||||
export const Json = Schema.fromJsonString(Schema.Unknown)
|
||||
export const decodeJson = Schema.decodeUnknownSync(Json)
|
||||
@@ -34,6 +35,22 @@ export const joinText = (parts: ReadonlyArray<{ readonly text: string }>) =>
|
||||
export const parseToolInput = (adapter: string, name: string, raw: string) =>
|
||||
parseJson(adapter, raw || "{}", `Invalid JSON input for ${adapter} tool call ${name}`)
|
||||
|
||||
/**
|
||||
* Encode a `MediaPart`'s raw bytes for inclusion in a JSON request body.
|
||||
* `data: string` is assumed to already be base64 (matches caller convention
|
||||
* across Gemini / Bedrock); `data: Uint8Array` is base64-encoded here. Used
|
||||
* by every adapter that supports image / document inputs.
|
||||
*/
|
||||
export const mediaBytes = (part: MediaPart) =>
|
||||
typeof part.data === "string" ? part.data : Buffer.from(part.data).toString("base64")
|
||||
|
||||
export const trimBaseUrl = (value: string) => value.replace(/\/+$/, "")
|
||||
|
||||
export const toolResultText = (part: ToolResultPart) => {
|
||||
if (part.result.type === "text" || part.result.type === "error") return String(part.result.value)
|
||||
return encodeJson(part.result.value)
|
||||
}
|
||||
|
||||
const streamError = (adapter: string, message: string, cause: Cause.Cause<unknown>) => {
|
||||
const failed = cause.reasons.find(Cause.isFailReason)?.error
|
||||
if (failed instanceof ProviderChunkError) return failed
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
type LLMEvent,
|
||||
LLMRequest,
|
||||
type ToolCallPart,
|
||||
type ToolResultPart,
|
||||
type ToolResultValue,
|
||||
} from "./schema"
|
||||
import { ToolFailure } from "./schema"
|
||||
@@ -63,9 +62,13 @@ export const run = <T extends Tools>(
|
||||
const maxSteps = options.maxSteps ?? 10
|
||||
const concurrency = options.concurrency ?? 10
|
||||
const tools = options.tools as Tools
|
||||
const runtimeTools = toDefinitions(tools)
|
||||
const initialRequest = new LLMRequest({
|
||||
...options.request,
|
||||
tools: [...options.request.tools, ...toDefinitions(tools)],
|
||||
tools: [
|
||||
...options.request.tools.filter((tool) => !runtimeTools.some((runtimeTool) => runtimeTool.name === tool.name)),
|
||||
...runtimeTools,
|
||||
],
|
||||
})
|
||||
|
||||
const loop = (request: LLMRequest, step: number): Stream.Stream<LLMEvent, LLMError, RequestExecutor.Service> =>
|
||||
@@ -128,13 +131,12 @@ const accumulate = (state: StepState, event: LLMEvent) => {
|
||||
return
|
||||
}
|
||||
if (event.type === "tool-call") {
|
||||
const part: ToolCallPart = {
|
||||
type: "tool-call",
|
||||
const part = LLM.toolCall({
|
||||
id: event.id,
|
||||
name: event.name,
|
||||
input: event.input,
|
||||
providerExecuted: event.providerExecuted,
|
||||
}
|
||||
})
|
||||
state.assistantContent.push(part)
|
||||
// Provider-executed tools are dispatched by the provider; the runtime must
|
||||
// not invoke a client handler. The matching `tool-result` event arrives
|
||||
@@ -144,14 +146,12 @@ const accumulate = (state: StepState, event: LLMEvent) => {
|
||||
return
|
||||
}
|
||||
if (event.type === "tool-result" && event.providerExecuted) {
|
||||
const part: ToolResultPart = {
|
||||
type: "tool-result",
|
||||
state.assistantContent.push(LLM.toolResult({
|
||||
id: event.id,
|
||||
name: event.name,
|
||||
result: event.result,
|
||||
providerExecuted: true,
|
||||
}
|
||||
state.assistantContent.push(part)
|
||||
}))
|
||||
return
|
||||
}
|
||||
if (event.type === "request-finish") {
|
||||
@@ -198,7 +198,10 @@ const decodeAndExecute = (tool: AnyTool, input: unknown): Effect.Effect<ToolResu
|
||||
|
||||
const emitEvents = (call: ToolCallPart, result: ToolResultValue): ReadonlyArray<LLMEvent> =>
|
||||
result.type === "error"
|
||||
? [{ type: "tool-error", id: call.id, name: call.name, message: String(result.value) }]
|
||||
? [
|
||||
{ type: "tool-error", id: call.id, name: call.name, message: String(result.value) },
|
||||
{ type: "tool-result", id: call.id, name: call.name, result },
|
||||
]
|
||||
: [{ type: "tool-result", id: call.id, name: call.name, result }]
|
||||
|
||||
export * as ToolRuntime from "./tool-runtime"
|
||||
|
||||
@@ -82,6 +82,12 @@ describe("ToolRuntime", () => {
|
||||
const toolError = events.find(LLMEvent.guards["tool-error"])
|
||||
expect(toolError).toMatchObject({ type: "tool-error", id: "call_1", name: "missing_tool" })
|
||||
expect(toolError?.message).toContain("Unknown tool")
|
||||
expect(events.find(LLMEvent.guards["tool-result"])).toMatchObject({
|
||||
type: "tool-result",
|
||||
id: "call_1",
|
||||
name: "missing_tool",
|
||||
result: { type: "error", value: "Unknown tool: missing_tool" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { AnthropicMessages } from "@opencode-ai/llm"
|
||||
import { client } from "@opencode-ai/llm/adapter"
|
||||
import { OpenAIResponses } from "@opencode-ai/llm/provider/openai-responses"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Cause, Effect, Exit, Layer, Schema } 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 { testEffect } from "../lib/effect"
|
||||
import type { MessageV2 } from "../../src/session/message-v2"
|
||||
import type { Provider } from "../../src/provider"
|
||||
import type { Tool } from "../../src/tool"
|
||||
@@ -111,26 +113,26 @@ const lookupTool = {
|
||||
execute: () => Effect.succeed({ title: "", metadata: {}, output: "" }),
|
||||
} satisfies Tool.Def<typeof lookupParameters>
|
||||
|
||||
const it = testEffect(Layer.empty)
|
||||
|
||||
describe("LLMNative.request", () => {
|
||||
test("builds a text-only native LLM request", async () => {
|
||||
it.effect("builds a text-only native LLM request", () => Effect.gen(function* () {
|
||||
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")]),
|
||||
],
|
||||
}),
|
||||
)
|
||||
const request = yield* 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",
|
||||
@@ -148,18 +150,16 @@ describe("LLMNative.request", () => {
|
||||
{ id: userID, role: "user", content: [{ type: "text", text: "Hello" }] },
|
||||
{ id: assistantID, role: "assistant", content: [{ type: "text", text: "Hi" }] },
|
||||
])
|
||||
})
|
||||
}))
|
||||
|
||||
test("converts native tool definitions", async () => {
|
||||
it.effect("converts native tool definitions", () => Effect.gen(function* () {
|
||||
const mdl = model()
|
||||
const request = await Effect.runPromise(
|
||||
LLMNative.request({
|
||||
provider: ProviderTest.info({ id: ProviderID.openai }, mdl),
|
||||
model: mdl,
|
||||
messages: [],
|
||||
tools: [lookupTool],
|
||||
}),
|
||||
)
|
||||
const request = yield* LLMNative.request({
|
||||
provider: ProviderTest.info({ id: ProviderID.openai }, mdl),
|
||||
model: mdl,
|
||||
messages: [],
|
||||
tools: [lookupTool],
|
||||
})
|
||||
|
||||
expect(request.tools).toHaveLength(1)
|
||||
expect(request.tools[0]).toMatchObject({
|
||||
@@ -179,38 +179,36 @@ describe("LLMNative.request", () => {
|
||||
opencodeToolID: "lookup",
|
||||
},
|
||||
})
|
||||
})
|
||||
}))
|
||||
|
||||
test("converts assistant reasoning and tool history", async () => {
|
||||
it.effect("converts assistant reasoning and tool history", () => Effect.gen(function* () {
|
||||
const mdl = model()
|
||||
const provider = ProviderTest.info({ id: ProviderID.openai }, mdl)
|
||||
const userID = MessageID.ascending()
|
||||
const assistantID = MessageID.ascending()
|
||||
|
||||
const request = await Effect.runPromise(
|
||||
LLMNative.request({
|
||||
provider,
|
||||
model: mdl,
|
||||
messages: [
|
||||
userMessage(mdl, userID, [textPart(userID, "Check weather")]),
|
||||
assistantMessage(mdl, assistantID, userID, [
|
||||
reasoningPart(assistantID, "Need a lookup."),
|
||||
toolPart(assistantID, {
|
||||
callID: "call_1",
|
||||
tool: "lookup",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { query: "weather" },
|
||||
output: "sunny",
|
||||
title: "Weather",
|
||||
metadata: {},
|
||||
time: { start: 1, end: 2 },
|
||||
},
|
||||
}),
|
||||
]),
|
||||
],
|
||||
}),
|
||||
)
|
||||
const request = yield* LLMNative.request({
|
||||
provider,
|
||||
model: mdl,
|
||||
messages: [
|
||||
userMessage(mdl, userID, [textPart(userID, "Check weather")]),
|
||||
assistantMessage(mdl, assistantID, userID, [
|
||||
reasoningPart(assistantID, "Need a lookup."),
|
||||
toolPart(assistantID, {
|
||||
callID: "call_1",
|
||||
tool: "lookup",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { query: "weather" },
|
||||
output: "sunny",
|
||||
title: "Weather",
|
||||
metadata: {},
|
||||
time: { start: 1, end: 2 },
|
||||
},
|
||||
}),
|
||||
]),
|
||||
],
|
||||
})
|
||||
|
||||
expect(request.messages.map((message) => ({ role: message.role, content: message.content }))).toEqual([
|
||||
{ role: "user", content: [{ type: "text", text: "Check weather" }] },
|
||||
@@ -234,36 +232,34 @@ describe("LLMNative.request", () => {
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
}))
|
||||
|
||||
test("keeps provider-executed tool results on assistant messages", async () => {
|
||||
it.effect("keeps provider-executed tool results on assistant messages", () => Effect.gen(function* () {
|
||||
const mdl = model()
|
||||
const userID = MessageID.ascending()
|
||||
const assistantID = MessageID.ascending()
|
||||
const request = await Effect.runPromise(
|
||||
LLMNative.request({
|
||||
provider: ProviderTest.info({ id: ProviderID.openai }, mdl),
|
||||
model: mdl,
|
||||
messages: [
|
||||
userMessage(mdl, userID, [textPart(userID, "Search docs")]),
|
||||
assistantMessage(mdl, assistantID, userID, [
|
||||
toolPart(assistantID, {
|
||||
callID: "ws_1",
|
||||
tool: "web_search",
|
||||
metadata: { providerExecuted: true, provider: "openai" },
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { query: "effect" },
|
||||
output: "found",
|
||||
title: "Search",
|
||||
metadata: {},
|
||||
time: { start: 1, end: 2 },
|
||||
},
|
||||
}),
|
||||
]),
|
||||
],
|
||||
}),
|
||||
)
|
||||
const request = yield* LLMNative.request({
|
||||
provider: ProviderTest.info({ id: ProviderID.openai }, mdl),
|
||||
model: mdl,
|
||||
messages: [
|
||||
userMessage(mdl, userID, [textPart(userID, "Search docs")]),
|
||||
assistantMessage(mdl, assistantID, userID, [
|
||||
toolPart(assistantID, {
|
||||
callID: "ws_1",
|
||||
tool: "web_search",
|
||||
metadata: { providerExecuted: true, provider: "openai" },
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { query: "effect" },
|
||||
output: "found",
|
||||
title: "Search",
|
||||
metadata: {},
|
||||
time: { start: 1, end: 2 },
|
||||
},
|
||||
}),
|
||||
]),
|
||||
],
|
||||
})
|
||||
|
||||
expect(request.messages.map((message) => ({ role: message.role, content: message.content }))).toEqual([
|
||||
{ role: "user", content: [{ type: "text", text: "Search docs" }] },
|
||||
@@ -289,53 +285,55 @@ describe("LLMNative.request", () => {
|
||||
],
|
||||
},
|
||||
])
|
||||
})
|
||||
}))
|
||||
|
||||
test("fails instead of dropping unsupported native parts", async () => {
|
||||
it.effect("fails instead of dropping unsupported native parts", () => Effect.gen(function* () {
|
||||
const mdl = model()
|
||||
const userID = MessageID.ascending()
|
||||
const exit = yield* LLMNative.request({
|
||||
provider: ProviderTest.info({ id: ProviderID.openai }, mdl),
|
||||
model: mdl,
|
||||
messages: [userMessage(mdl, userID, [filePart(userID)])],
|
||||
}).pipe(Effect.exit)
|
||||
|
||||
await expect(
|
||||
Effect.runPromise(
|
||||
LLMNative.request({
|
||||
provider: ProviderTest.info({ id: ProviderID.openai }, mdl),
|
||||
model: mdl,
|
||||
messages: [userMessage(mdl, userID, [filePart(userID)])],
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow(`Native LLM request conversion does not support file parts in message ${userID}`)
|
||||
})
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) {
|
||||
const err = Cause.squash(exit.cause)
|
||||
expect(err).toBeInstanceOf(Error)
|
||||
if (err instanceof Error) {
|
||||
expect(err.message).toBe(`Native LLM request conversion does not support file parts in message ${userID}`)
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
test("prepares OpenAI Responses text and tool request body", async () => {
|
||||
it.effect("prepares OpenAI Responses text and tool request body", () => Effect.gen(function* () {
|
||||
const mdl = model()
|
||||
const userID = MessageID.ascending()
|
||||
const assistantID = MessageID.ascending()
|
||||
const request = await Effect.runPromise(
|
||||
LLMNative.request({
|
||||
provider: ProviderTest.info({ id: ProviderID.openai }, mdl),
|
||||
model: mdl,
|
||||
messages: [
|
||||
userMessage(mdl, userID, [textPart(userID, "What is the weather?")]),
|
||||
assistantMessage(mdl, assistantID, userID, [
|
||||
toolPart(assistantID, {
|
||||
callID: "call_1",
|
||||
tool: "lookup",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { query: "weather" },
|
||||
output: '{"forecast":"sunny"}',
|
||||
title: "Weather",
|
||||
metadata: {},
|
||||
time: { start: 1, end: 2 },
|
||||
},
|
||||
}),
|
||||
]),
|
||||
],
|
||||
tools: [lookupTool],
|
||||
toolChoice: "lookup",
|
||||
}),
|
||||
)
|
||||
const prepared = await Effect.runPromise(client({ adapters: [OpenAIResponses.adapter] }).prepare(request))
|
||||
const request = yield* LLMNative.request({
|
||||
provider: ProviderTest.info({ id: ProviderID.openai }, mdl),
|
||||
model: mdl,
|
||||
messages: [
|
||||
userMessage(mdl, userID, [textPart(userID, "What is the weather?")]),
|
||||
assistantMessage(mdl, assistantID, userID, [
|
||||
toolPart(assistantID, {
|
||||
callID: "call_1",
|
||||
tool: "lookup",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { query: "weather" },
|
||||
output: '{"forecast":"sunny"}',
|
||||
title: "Weather",
|
||||
metadata: {},
|
||||
time: { start: 1, end: 2 },
|
||||
},
|
||||
}),
|
||||
]),
|
||||
],
|
||||
tools: [lookupTool],
|
||||
toolChoice: "lookup",
|
||||
})
|
||||
const prepared = yield* client({ adapters: [OpenAIResponses.adapter] }).prepare(request)
|
||||
|
||||
expect(prepared.target).toMatchObject({
|
||||
model: "gpt-5",
|
||||
@@ -359,5 +357,71 @@ describe("LLMNative.request", () => {
|
||||
tool_choice: { type: "function", name: "lookup" },
|
||||
stream: true,
|
||||
})
|
||||
})
|
||||
}))
|
||||
|
||||
it.effect("prepares Anthropic Messages text and tool request body", () => Effect.gen(function* () {
|
||||
const mdl = model({
|
||||
id: ModelID.make("claude-sonnet-4-5"),
|
||||
providerID: ProviderID.make("anthropic"),
|
||||
api: { id: "claude-sonnet-4-5", url: "https://api.anthropic.com/v1", npm: "@ai-sdk/anthropic" },
|
||||
})
|
||||
const userID = MessageID.ascending()
|
||||
const assistantID = MessageID.ascending()
|
||||
const request = yield* LLMNative.request({
|
||||
provider: ProviderTest.info({ id: ProviderID.make("anthropic"), key: "anthropic-key" }, mdl),
|
||||
model: mdl,
|
||||
system: ["You are concise."],
|
||||
generation: { maxTokens: 20, temperature: 0 },
|
||||
messages: [
|
||||
userMessage(mdl, userID, [textPart(userID, "What is the weather?")]),
|
||||
assistantMessage(mdl, assistantID, userID, [
|
||||
toolPart(assistantID, {
|
||||
callID: "call_1",
|
||||
tool: "lookup",
|
||||
state: {
|
||||
status: "completed",
|
||||
input: { query: "weather" },
|
||||
output: '{"forecast":"sunny"}',
|
||||
title: "Weather",
|
||||
metadata: {},
|
||||
time: { start: 1, end: 2 },
|
||||
},
|
||||
}),
|
||||
]),
|
||||
],
|
||||
tools: [lookupTool],
|
||||
toolChoice: "lookup",
|
||||
})
|
||||
const prepared = yield* client({ adapters: [AnthropicMessages.adapter] }).prepare(request)
|
||||
|
||||
expect(request.model).toMatchObject({
|
||||
provider: "anthropic",
|
||||
protocol: "anthropic-messages",
|
||||
headers: { "x-api-key": "anthropic-key" },
|
||||
})
|
||||
expect(prepared.target).toMatchObject({
|
||||
model: "claude-sonnet-4-5",
|
||||
system: [{ type: "text", text: "You are concise." }],
|
||||
messages: [
|
||||
{ role: "user", content: [{ type: "text", text: "What is the weather?" }] },
|
||||
{ role: "assistant", content: [{ type: "tool_use", id: "call_1", name: "lookup", input: { query: "weather" } }] },
|
||||
{ role: "user", content: [{ type: "tool_result", tool_use_id: "call_1", content: '{"forecast":"sunny"}' }] },
|
||||
],
|
||||
tools: [
|
||||
{
|
||||
name: "lookup",
|
||||
description: "Lookup project data",
|
||||
input_schema: {
|
||||
type: "object",
|
||||
properties: { query: { type: "string", description: "Search query" } },
|
||||
required: ["query"],
|
||||
},
|
||||
},
|
||||
],
|
||||
tool_choice: { type: "tool", name: "lookup" },
|
||||
stream: true,
|
||||
max_tokens: 20,
|
||||
temperature: 0,
|
||||
})
|
||||
}))
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user