| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584 |
- import { describe, expect } from "bun:test"
- import { Effect } from "effect"
- import { LLM, LLMError, Message, ToolCallPart, Usage } from "../../src"
- import { Auth, LLMClient } from "../../src/route"
- import * as Gemini from "../../src/protocols/gemini"
- import { ProviderShared } from "../../src/protocols/shared"
- import { it } from "../lib/effect"
- import { fixedResponse } from "../lib/http"
- import { sseEvents, sseRaw } from "../lib/sse"
- const model = Gemini.route
- .with({
- endpoint: { baseURL: "https://generativelanguage.test/v1beta/" },
- auth: Auth.header("x-goog-api-key", "test"),
- })
- .model({ id: "gemini-2.5-flash" })
- const request = LLM.request({
- id: "req_1",
- model,
- system: "You are concise.",
- prompt: "Say hello.",
- generation: { maxTokens: 20, temperature: 0 },
- })
- describe("Gemini route", () => {
- it.effect("prepares Gemini target", () =>
- Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(request)
- expect(prepared.body).toEqual({
- contents: [{ role: "user", parts: [{ text: "Say hello." }] }],
- systemInstruction: { parts: [{ text: "You are concise." }] },
- generationConfig: { maxOutputTokens: 20, temperature: 0 },
- })
- }),
- )
- 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(
- LLM.request({
- id: "req_tool_result",
- model,
- tools: [
- {
- name: "lookup",
- description: "Lookup data",
- inputSchema: { type: "object", properties: { query: { type: "string" } } },
- },
- ],
- toolChoice: { type: "tool", name: "lookup" },
- messages: [
- Message.user([
- { type: "text", text: "What is in this image?" },
- { type: "media", mediaType: "image/png", data: "AAECAw==" },
- ]),
- Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]),
- Message.tool({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }),
- ],
- }),
- )
- expect(prepared.body).toEqual({
- contents: [
- {
- role: "user",
- parts: [{ text: "What is in this image?" }, { inlineData: { mimeType: "image/png", data: "AAECAw==" } }],
- },
- {
- 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 data",
- parameters: { type: "object", properties: { query: { type: "string" } } },
- },
- ],
- },
- ],
- toolConfig: { functionCallingConfig: { mode: "ANY", allowedFunctionNames: ["lookup"] } },
- })
- }),
- )
- it.effect("continues image tool results as inline vision input without base64 text", () =>
- Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare<Gemini.GeminiBody>(
- LLM.request({
- model,
- messages: [
- Message.assistant([ToolCallPart.make({ id: "call_image", name: "read", input: { path: "pixel.png" } })]),
- Message.tool({
- id: "call_image",
- name: "read",
- result: {
- type: "content",
- value: [
- { type: "text", text: "Image read successfully" },
- { type: "file", uri: "data:image/png;base64,AAECAw==", mime: "image/png", name: "pixel.png" },
- ],
- },
- }),
- ],
- }),
- )
- expect(prepared.body.contents).toEqual([
- { role: "model", parts: [{ functionCall: { name: "read", args: { path: "pixel.png" } } }] },
- {
- role: "user",
- parts: [
- {
- functionResponse: {
- name: "read",
- response: { name: "read", content: "Image read successfully" },
- },
- },
- { inlineData: { mimeType: "image/png", data: "AAECAw==" } },
- ],
- },
- ])
- expect(JSON.stringify(prepared.body.contents)).not.toContain('"content":"AAECAw=="')
- }),
- )
- it.effect("strips matching data URLs to raw base64 inlineData", () =>
- Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare<Gemini.GeminiBody>(
- LLM.request({
- model,
- messages: [
- Message.user({ type: "media", mediaType: "image/png", data: "data:image/png;base64,AAEC" }),
- Message.tool({
- id: "call_image",
- name: "read",
- result: {
- type: "content",
- value: [{ type: "file", uri: "data:image/jpeg;base64,/9j/", mime: "image/jpeg" }],
- },
- }),
- ],
- }),
- )
- expect(prepared.body.contents).toEqual([
- { role: "user", parts: [{ inlineData: { mimeType: "image/png", data: "AAEC" } }] },
- {
- role: "user",
- parts: [
- { functionResponse: { name: "read", response: { name: "read", content: "" } } },
- { inlineData: { mimeType: "image/jpeg", data: "/9j/" } },
- ],
- },
- ])
- }),
- )
- for (const [name, media] of [
- ["mismatched data URL MIME", { mediaType: "image/png", data: "data:image/jpeg;base64,/9j/" }],
- ["malformed base64", { mediaType: "image/png", data: "%%%=" }],
- ["unsupported SVG", { mediaType: "image/svg+xml", data: "PHN2Zz4=" }],
- ] as const)
- it.effect(`rejects ${name}`, () =>
- Effect.gen(function* () {
- const error = yield* LLMClient.prepare(
- LLM.request({ model, messages: [Message.user({ type: "media", ...media })] }),
- ).pipe(Effect.flip)
- expect(error.message).toMatch(/does not support|does not match|valid base64/)
- }),
- )
- it.effect("rejects oversized image input", () =>
- Effect.gen(function* () {
- const error = yield* LLMClient.prepare(
- LLM.request({
- model,
- messages: [
- Message.user({
- type: "media",
- mediaType: "image/png",
- data: "A".repeat(ProviderShared.MAX_MEDIA_ENCODED_BYTES + 4),
- }),
- ],
- }),
- ).pipe(Effect.flip)
- expect(error.message).toContain("encoded limit")
- }),
- )
- it.effect("omits tools when tool choice is none", () =>
- Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
- LLM.request({
- id: "req_no_tools",
- model,
- prompt: "Say hello.",
- tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
- toolChoice: { type: "none" },
- }),
- )
- expect(prepared.body).toEqual({
- contents: [{ role: "user", parts: [{ text: "Say hello." }] }],
- })
- }),
- )
- it.effect("sanitizes integer enums, dangling required, untyped arrays, and scalar object keys", () =>
- Effect.gen(function* () {
- const prepared = yield* LLMClient.prepare(
- LLM.request({
- id: "req_schema_patch",
- model,
- prompt: "Use the tool.",
- tools: [
- {
- name: "lookup",
- description: "Lookup data",
- inputSchema: {
- type: "object",
- required: ["status", "missing"],
- properties: {
- status: { type: "integer", enum: [1, 2] },
- tags: { type: "array" },
- name: { type: "string", properties: { ignored: { type: "string" } }, required: ["ignored"] },
- },
- },
- },
- ],
- }),
- )
- expect(prepared.body).toMatchObject({
- tools: [
- {
- functionDeclarations: [
- {
- parameters: {
- type: "object",
- required: ["status"],
- properties: {
- status: { type: "string", enum: ["1", "2"] },
- tags: { type: "array", items: { type: "string" } },
- name: { type: "string" },
- },
- },
- },
- ],
- },
- ],
- })
- }),
- )
- it.effect("parses text, reasoning, and usage stream fixtures", () =>
- Effect.gen(function* () {
- const body = sseEvents(
- {
- candidates: [
- {
- content: { role: "model", parts: [{ text: "thinking", thought: true }] },
- },
- ],
- },
- {
- candidates: [
- {
- content: { role: "model", parts: [{ text: "Hello" }] },
- },
- ],
- },
- {
- candidates: [
- {
- content: { role: "model", parts: [{ text: "!" }] },
- finishReason: "STOP",
- },
- ],
- },
- {
- usageMetadata: {
- promptTokenCount: 5,
- candidatesTokenCount: 2,
- totalTokenCount: 7,
- thoughtsTokenCount: 1,
- cachedContentTokenCount: 1,
- },
- },
- )
- const response = yield* LLMClient.generate(request).pipe(Effect.provide(fixedResponse(body)))
- expect(response.text).toBe("Hello!")
- expect(response.reasoning).toBe("thinking")
- expect(response.usage).toMatchObject({
- inputTokens: 5,
- outputTokens: 3,
- nonCachedInputTokens: 4,
- cacheReadInputTokens: 1,
- reasoningTokens: 1,
- totalTokens: 7,
- })
- const usage = new Usage({
- inputTokens: 5,
- outputTokens: 3,
- nonCachedInputTokens: 4,
- cacheReadInputTokens: 1,
- reasoningTokens: 1,
- totalTokens: 7,
- providerMetadata: {
- google: {
- promptTokenCount: 5,
- candidatesTokenCount: 2,
- totalTokenCount: 7,
- thoughtsTokenCount: 1,
- cachedContentTokenCount: 1,
- },
- },
- })
- expect(response.events).toEqual([
- { type: "step-start", index: 0 },
- { type: "reasoning-start", id: "reasoning-0" },
- { type: "reasoning-delta", id: "reasoning-0", text: "thinking" },
- { type: "reasoning-end", id: "reasoning-0" },
- { type: "text-start", id: "text-0" },
- { type: "text-delta", id: "text-0", text: "Hello" },
- { type: "text-delta", id: "text-0", text: "!" },
- { type: "text-end", id: "text-0" },
- { type: "step-finish", index: 0, reason: "stop", usage, providerMetadata: undefined },
- {
- type: "finish",
- reason: "stop",
- usage,
- },
- ])
- }),
- )
- 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" } } })
- expect(response.events.findIndex((event) => event.type === "reasoning-end")).toBeLessThan(
- response.events.findIndex((event) => event.type === "tool-call"),
- )
- 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({
- candidates: [
- {
- content: {
- role: "model",
- parts: [{ functionCall: { name: "lookup", args: { query: "weather" } } }],
- },
- finishReason: "STOP",
- },
- ],
- usageMetadata: { promptTokenCount: 5, candidatesTokenCount: 1 },
- })
- const response = yield* LLMClient.generate(
- LLM.updateRequest(request, {
- tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
- }),
- ).pipe(Effect.provide(fixedResponse(body)))
- const usage = new Usage({
- inputTokens: 5,
- outputTokens: 1,
- nonCachedInputTokens: 5,
- cacheReadInputTokens: undefined,
- reasoningTokens: undefined,
- totalTokens: 6,
- providerMetadata: { google: { promptTokenCount: 5, candidatesTokenCount: 1 } },
- })
- expect(response.toolCalls).toEqual([
- {
- type: "tool-call",
- id: "tool_0",
- name: "lookup",
- input: { query: "weather" },
- providerExecuted: undefined,
- providerMetadata: undefined,
- },
- ])
- expect(response.events).toEqual([
- { type: "step-start", index: 0 },
- {
- type: "tool-call",
- id: "tool_0",
- name: "lookup",
- input: { query: "weather" },
- providerExecuted: undefined,
- providerMetadata: undefined,
- },
- { type: "step-finish", index: 0, reason: "tool-calls", usage, providerMetadata: undefined },
- {
- type: "finish",
- reason: "tool-calls",
- usage,
- },
- ])
- }),
- )
- it.effect("assigns unique ids to multiple streamed tool calls", () =>
- Effect.gen(function* () {
- const body = sseEvents({
- candidates: [
- {
- content: {
- role: "model",
- parts: [
- { functionCall: { name: "lookup", args: { query: "weather" } } },
- { functionCall: { name: "lookup", args: { query: "news" } } },
- ],
- },
- finishReason: "STOP",
- },
- ],
- })
- const response = yield* LLMClient.generate(
- LLM.updateRequest(request, {
- tools: [{ name: "lookup", description: "Lookup data", inputSchema: { type: "object" } }],
- }),
- ).pipe(Effect.provide(fixedResponse(body)))
- expect(response.toolCalls).toEqual([
- { type: "tool-call", id: "tool_0", name: "lookup", input: { query: "weather" } },
- { type: "tool-call", id: "tool_1", name: "lookup", input: { query: "news" } },
- ])
- expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "tool-calls" })
- }),
- )
- it.effect("maps length and content-filter finish reasons", () =>
- Effect.gen(function* () {
- const length = yield* LLMClient.generate(request).pipe(
- Effect.provide(
- fixedResponse(
- sseEvents({ candidates: [{ content: { role: "model", parts: [] }, finishReason: "MAX_TOKENS" }] }),
- ),
- ),
- )
- const filtered = yield* LLMClient.generate(request).pipe(
- Effect.provide(
- fixedResponse(sseEvents({ candidates: [{ content: { role: "model", parts: [] }, finishReason: "SAFETY" }] })),
- ),
- )
- expect(length.events.map((event) => event.type)).toEqual(["step-start", "step-finish", "finish"])
- expect(length.events.at(-1)).toMatchObject({ type: "finish", reason: "length" })
- expect(filtered.events.map((event) => event.type)).toEqual(["step-start", "step-finish", "finish"])
- expect(filtered.events.at(-1)).toMatchObject({ type: "finish", reason: "content-filter" })
- }),
- )
- it.effect("leaves total usage undefined when component counts are missing", () =>
- Effect.gen(function* () {
- const response = yield* LLMClient.generate(request).pipe(
- Effect.provide(fixedResponse(sseEvents({ usageMetadata: { thoughtsTokenCount: 1 } }))),
- )
- expect(response.usage).toMatchObject({ reasoningTokens: 1 })
- expect(response.usage?.totalTokens).toBeUndefined()
- }),
- )
- it.effect("fails invalid stream events", () =>
- Effect.gen(function* () {
- const error = yield* LLMClient.generate(request).pipe(
- Effect.provide(fixedResponse(sseRaw("data: {not json}"))),
- Effect.flip,
- )
- expect(error).toBeInstanceOf(LLMError)
- expect(error.reason).toMatchObject({ _tag: "InvalidProviderOutput" })
- expect(error.message).toContain("Invalid google/gemini stream event")
- }),
- )
- it.effect("rejects unsupported assistant media content", () =>
- Effect.gen(function* () {
- const error = yield* LLMClient.prepare(
- LLM.request({
- id: "req_media",
- model,
- messages: [Message.assistant({ type: "media", mediaType: "image/png", data: "AAECAw==" })],
- }),
- ).pipe(Effect.flip)
- expect(error.message).toContain(
- "Gemini assistant messages only support text, reasoning, and tool-call content for now",
- )
- }),
- )
- })
|