Files
anomalyco_opencode/packages/opencode/test/session/llm-native.test.ts
T
Kit Langton f86a6790a2 refactor(llm): move queryParams off model.native to typed field
Promotes queryParams to a first-class ModelRef field used by Endpoint.baseURL,
so deployment-level URL query params (Azure api-version, OpenAI-compatible
provider knobs) live in a typed home instead of an opaque `native` bag.

Also removes write-only dead fields from `native`:

- openaiCompatibleProvider (set by family helper, never read)
- opencodeProviderID, opencodeModelID (set by opencode bridge + native session
  builder, never read)
- npm (set by opencode bridge, never read)

After this commit `model.native` only carries genuinely provider-specific
opaque options that no other adapter cares about (Bedrock's aws_credentials
+ aws_region for SigV4). Drops the now-dead ProviderShared.queryParams
helper. Updates AGENTS.md doc on native is implicit through the new schema
JSDoc.
2026-05-01 08:12:37 -04:00

1172 lines
40 KiB
TypeScript

import { describe, expect } from "bun:test"
import { AnthropicMessages, BedrockConverse, Gemini, LLMClient, OpenAICompatibleChat, OpenAIResponses, ProviderPatch } from "@opencode-ai/llm"
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/provider"
import type { Tool } from "../../src/tool/tool"
const sessionID = SessionID.descending()
const model = (input: Partial<Provider.Model> = {}) =>
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> = {}): MessageV2.TextPart => ({
id: PartID.ascending(),
sessionID,
messageID,
type: "text",
text,
...input,
})
const filePart = (messageID: MessageID, input: Partial<MessageV2.FilePart> = {}): MessageV2.FilePart => ({
id: PartID.ascending(),
sessionID,
messageID,
type: "file",
mime: "image/png",
url: "data:image/png;base64,abc",
...input,
})
const reasoningPart = (messageID: MessageID, text: string): MessageV2.ReasoningPart => ({
id: PartID.ascending(),
sessionID,
messageID,
type: "reasoning",
text,
time: { start: 1 },
})
const toolPart = (
messageID: MessageID,
input: Partial<MessageV2.ToolPart> & Pick<MessageV2.ToolPart, "callID" | "tool" | "state">,
): MessageV2.ToolPart => ({
id: PartID.ascending(),
sessionID,
messageID,
type: "tool",
callID: input.callID,
tool: input.tool,
state: input.state,
metadata: input.metadata,
})
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,
}
}
const lookupParameters = Schema.Struct({
query: Schema.String.annotate({ description: "Search query" }),
})
const lookupTool = {
id: "lookup",
description: "Lookup project data",
parameters: lookupParameters,
execute: () => Effect.succeed({ title: "", metadata: {}, output: "" }),
} satisfies Tool.Def<typeof lookupParameters>
const it = testEffect(Layer.empty)
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value)
const cacheControl = (value: unknown) => isRecord(value) ? value.cache_control : undefined
const targetArray = (value: unknown, key: string) => isRecord(value) && Array.isArray(value[key]) ? value[key] : []
describe("LLMNative.request", () => {
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 = 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",
model: {
id: "gpt-5",
provider: "openai",
protocol: "openai-responses",
apiKey: "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" }] },
])
}))
it.effect("converts native tool definitions", () => Effect.gen(function* () {
const mdl = model()
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({
name: "lookup",
description: "Lookup project data",
inputSchema: {
type: "object",
properties: {
query: {
type: "string",
description: "Search query",
},
},
required: ["query"],
},
native: {
opencodeToolID: "lookup",
},
})
}))
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 = 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" }] },
{
role: "assistant",
content: [
{ type: "reasoning", text: "Need a lookup.", metadata: undefined },
{ type: "tool-call", id: "call_1", name: "lookup", input: { query: "weather" }, metadata: undefined },
],
},
{
role: "tool",
content: [
{
type: "tool-result",
id: "call_1",
name: "lookup",
result: { type: "text", value: "sunny" },
metadata: undefined,
},
],
},
])
}))
it.effect("converts failed tool results as error tool messages", () => Effect.gen(function* () {
const mdl = model()
const userID = MessageID.ascending()
const assistantID = MessageID.ascending()
const request = yield* LLMNative.request({
provider: ProviderTest.info({ id: ProviderID.openai }, mdl),
model: mdl,
messages: [
userMessage(mdl, userID, [textPart(userID, "Check weather")]),
assistantMessage(mdl, assistantID, userID, [
toolPart(assistantID, {
callID: "call_error",
tool: "lookup",
state: {
status: "error",
input: { query: "weather" },
error: "Lookup failed",
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" }] },
{
role: "assistant",
content: [{ type: "tool-call", id: "call_error", name: "lookup", input: { query: "weather" }, metadata: undefined }],
},
{
role: "tool",
content: [
{
type: "tool-result",
id: "call_error",
name: "lookup",
result: { type: "error", value: "Lookup failed" },
metadata: undefined,
},
],
},
])
}))
it.effect("uses interrupted tool metadata output when present", () => Effect.gen(function* () {
const mdl = model()
const userID = MessageID.ascending()
const assistantID = MessageID.ascending()
const request = yield* LLMNative.request({
provider: ProviderTest.info({ id: ProviderID.openai }, mdl),
model: mdl,
messages: [
userMessage(mdl, userID, [textPart(userID, "Read logs")]),
assistantMessage(mdl, assistantID, userID, [
toolPart(assistantID, {
callID: "call_interrupted",
tool: "read_logs",
state: {
status: "error",
input: { path: "app.log" },
error: "Tool execution aborted",
metadata: { interrupted: true, output: "partial log output" },
time: { start: 1, end: 2 },
},
}),
]),
],
})
expect(request.messages.at(-1)?.content).toEqual([
{
type: "tool-result",
id: "call_interrupted",
name: "read_logs",
result: { type: "text", value: "partial log output" },
metadata: undefined,
},
])
}))
it.effect("marks pending and running tool states as interrupted error results", () => Effect.gen(function* () {
const mdl = model()
const userID = MessageID.ascending()
const assistantID = MessageID.ascending()
const request = yield* LLMNative.request({
provider: ProviderTest.info({ id: ProviderID.openai }, mdl),
model: mdl,
messages: [
userMessage(mdl, userID, [textPart(userID, "Run tools")]),
assistantMessage(mdl, assistantID, userID, [
toolPart(assistantID, {
callID: "call_pending",
tool: "lookup",
state: { status: "pending", input: { query: "pending" }, raw: "" },
}),
toolPart(assistantID, {
callID: "call_running",
tool: "lookup",
state: { status: "running", input: { query: "running" }, title: "Lookup", time: { start: 1 } },
}),
]),
],
})
expect(request.messages.map((message) => ({ role: message.role, content: message.content }))).toEqual([
{ role: "user", content: [{ type: "text", text: "Run tools" }] },
{
role: "assistant",
content: [
{ type: "tool-call", id: "call_pending", name: "lookup", input: { query: "pending" }, metadata: undefined },
{ type: "tool-call", id: "call_running", name: "lookup", input: { query: "running" }, metadata: undefined },
],
},
{
role: "tool",
content: [
{
type: "tool-result",
id: "call_pending",
name: "lookup",
result: { type: "error", value: "[Tool execution was interrupted]" },
metadata: undefined,
},
],
},
{
role: "tool",
content: [
{
type: "tool-result",
id: "call_running",
name: "lookup",
result: { type: "error", value: "[Tool execution was interrupted]" },
metadata: undefined,
},
],
},
])
}))
it.effect("uses the compacted-output placeholder for compacted completed tools", () => Effect.gen(function* () {
const mdl = model()
const userID = MessageID.ascending()
const assistantID = MessageID.ascending()
const request = yield* LLMNative.request({
provider: ProviderTest.info({ id: ProviderID.openai }, mdl),
model: mdl,
messages: [
userMessage(mdl, userID, [textPart(userID, "Read old output")]),
assistantMessage(mdl, assistantID, userID, [
toolPart(assistantID, {
callID: "call_compacted",
tool: "lookup",
state: {
status: "completed",
input: { query: "old" },
output: "old output",
title: "Lookup",
metadata: {},
time: { start: 1, end: 2, compacted: 3 },
},
}),
]),
],
})
expect(request.messages.at(-1)?.content).toEqual([
{
type: "tool-result",
id: "call_compacted",
name: "lookup",
result: { type: "text", value: "[Old tool result content cleared]" },
metadata: undefined,
},
])
}))
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 = 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" }] },
{
role: "assistant",
content: [
{
type: "tool-call",
id: "ws_1",
name: "web_search",
input: { query: "effect" },
providerExecuted: true,
metadata: { provider: "openai" },
},
{
type: "tool-result",
id: "ws_1",
name: "web_search",
result: { type: "text", value: "found" },
providerExecuted: true,
metadata: { provider: "openai" },
},
],
},
])
}))
it.effect("fails instead of dropping unsupported native parts", () => Effect.gen(function* () {
const mdl = model()
const userID = MessageID.ascending()
// Reasoning parts are valid on assistant messages but not user messages —
// a clean stand-in for the "static gate rejects unknown shapes" path.
const exit = yield* LLMNative.request({
provider: ProviderTest.info({ id: ProviderID.openai }, mdl),
model: mdl,
messages: [userMessage(mdl, userID, [reasoningPart(userID, "internal thought")])],
}).pipe(Effect.exit)
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 reasoning parts in message ${userID}`)
}
}
}))
it.effect("converts user file parts with data: URLs to MediaPart", () => Effect.gen(function* () {
const mdl = model()
const userID = MessageID.ascending()
const request = yield* LLMNative.request({
provider: ProviderTest.info({ id: ProviderID.openai }, mdl),
model: mdl,
messages: [
userMessage(mdl, userID, [
textPart(userID, "describe this"),
filePart(userID, {
mime: "image/png",
filename: "screenshot.png",
url: "data:image/png;base64,iVBORw0KGgo=",
}),
]),
],
})
expect(request.messages).toHaveLength(1)
expect(request.messages[0].content).toEqual([
{ type: "text", text: "describe this" },
{ type: "media", mediaType: "image/png", data: "iVBORw0KGgo=", filename: "screenshot.png" },
])
}))
it.effect("preserves filename and base64 payload for document data URLs", () => Effect.gen(function* () {
const mdl = model()
const userID = MessageID.ascending()
const request = yield* LLMNative.request({
provider: ProviderTest.info({ id: ProviderID.openai }, mdl),
model: mdl,
messages: [
userMessage(mdl, userID, [
filePart(userID, {
mime: "application/pdf",
filename: "report.pdf",
url: "data:application/pdf;base64,JVBERi0xLg==",
}),
]),
],
})
expect(request.messages[0].content).toEqual([
{ type: "media", mediaType: "application/pdf", data: "JVBERi0xLg==", filename: "report.pdf" },
])
}))
it.effect("rejects file parts whose URL is not a data: URL", () => 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, { mime: "image/png", url: "https://example.com/img.png" }),
]),
],
}).pipe(Effect.exit)
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).toContain("file parts")
expect(err.message).toContain(userID)
expect(err.message).toContain("https://example.com/img.png")
}
}
}))
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 = 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* LLMClient.make({ adapters: [OpenAIResponses.adapter] }).prepare(request)
expect(prepared.target).toMatchObject({
model: "gpt-5",
input: [
{ role: "user", content: [{ type: "input_text", text: "What is the weather?" }] },
{ type: "function_call", call_id: "call_1", name: "lookup", arguments: '{"query":"weather"}' },
{ type: "function_call_output", call_id: "call_1", output: '{"forecast":"sunny"}' },
],
tools: [
{
type: "function",
name: "lookup",
description: "Lookup project data",
parameters: {
type: "object",
properties: { query: { type: "string", description: "Search query" } },
required: ["query"],
},
},
],
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* LLMClient.make({ adapters: [AnthropicMessages.adapter] }).prepare(request)
expect(request.model).toMatchObject({
provider: "anthropic",
protocol: "anthropic-messages",
apiKey: "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,
})
}))
it.effect("prepares OpenAI-compatible Chat text and tool request body", () => Effect.gen(function* () {
const mdl = model({
id: ModelID.make("meta-llama/Llama-3.3-70B-Instruct-Turbo"),
providerID: ProviderID.make("togetherai"),
api: {
id: "meta-llama/Llama-3.3-70B-Instruct-Turbo",
url: "https://api.together.xyz/v1",
npm: "@ai-sdk/togetherai",
},
})
const userID = MessageID.ascending()
const assistantID = MessageID.ascending()
const request = yield* LLMNative.request({
provider: ProviderTest.info({ id: ProviderID.make("togetherai"), key: "together-key" }, mdl),
model: mdl,
generation: { maxTokens: 64, 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* LLMClient.make({ adapters: [OpenAICompatibleChat.adapter] }).prepare(request)
expect(request.model).toMatchObject({
provider: "togetherai",
protocol: "openai-compatible-chat",
baseURL: "https://api.together.xyz/v1",
apiKey: "together-key",
})
expect(prepared.target).toMatchObject({
model: "meta-llama/Llama-3.3-70B-Instruct-Turbo",
messages: [
{ role: "user", content: "What is the weather?" },
{
role: "assistant",
content: null,
tool_calls: [
{
id: "call_1",
type: "function",
function: { name: "lookup", arguments: '{"query":"weather"}' },
},
],
},
{ role: "tool", tool_call_id: "call_1", content: '{"forecast":"sunny"}' },
],
tools: [
{
type: "function",
function: {
name: "lookup",
description: "Lookup project data",
parameters: {
type: "object",
properties: { query: { type: "string", description: "Search query" } },
required: ["query"],
},
},
},
],
tool_choice: { type: "function", function: { name: "lookup" } },
stream: true,
max_tokens: 64,
temperature: 0,
})
}))
it.effect("maps Azure native requests to OpenAI Responses by default", () => Effect.gen(function* () {
const mdl = model({
id: ModelID.make("gpt-5"),
providerID: ProviderID.make("azure"),
api: { id: "gpt-5-deployment", url: "", npm: "@ai-sdk/azure" },
})
const userID = MessageID.ascending()
const request = yield* LLMNative.request({
provider: ProviderTest.info({
id: ProviderID.make("azure"),
key: "azure-key",
options: { resourceName: "opencode-test", apiVersion: "2025-04-01-preview" },
}, mdl),
model: mdl,
messages: [userMessage(mdl, userID, [textPart(userID, "Hello")])],
})
expect(request.model).toMatchObject({
id: "gpt-5-deployment",
provider: "azure",
protocol: "openai-responses",
baseURL: "https://opencode-test.openai.azure.com/openai/v1",
apiKey: "azure-key",
queryParams: { "api-version": "2025-04-01-preview" },
})
}))
it.effect("maps Azure useCompletionUrls native requests to OpenAI Chat", () => Effect.gen(function* () {
const mdl = model({
id: ModelID.make("gpt-4.1"),
providerID: ProviderID.make("azure"),
api: { id: "gpt-4-1-deployment", url: "", npm: "@ai-sdk/azure" },
options: { useCompletionUrls: true },
})
const userID = MessageID.ascending()
const request = yield* LLMNative.request({
provider: ProviderTest.info({ id: ProviderID.make("azure"), key: "azure-key", options: { resourceName: "opencode-test" } }, mdl),
model: mdl,
messages: [userMessage(mdl, userID, [textPart(userID, "Hello")])],
})
expect(request.model).toMatchObject({
id: "gpt-4-1-deployment",
provider: "azure",
protocol: "openai-chat",
baseURL: "https://opencode-test.openai.azure.com/openai/v1",
apiKey: "azure-key",
queryParams: { "api-version": "v1" },
})
}))
it.effect("prepares Gemini text and tool request body", () => Effect.gen(function* () {
const mdl = model({
id: ModelID.make("gemini-2.5-flash"),
providerID: ProviderID.make("google"),
api: { id: "gemini-2.5-flash", url: "https://generativelanguage.googleapis.com/v1beta", npm: "@ai-sdk/google" },
})
const userID = MessageID.ascending()
const assistantID = MessageID.ascending()
const request = yield* LLMNative.request({
provider: ProviderTest.info({ id: ProviderID.make("google"), key: "google-key" }, mdl),
model: mdl,
system: ["You are concise."],
generation: { maxTokens: 32, 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* LLMClient.make({ adapters: [Gemini.adapter] }).prepare(request)
expect(request.model).toMatchObject({
provider: "google",
protocol: "gemini",
baseURL: "https://generativelanguage.googleapis.com/v1beta",
apiKey: "google-key",
})
expect(prepared.target).toMatchObject({
systemInstruction: { parts: [{ text: "You are concise." }] },
contents: [
{ role: "user", parts: [{ text: "What is the weather?" }] },
{ role: "model", parts: [{ functionCall: { name: "lookup", args: { query: "weather" } } }] },
{
role: "user",
parts: [{ functionResponse: { name: "lookup", response: { name: "lookup", content: '{"forecast":"sunny"}' } } }],
},
],
tools: [
{
functionDeclarations: [
{
name: "lookup",
description: "Lookup project data",
parameters: {
type: "object",
properties: { query: { type: "string", description: "Search query" } },
required: ["query"],
},
},
],
},
],
toolConfig: { functionCallingConfig: { mode: "ANY", allowedFunctionNames: ["lookup"] } },
generationConfig: { maxOutputTokens: 32, temperature: 0 },
})
}))
// Cache hint policy. The bridge produces a hint-free `LLMRequest`; the
// `ProviderPatch.cachePromptHints` patch (loaded in `ProviderPatch.defaults`)
// marks first-2 system parts and last-2 messages with ephemeral cache
// hints when the model advertises `capabilities.cache.prompt`. Adapters
// then lower the hints to the provider-specific marker — `cache_control`
// on Anthropic, `cachePoint` on Bedrock. Non-cache adapters never see a
// hint thanks to the predicate gate.
const anthropicModel = () =>
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 bedrockModel = () =>
model({
id: ModelID.make("us.amazon.nova-micro-v1:0"),
providerID: ProviderID.make("amazon-bedrock"),
api: {
id: "us.amazon.nova-micro-v1:0",
url: "https://bedrock-runtime.us-east-1.amazonaws.com",
npm: "@ai-sdk/amazon-bedrock",
},
})
it.effect("lowers cache hints to Anthropic cache_control on the first 2 system blocks", () =>
Effect.gen(function* () {
const mdl = anthropicModel()
const userID = MessageID.ascending()
const request = yield* LLMNative.request({
provider: ProviderTest.info({ id: ProviderID.make("anthropic"), key: "anthropic-key" }, mdl),
model: mdl,
system: ["First", "Second", "Third"],
messages: [userMessage(mdl, userID, [textPart(userID, "hello")])],
})
const prepared = yield* LLMClient.make({
adapters: [AnthropicMessages.adapter],
patches: ProviderPatch.defaults,
}).prepare(request)
expect(prepared.target).toMatchObject({
system: [
{ type: "text", text: "First", cache_control: { type: "ephemeral" } },
{ type: "text", text: "Second", cache_control: { type: "ephemeral" } },
{ type: "text", text: "Third" },
],
})
// The third system block must not carry a cache_control marker.
expect(cacheControl(targetArray(prepared.target, "system")[2])).toBeUndefined()
}))
it.effect("lowers cache hints to Anthropic cache_control on the last text block of the last 2 messages", () =>
Effect.gen(function* () {
const mdl = anthropicModel()
const messageIds = [MessageID.ascending(), MessageID.ascending(), MessageID.ascending()]
const request = yield* LLMNative.request({
provider: ProviderTest.info({ id: ProviderID.make("anthropic"), key: "anthropic-key" }, mdl),
model: mdl,
messages: messageIds.map((id, index) => userMessage(mdl, id, [textPart(id, `m${index}`)])),
})
const prepared = yield* LLMClient.make({
adapters: [AnthropicMessages.adapter],
patches: ProviderPatch.defaults,
}).prepare(request)
expect(prepared.target).toMatchObject({
messages: [
{ role: "user", content: [{ type: "text", text: "m0" }] },
{ role: "user", content: [{ type: "text", text: "m1", cache_control: { type: "ephemeral" } }] },
{ role: "user", content: [{ type: "text", text: "m2", cache_control: { type: "ephemeral" } }] },
],
})
// The first message's text must not carry cache_control.
const firstMessage = targetArray(prepared.target, "messages")[0]
expect(cacheControl(targetArray(firstMessage, "content")[0])).toBeUndefined()
}))
it.effect("lowers cache hints to Bedrock Converse cachePoint marker blocks end-to-end", () =>
Effect.gen(function* () {
const mdl = bedrockModel()
const userID = MessageID.ascending()
const request = yield* LLMNative.request({
provider: ProviderTest.info({ id: ProviderID.make("amazon-bedrock"), key: "bedrock-bearer" }, mdl),
model: mdl,
system: ["You are concise."],
messages: [userMessage(mdl, userID, [textPart(userID, "hello")])],
})
const prepared = yield* LLMClient.make({
adapters: [BedrockConverse.adapter],
patches: ProviderPatch.defaults,
}).prepare(request)
expect(prepared.target).toMatchObject({
system: [{ text: "You are concise." }, { cachePoint: { type: "default" } }],
messages: [
{
role: "user",
content: [{ text: "hello" }, { cachePoint: { type: "default" } }],
},
],
})
}))
it.effect("does not apply cache hints when the model does not support prompt caching", () =>
Effect.gen(function* () {
// gpt-5 / openai resolves to openai-responses with cache.prompt: false.
// The patch's `when` predicate must skip, leaving the target hint-free.
const mdl = model()
const ids = [MessageID.ascending(), MessageID.ascending()]
const request = yield* LLMNative.request({
provider: ProviderTest.info({ id: ProviderID.openai, key: "openai-key" }, mdl),
model: mdl,
system: ["A", "B", "C"],
messages: ids.map((id, index) => userMessage(mdl, id, [textPart(id, `m${index}`)])),
})
const prepared = yield* LLMClient.make({
adapters: [OpenAIResponses.adapter],
patches: ProviderPatch.defaults,
}).prepare(request)
// The serialized OpenAI Responses payload has no cache concept; the
// assertion is that nothing in the target carries a cache marker.
const json = JSON.stringify(prepared.target)
expect(json).not.toContain("cache_control")
expect(json).not.toContain("cachePoint")
expect(json).not.toContain("ephemeral")
}))
// Encrypted reasoning round-trip. OpenCode persists the encrypted blob in
// `MessageV2.ReasoningPart.metadata` using the AI-SDK's provider-keyed
// shape (`metadata.anthropic.signature`,
// `metadata.openai.reasoningEncryptedContent`) for sessions started on the
// AI-SDK path. Future LLM-native sessions will store it as a top-level
// `metadata.encrypted` string. The bridge probes both conventions and
// populates `LLM.ReasoningPart.encrypted` so adapters can lower it to the
// wire (Anthropic `thinking.signature`, Bedrock `reasoningText.signature`).
const reasoningPartWithMetadata = (
messageID: MessageID,
text: string,
metadata: Record<string, unknown>,
): MessageV2.ReasoningPart => ({
id: PartID.ascending(),
sessionID,
messageID,
type: "reasoning",
text,
metadata,
time: { start: 1 },
})
it.effect("extracts AI-SDK Anthropic signature into LLM.ReasoningPart.encrypted", () =>
Effect.gen(function* () {
const mdl = anthropicModel()
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,
messages: [
userMessage(mdl, userID, [textPart(userID, "think about it")]),
assistantMessage(mdl, assistantID, userID, [
reasoningPartWithMetadata(assistantID, "thinking...", {
anthropic: { signature: "ant-signature-abc" },
}),
]),
],
})
// The bridge surfaces `encrypted` on the LLM IR's ReasoningPart.
expect(request.messages[1].content[0]).toMatchObject({
type: "reasoning",
text: "thinking...",
encrypted: "ant-signature-abc",
})
}))
it.effect("lowers encrypted reasoning to Anthropic thinking.signature end-to-end", () =>
Effect.gen(function* () {
const mdl = anthropicModel()
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,
messages: [
userMessage(mdl, userID, [textPart(userID, "think about it")]),
assistantMessage(mdl, assistantID, userID, [
reasoningPartWithMetadata(assistantID, "thinking...", {
anthropic: { signature: "ant-signature-abc" },
}),
]),
],
})
const prepared = yield* LLMClient.make({
adapters: [AnthropicMessages.adapter],
patches: ProviderPatch.defaults,
}).prepare(request)
expect(prepared.target).toMatchObject({
messages: [
{ role: "user" },
{
role: "assistant",
content: [{ type: "thinking", thinking: "thinking...", signature: "ant-signature-abc" }],
},
],
})
}))
it.effect("extracts AI-SDK OpenAI reasoningEncryptedContent into LLM.ReasoningPart.encrypted", () =>
Effect.gen(function* () {
const mdl = anthropicModel() // any cache-irrelevant cache-capable model works for the bridge check
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,
messages: [
userMessage(mdl, userID, [textPart(userID, "think")]),
assistantMessage(mdl, assistantID, userID, [
reasoningPartWithMetadata(assistantID, "internal", {
openai: { reasoningEncryptedContent: "openai-blob-xyz" },
}),
]),
],
})
expect(request.messages[1].content[0]).toMatchObject({
type: "reasoning",
encrypted: "openai-blob-xyz",
})
}))
it.effect("extracts a top-level metadata.encrypted string", () =>
Effect.gen(function* () {
const mdl = anthropicModel()
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,
messages: [
userMessage(mdl, userID, [textPart(userID, "think")]),
assistantMessage(mdl, assistantID, userID, [
reasoningPartWithMetadata(assistantID, "internal", { encrypted: "native-blob" }),
]),
],
})
expect(request.messages[1].content[0]).toMatchObject({
type: "reasoning",
encrypted: "native-blob",
})
}))
it.effect("leaves encrypted unset when reasoning metadata carries no known key", () =>
Effect.gen(function* () {
const mdl = anthropicModel()
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,
messages: [
userMessage(mdl, userID, [textPart(userID, "think")]),
assistantMessage(mdl, assistantID, userID, [
reasoningPartWithMetadata(assistantID, "internal", { somethingElse: "x" }),
]),
],
})
const reasoning = request.messages[1].content[0]
expect(reasoning).toMatchObject({ type: "reasoning", text: "internal" })
if (reasoning.type === "reasoning") expect(reasoning.encrypted).toBeUndefined()
}))
})