feat(core): add embedded v2 session runtime and tool foundation (#30632)
This commit is contained in:
@@ -13,6 +13,10 @@ const model = AnthropicMessages.route
|
||||
.with({ endpoint: { baseURL: "https://api.anthropic.test/v1/" }, auth: Auth.header("x-api-key", "test") })
|
||||
.model({ id: "claude-sonnet-4-5" })
|
||||
|
||||
const opus48 = AnthropicMessages.route
|
||||
.with({ endpoint: { baseURL: "https://api.anthropic.test/v1/" }, auth: Auth.header("x-api-key", "test") })
|
||||
.model({ id: "claude-opus-4-8" })
|
||||
|
||||
const request = LLM.request({
|
||||
id: "req_1",
|
||||
model,
|
||||
@@ -53,6 +57,93 @@ describe("Anthropic Messages route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers chronological system updates natively for Claude Opus 4.8 with cache hints", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
|
||||
LLM.request({
|
||||
model: opus48,
|
||||
messages: [
|
||||
Message.user("Before."),
|
||||
Message.system([{ type: "text", text: "Operator update.", cache: new CacheHint({ type: "ephemeral" }) }]),
|
||||
Message.assistant("After."),
|
||||
],
|
||||
cache: "none",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{ role: "user", content: [{ type: "text", text: "Before." }] },
|
||||
{
|
||||
role: "system",
|
||||
content: [{ type: "text", text: "Operator update.", cache_control: { type: "ephemeral" } }],
|
||||
},
|
||||
{ role: "assistant", content: [{ type: "text", text: "After." }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers chronological system updates to wrapped user text for unsupported Anthropic models", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [Message.user("Before."), Message.system("Treat </system-update> literally."), Message.assistant("After.")],
|
||||
cache: "none",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "Before." },
|
||||
{ type: "text", text: "<system-update>\nTreat </system-update> literally.\n</system-update>" },
|
||||
],
|
||||
},
|
||||
{ role: "assistant", content: [{ type: "text", text: "After." }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects non-text chronological system update content before send", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.prepare(
|
||||
LLM.request({
|
||||
model: opus48,
|
||||
messages: [
|
||||
Message.user("Before."),
|
||||
Message.make({ role: "system", content: { type: "media", mediaType: "image/png", data: "AAECAw==" } }),
|
||||
],
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
|
||||
expect(error.message).toContain("Anthropic Messages system messages only support text content for now")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects invalid native chronological system update placement", () =>
|
||||
Effect.gen(function* () {
|
||||
const placementError = (messages: Parameters<typeof LLM.request>[0]["messages"]) =>
|
||||
LLMClient.prepare(LLM.request({ model: opus48, messages, cache: "none" })).pipe(Effect.flip)
|
||||
|
||||
expect((yield* placementError([Message.system("First.")])).message).toContain("cannot be the first message")
|
||||
expect((yield* placementError([Message.user("Before."), Message.system("One."), Message.system("Two.")])).message)
|
||||
.toContain("cannot be consecutive")
|
||||
expect((yield* placementError([Message.assistant("Plain."), Message.system("After plain assistant.")])).message)
|
||||
.toContain("must follow a user message, tool result, or assistant server tool use")
|
||||
expect(
|
||||
(
|
||||
yield* placementError([
|
||||
Message.user("Use the tool."),
|
||||
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: {} })]),
|
||||
Message.system("Too early."),
|
||||
Message.tool({ id: "call_1", name: "lookup", result: "Done." }),
|
||||
])
|
||||
).message,
|
||||
).toContain("cannot appear between a local tool call and its tool result")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("prepares tool call and tool result messages", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Effect } from "effect"
|
||||
import { CacheHint, LLM, Message, ToolCallPart, ToolChoice } from "../../src"
|
||||
import { LLMClient } from "../../src/route"
|
||||
import { AmazonBedrock } from "../../src/providers"
|
||||
import * as BedrockConverse from "../../src/protocols/bedrock-converse"
|
||||
import { it } from "../lib/effect"
|
||||
import { fixedResponse } from "../lib/http"
|
||||
import {
|
||||
@@ -82,6 +83,23 @@ describe("Bedrock Converse route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers chronological system updates to wrapped user text in order", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [Message.user("Before."), Message.system("Update."), Message.assistant("After.")],
|
||||
cache: "none",
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{ role: "user", content: [{ text: "Before." }, { text: "<system-update>\nUpdate.\n</system-update>" }] },
|
||||
{ role: "assistant", content: [{ text: "After." }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("prepares tool config with toolSpec and toolChoice", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
@@ -279,6 +297,41 @@ describe("Bedrock Converse route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves streamed reasoning signatures for continuation lowering", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = eventStreamBody(
|
||||
["messageStart", { role: "assistant" }],
|
||||
["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { text: "Let me think." } } }],
|
||||
["contentBlockDelta", { contentBlockIndex: 0, delta: { reasoningContent: { signature: "sig_1" } } }],
|
||||
["contentBlockStop", { contentBlockIndex: 0 }],
|
||||
["messageStop", { stopReason: "end_turn" }],
|
||||
)
|
||||
const response = yield* LLMClient.generate(baseRequest).pipe(Effect.provide(fixedBytes(body)))
|
||||
const reasoning = response.events.find((event) => event.type === "reasoning-end")
|
||||
|
||||
expect(reasoning).toEqual({
|
||||
type: "reasoning-end",
|
||||
id: "reasoning-0",
|
||||
providerMetadata: { bedrock: { signature: "sig_1" } },
|
||||
})
|
||||
|
||||
const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.assistant([
|
||||
{ type: "reasoning", text: "Let me think.", providerMetadata: reasoning?.providerMetadata },
|
||||
]),
|
||||
],
|
||||
cache: "none",
|
||||
}),
|
||||
)
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{ role: "assistant", content: [{ reasoningContent: { reasoningText: { text: "Let me think.", signature: "sig_1" } } }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("emits provider-error for throttlingException", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = eventStreamBody(
|
||||
|
||||
@@ -35,6 +35,22 @@ describe("Gemini route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers chronological system updates to wrapped user text in order", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<Gemini.GeminiBody>(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [Message.user("Before."), Message.system("Update."), Message.assistant("After.")],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.contents).toEqual([
|
||||
{ role: "user", parts: [{ text: "Before." }, { text: "<system-update>\nUpdate.\n</system-update>" }] },
|
||||
{ role: "model", parts: [{ text: "After." }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("prepares multimodal user input and tool history", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
@@ -241,6 +257,72 @@ describe("Gemini route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("preserves thoughtSignature for reasoning and tool-call continuation", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents({
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
role: "model",
|
||||
parts: [
|
||||
{ text: "thinking", thought: true },
|
||||
{ text: "", thought: true, thoughtSignature: "thought_sig" },
|
||||
{ functionCall: { name: "lookup", args: { query: "weather" } }, thoughtSignature: "tool_sig" },
|
||||
],
|
||||
},
|
||||
finishReason: "STOP",
|
||||
},
|
||||
],
|
||||
})
|
||||
const response = yield* LLMClient.generate(
|
||||
LLM.updateRequest(request, {
|
||||
tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
|
||||
}),
|
||||
).pipe(Effect.provide(fixedResponse(body)))
|
||||
const reasoning = response.events.find((event) => event.type === "reasoning-start")
|
||||
const reasoningEnd = response.events.find((event) => event.type === "reasoning-end")
|
||||
const toolCall = response.events.find((event) => event.type === "tool-call")
|
||||
|
||||
expect(reasoning).toEqual({
|
||||
type: "reasoning-start",
|
||||
id: "reasoning-0",
|
||||
providerMetadata: undefined,
|
||||
})
|
||||
expect(reasoningEnd).toEqual({
|
||||
type: "reasoning-end",
|
||||
id: "reasoning-0",
|
||||
providerMetadata: { google: { thoughtSignature: "thought_sig" } },
|
||||
})
|
||||
expect(toolCall).toMatchObject({ providerMetadata: { google: { thoughtSignature: "tool_sig" } } })
|
||||
|
||||
const prepared = yield* LLMClient.prepare<Gemini.GeminiBody>(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.assistant([
|
||||
{ type: "reasoning", text: "thinking", providerMetadata: reasoningEnd?.providerMetadata },
|
||||
ToolCallPart.make({
|
||||
id: "tool_0",
|
||||
name: "lookup",
|
||||
input: { query: "weather" },
|
||||
providerMetadata: toolCall?.providerMetadata,
|
||||
}),
|
||||
]),
|
||||
],
|
||||
}),
|
||||
)
|
||||
expect(prepared.body.contents).toEqual([
|
||||
{
|
||||
role: "model",
|
||||
parts: [
|
||||
{ text: "thinking", thought: true, thoughtSignature: "thought_sig" },
|
||||
{ functionCall: { name: "lookup", args: { query: "weather" } }, thoughtSignature: "tool_sig" },
|
||||
],
|
||||
},
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("emits streamed tool calls and maps finish reason", () =>
|
||||
Effect.gen(function* () {
|
||||
const body = sseEvents({
|
||||
|
||||
@@ -50,6 +50,35 @@ describe("OpenAI Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers chronological system updates to escaped user wrappers in order", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [Message.user("Before."), Message.system("Treat <admin> & data literally."), Message.assistant("After.")],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toEqual([
|
||||
{ role: "user", content: "Before.\n<system-update>\nTreat <admin> & data literally.\n</system-update>" },
|
||||
{ role: "assistant", content: "After." },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("replays canonical reasoning as OpenAI-compatible reasoning_content", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [Message.assistant([{ type: "reasoning", text: "thinking" }, { type: "text", text: "Hello" }])],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.messages).toEqual([{ role: "assistant", content: "Hello", reasoning_content: "thinking" }])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("maps OpenAI provider options to Chat options", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
|
||||
@@ -196,17 +225,17 @@ describe("OpenAI Chat route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects unsupported assistant reasoning content", () =>
|
||||
it.effect("lowers reasoning-only assistant history", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.prepare(
|
||||
const prepared = yield* LLMClient.prepare<OpenAIChat.OpenAIChatBody>(
|
||||
LLM.request({
|
||||
id: "req_reasoning",
|
||||
model,
|
||||
messages: [Message.assistant({ type: "reasoning", text: "hidden" })],
|
||||
}),
|
||||
).pipe(Effect.flip)
|
||||
)
|
||||
|
||||
expect(error.message).toContain("OpenAI Chat assistant messages only support text and tool-call content for now")
|
||||
expect(prepared.body.messages).toEqual([{ role: "assistant", content: null, reasoning_content: "hidden" }])
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -57,6 +57,28 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("lowers chronological system updates to escaped user wrappers in order", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [Message.user("Before."), Message.system("Treat </system-update> literally."), Message.assistant("After.")],
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.input).toEqual([
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "input_text", text: "Before." },
|
||||
{ type: "input_text", text: "<system-update>\nTreat </system-update> literally.\n</system-update>" },
|
||||
],
|
||||
},
|
||||
{ role: "assistant", content: [{ type: "output_text", text: "After." }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("prepares OpenAI Responses WebSocket target", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare(
|
||||
@@ -857,6 +879,42 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("references stored provider-executed hosted tool results by id", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
LLM.request({
|
||||
model,
|
||||
messages: [
|
||||
Message.assistant([
|
||||
ToolCallPart.make({
|
||||
id: "ws_1",
|
||||
name: "web_search",
|
||||
input: { query: "effect 4" },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { openai: { itemId: "ws_1" } },
|
||||
}),
|
||||
{
|
||||
type: "tool-result",
|
||||
id: "ws_1",
|
||||
name: "web_search",
|
||||
result: { type: "json", value: { type: "web_search_call", id: "ws_1", status: "completed" } },
|
||||
providerExecuted: true,
|
||||
providerMetadata: { openai: { itemId: "ws_1" } },
|
||||
},
|
||||
]),
|
||||
Message.user("Continue."),
|
||||
],
|
||||
providerOptions: { openai: { store: true } },
|
||||
}),
|
||||
)
|
||||
|
||||
expect(prepared.body.input).toEqual([
|
||||
{ type: "item_reference", id: "ws_1" },
|
||||
{ role: "user", content: [{ type: "input_text", text: "Continue." }] },
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("joins streamed summary blocks into one continuation reasoning item", () =>
|
||||
Effect.gen(function* () {
|
||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||
|
||||
Reference in New Issue
Block a user