import { describe, expect, test } from "bun:test" import { Effect, Layer } from "effect" import { ai, EmbeddingClient, EmbeddingUsage, Media } from "../src/effect.js" import { Google, GoogleVertex, OpenAI, OpenAICompatible } from "../src/providers.js" import { embeddingModel as googleEmbeddingModel } from "../src/providers/google.js" import { it } from "./lib/effect.js" import { dynamicResponse, json, type Handler } from "./lib/http.js" const client = (handler: Handler) => EmbeddingClient.layer.pipe(Layer.provide(dynamicResponse(handler))) const offline = client(() => Effect.die("an invalid embedding request reached the network")) const openai = OpenAI.configure({ apiKey: "openai-key" }) describe("Embedding", () => { it.effect("sends text values to OpenAI and returns vectors in value order", () => ai.embedding .generate({ model: openai.embedding("text-embedding-3-small"), values: ["first", "second"], dimensions: 2, inputType: "query", providerOptions: { user: "user-1" }, }) .pipe( Effect.tap((response) => Effect.sync(() => { expect(response.embeddings).toEqual([ [0.1, 0.2], [0.3, 0.4], ]) expect(response.usage).toEqual(new EmbeddingUsage({ inputTokens: 4 })) expect(response.providerMetadata).toEqual({ openai: { model: "text-embedding-3-small", usage: { prompt_tokens: 4, total_tokens: 4 } }, }) }), ), Effect.provide( client((input) => { expect(input.request.url).toBe("https://api.openai.com/v1/embeddings") expect(input.request.headers.authorization).toBe("Bearer openai-key") expect(JSON.parse(input.text)).toEqual({ encoding_format: "float", user: "user-1", model: "text-embedding-3-small", input: ["first", "second"], dimensions: 2, }) return Effect.succeed( json(input, { object: "list", model: "text-embedding-3-small", data: [ { object: "embedding", index: 1, embedding: [0.3, 0.4] }, { object: "embedding", index: 0, embedding: [0.1, 0.2] }, ], usage: { prompt_tokens: 4, total_tokens: 4 }, }), ) }), ), ), ) it.effect("sends host fields through a custom OpenAI-compatible endpoint and decodes base64 vectors", () => ai.embedding .generate({ model: OpenAICompatible.configure({ provider: "nvidia", apiKey: "nvidia-key", baseURL: "https://integrate.api.nvidia.test/v1/", http: { body: { truncate: "END" } }, }).embedding("nvidia/nemotron-3.5-embed-1b"), values: ["a document"], providerOptions: { input_type: "passage", encoding_format: "base64" }, }) .pipe( Effect.tap((response) => Effect.sync(() => { expect(response.embeddings).toEqual([[1, -2]]) expect(response.usage).toBeUndefined() expect(response.providerMetadata).toEqual({ nvidia: {} }) }), ), Effect.provide( client((input) => { expect(input.request.url).toBe("https://integrate.api.nvidia.test/v1/embeddings") expect(input.request.headers.authorization).toBe("Bearer nvidia-key") expect(JSON.parse(input.text)).toEqual({ encoding_format: "base64", input_type: "passage", truncate: "END", model: "nvidia/nemotron-3.5-embed-1b", input: ["a document"], }) const vector = Buffer.from(new Float32Array([1, -2]).buffer).toString("base64") return Effect.succeed(json(input, { data: [{ embedding: vector }] })) }), ), ), ) it.effect("rejects media values before sending them to OpenAI", () => Effect.gen(function* () { const error = yield* ai.embedding .generate({ model: openai.embedding("text-embedding-3-small"), values: ["caption", Media.bytes(new Uint8Array([1, 2, 3]), "image/png")], }) .pipe(Effect.flip) expect(error.reason._tag).toBe("UnsupportedOperation") expect(error.message).toContain("embeds text only") }).pipe(Effect.provide(offline)), ) it.effect("rejects an empty value list", () => Effect.gen(function* () { const error = yield* ai.embedding .generate({ model: openai.embedding("text-embedding-3-small"), values: [] }) .pipe(Effect.flip) expect(error.reason._tag).toBe("InvalidRequest") expect(error.message).toContain("Embedding values must not be empty") }).pipe(Effect.provide(offline)), ) it.effect("fails when the host returns a different number of vectors", () => Effect.gen(function* () { const error = yield* ai.embedding .generate({ model: openai.embedding("text-embedding-3-small"), values: ["one", "two"] }) .pipe(Effect.flip) expect(error.reason._tag).toBe("InvalidProviderOutput") expect(error.message).toContain("Expected 2 embeddings, got 1") }).pipe(Effect.provide(client((input) => Effect.succeed(json(input, { data: [{ index: 0, embedding: [0.1] }] }))))), ) it.effect("batches Google values as one content each and maps inputType to taskType", () => ai.embedding .generate({ model: Google.configure({ apiKey: "google-key" }).embedding("gemini-embedding-2"), values: ["a caption", Media.base64("iVBORw0KGgo=", "image/png")], dimensions: 256, inputType: "document", providerOptions: { title: "Restroom" }, }) .pipe( Effect.tap((response) => Effect.sync(() => { expect(response.embeddings).toEqual([ [0.1, 0.2], [0.3, 0.4], ]) expect(response.usage).toEqual(new EmbeddingUsage({ inputTokens: 260 })) expect(response.providerMetadata).toEqual({ google: { usageMetadata: { promptTokenCount: 260 } } }) }), ), Effect.provide( client((input) => { expect(input.request.url).toBe( "https://generativelanguage.googleapis.com/v1beta/models/gemini-embedding-2:batchEmbedContents", ) expect(input.request.headers["x-goog-api-key"]).toBe("google-key") const fields = { taskType: "RETRIEVAL_DOCUMENT", outputDimensionality: 256, title: "Restroom" } expect(JSON.parse(input.text)).toEqual({ requests: [ { model: "models/gemini-embedding-2", content: { parts: [{ text: "a caption" }] }, ...fields }, { model: "models/gemini-embedding-2", content: { parts: [{ inlineData: { mimeType: "image/png", data: "iVBORw0KGgo=" } }] }, ...fields, }, ], }) return Effect.succeed( json(input, { embeddings: [{ values: [0.1, 0.2] }, { values: [0.3, 0.4] }], usageMetadata: { promptTokenCount: 260 }, }), ) }), ), ), ) it.effect("lets a Google taskType option replace the inputType mapping and sends settings", () => ai.embedding .generate({ model: googleEmbeddingModel("gemini-embedding-001", { apiKey: "google-key", headers: { "x-test": "yes" }, body: { extra: true }, }), values: ["query"], inputType: "query", providerOptions: { taskType: "CODE_RETRIEVAL_QUERY" }, }) .pipe( Effect.tap((response) => Effect.sync(() => { expect(response.usage).toBeUndefined() expect(response.providerMetadata).toEqual({ google: {} }) }), ), Effect.provide( client((input) => { expect(input.request.headers["x-test"]).toBe("yes") expect(JSON.parse(input.text)).toEqual({ requests: [ { model: "models/gemini-embedding-001", content: { parts: [{ text: "query" }] }, taskType: "CODE_RETRIEVAL_QUERY", }, ], extra: true, }) return Effect.succeed(json(input, { embeddings: [{ values: [1] }] })) }), ), ), ) it.effect("splits more than 100 Google values across batches and joins them in order", () => Effect.gen(function* () { const sizes: Array = [] const response = yield* ai.embedding .generate({ model: Google.configure({ apiKey: "google-key" }).embedding("gemini-embedding-2"), values: Array.from({ length: 105 }, (_, index) => `${index}`), }) .pipe( Effect.provide( client((input) => { const requests: Array<{ content: { parts: Array<{ text: string }> } }> = JSON.parse(input.text).requests sizes.push(requests.length) return Effect.succeed( json(input, { embeddings: requests.map((request) => ({ values: [Number(request.content.parts[0].text)] })), usageMetadata: { promptTokenCount: requests.length }, }), ) }), ), ) expect(sizes.toSorted((a, b) => a - b)).toEqual([5, 100]) expect(response.embeddings).toEqual(Array.from({ length: 105 }, (_, index) => [index])) expect(response.usage).toEqual(new EmbeddingUsage({ inputTokens: 105 })) expect(response.providerMetadata).toEqual({ google: { responses: [{ usageMetadata: { promptTokenCount: 100 } }, { usageMetadata: { promptTokenCount: 5 } }], }, }) }), ) it.effect("sends one Vertex embedContent request per value with embedContentConfig", () => Effect.gen(function* () { const bodies: Array = [] const response = yield* ai.embedding .generate({ model: GoogleVertex.configure({ accessToken: "vertex-token", project: "proj", location: "us" }).embedding( "gemini-embedding-2", ), values: ["first", Media.base64("JVBERi0=", "application/pdf")], dimensions: 128, providerOptions: { documentOcr: true }, }) .pipe( Effect.provide( client((input) => { expect(input.request.url).toBe( "https://aiplatform.us.rep.googleapis.com/v1beta1/projects/proj/locations/us/publishers/google/models/gemini-embedding-2:embedContent", ) expect(input.request.headers.authorization).toBe("Bearer vertex-token") const body = JSON.parse(input.text) bodies.push(body) return Effect.succeed( json(input, { embedding: { values: ["text" in body.content.parts[0] ? 1 : 2] }, usageMetadata: { promptTokenCount: 3 }, truncated: false, }), ) }), ), ) expect(bodies).toContainEqual({ content: { parts: [{ text: "first" }] }, embedContentConfig: { outputDimensionality: 128, documentOcr: true }, }) expect(bodies).toContainEqual({ content: { parts: [{ inlineData: { mimeType: "application/pdf", data: "JVBERi0=" } }] }, embedContentConfig: { outputDimensionality: 128, documentOcr: true }, }) expect(response.embeddings).toEqual([[1], [2]]) expect(response.usage).toEqual(new EmbeddingUsage({ inputTokens: 6 })) expect(response.providerMetadata).toEqual({ vertex: { responses: [ { usageMetadata: { promptTokenCount: 3 }, truncated: false }, { usageMetadata: { promptTokenCount: 3 }, truncated: false }, ], }, }) }), ) test("rejects Vertex Express Mode keys for embedContent", () => { expect(() => GoogleVertex.configure({ apiKey: "vertex-key" }).embedding("gemini-embedding-2")).toThrow( "does not support Express Mode API keys", ) }) it.effect("sends Vertex text embedding models through :predict with instances and parameters", () => ai.embedding .generate({ model: GoogleVertex.configure({ apiKey: "vertex-key" }).embedding("text-embedding-005"), values: ["first", "second"], dimensions: 256, inputType: "query", providerOptions: { title: "Docs", autoTruncate: false }, }) .pipe( Effect.tap((response) => Effect.sync(() => { expect(response.embeddings).toEqual([[0.1], [0.2]]) expect(response.usage).toEqual(new EmbeddingUsage({ inputTokens: 5 })) expect(response.providerMetadata).toEqual({ vertex: { statistics: [ { token_count: 2, truncated: false }, { token_count: 3, truncated: false }, ], metadata: { billableCharacterCount: 11 }, }, }) }), ), Effect.provide( client((input) => { expect(input.request.url).toBe( "https://aiplatform.googleapis.com/v1/publishers/google/models/text-embedding-005:predict", ) expect(input.request.headers["x-goog-api-key"]).toBe("vertex-key") expect(JSON.parse(input.text)).toEqual({ instances: [ { content: "first", task_type: "RETRIEVAL_QUERY", title: "Docs" }, { content: "second", task_type: "RETRIEVAL_QUERY", title: "Docs" }, ], parameters: { outputDimensionality: 256, autoTruncate: false }, }) return Effect.succeed( json(input, { predictions: [ { embeddings: { values: [0.1], statistics: { token_count: 2, truncated: false } } }, { embeddings: { values: [0.2], statistics: { token_count: 3, truncated: false } } }, ], metadata: { billableCharacterCount: 11 }, }), ) }), ), ), ) it.effect("sends gemini-embedding-001 on Vertex one text per :predict request", () => Effect.gen(function* () { const sent: Array = [] const response = yield* ai.embedding .generate({ model: GoogleVertex.configure({ apiKey: "vertex-key" }).embedding("gemini-embedding-001"), values: ["0", "1", "2"], providerOptions: { taskType: "CLUSTERING" }, }) .pipe( Effect.provide( client((input) => { const body = JSON.parse(input.text) expect(body.instances).toHaveLength(1) expect(body.instances[0].task_type).toBe("CLUSTERING") expect(body.parameters).toBeUndefined() sent.push(body.instances[0].content) return Effect.succeed( json(input, { predictions: [{ embeddings: { values: [Number(body.instances[0].content)] } }], }), ) }), ), ) expect(sent.toSorted()).toEqual(["0", "1", "2"]) expect(response.embeddings).toEqual([[0], [1], [2]]) expect(response.usage).toBeUndefined() }), ) it.effect("splits more than 250 Vertex :predict values", () => Effect.gen(function* () { const sizes: Array = [] const response = yield* ai.embedding .generate({ model: GoogleVertex.configure({ apiKey: "vertex-key" }).embedding("text-multilingual-embedding-002"), values: Array.from({ length: 251 }, (_, index) => `${index}`), }) .pipe( Effect.provide( client((input) => { const instances: Array<{ content: string }> = JSON.parse(input.text).instances sizes.push(instances.length) return Effect.succeed( json(input, { predictions: instances.map((instance) => ({ embeddings: { values: [Number(instance.content)] } })), }), ) }), ), ) expect(sizes.toSorted((a, b) => a - b)).toEqual([1, 250]) expect(response.embeddings).toEqual(Array.from({ length: 251 }, (_, index) => [index])) }), ) it.effect("rejects media values before sending them to Vertex :predict", () => Effect.gen(function* () { const error = yield* ai.embedding .generate({ model: GoogleVertex.configure({ apiKey: "vertex-key" }).embedding("text-embedding-005"), values: [Media.bytes(new Uint8Array([1, 2, 3]), "image/png")], }) .pipe(Effect.flip) expect(error.reason._tag).toBe("UnsupportedOperation") expect(error.message).toContain("embeds text only") }).pipe(Effect.provide(offline)), ) it.effect("sends tuned Vertex embedding endpoints through :predict", () => ai.embedding .generate({ model: GoogleVertex.configure({ accessToken: "vertex-token", project: "proj", location: "us-central1", }).embedding("endpoints/123"), values: ["text"], }) .pipe( Effect.provide( client((input) => { expect(input.request.url).toBe( "https://us-central1-aiplatform.googleapis.com/v1beta1/projects/proj/locations/us-central1/endpoints/123:predict", ) return Effect.succeed(json(input, { predictions: [{ embeddings: { values: [1] } }] })) }), ), ), ) })