426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
292 lines
12 KiB
TypeScript
292 lines
12 KiB
TypeScript
/** Tests for the embedding handlers against a mocked fetch: request shape, dimension validation, and batch handling (no live endpoint). */
|
|
import type { IAgentRuntime } from "@elizaos/core";
|
|
import { VECTOR_DIMS } from "@elizaos/core";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { handleBatchTextEmbedding, handleTextEmbedding } from "../src/models/embedding";
|
|
|
|
function createRuntime(settings: Record<string, string> = {}): IAgentRuntime {
|
|
const values: Record<string, string> = {
|
|
EMBEDDING_BASE_URL: "https://embeddings.example/v1",
|
|
EMBEDDING_API_KEY: "test-key",
|
|
...settings,
|
|
};
|
|
return {
|
|
character: { name: "Ada" },
|
|
emitEvent: vi.fn(async () => undefined),
|
|
getSetting: vi.fn((key: string) => values[key] ?? null),
|
|
} as unknown as IAgentRuntime;
|
|
}
|
|
|
|
function vectorOf(length: number): number[] {
|
|
return Array.from({ length }, (_v, i) => (i + 1) / length);
|
|
}
|
|
|
|
function mockEmbeddingsResponse(vectors: number[][]): Response {
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
statusText: "OK",
|
|
json: async () => ({
|
|
object: "list",
|
|
data: vectors.map((embedding, index) => ({ object: "embedding", embedding, index })),
|
|
model: "text-embedding-3-small",
|
|
usage: { prompt_tokens: 3, total_tokens: 3 },
|
|
}),
|
|
text: async () => "",
|
|
} as unknown as Response;
|
|
}
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
vi.unstubAllEnvs();
|
|
});
|
|
|
|
describe("plugin-embeddings handleTextEmbedding", () => {
|
|
it("returns an EMBEDDING_DIMENSIONS-wide vector for the null init-probe", async () => {
|
|
const fetchMock = vi.fn();
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(fetchMock as typeof fetch);
|
|
|
|
const probe = await handleTextEmbedding(createRuntime({ EMBEDDING_DIMENSIONS: "768" }), null);
|
|
|
|
expect(probe).toHaveLength(768);
|
|
expect(probe[0]).toBeCloseTo(0.1);
|
|
// The null probe must never hit the network — it only reports the width.
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("defaults the probe width to 1536 when EMBEDDING_DIMENSIONS is unset", async () => {
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(vi.fn() as typeof fetch);
|
|
const probe = await handleTextEmbedding(createRuntime(), null);
|
|
expect(probe).toHaveLength(1536);
|
|
});
|
|
|
|
it("returns the parsed vector from a wire-mocked /embeddings response", async () => {
|
|
const expected = vectorOf(1536);
|
|
const fetchMock = vi.fn(async () => mockEmbeddingsResponse([expected]));
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(fetchMock as unknown as typeof fetch);
|
|
|
|
const result = await handleTextEmbedding(createRuntime(), { text: "hello world" });
|
|
|
|
expect(result).toEqual(expected);
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
|
|
expect(url).toBe("https://embeddings.example/v1/embeddings");
|
|
expect((init.headers as Record<string, string>).Authorization).toBe("Bearer test-key");
|
|
const body = JSON.parse(init.body as string);
|
|
expect(body.input).toBe("hello world");
|
|
expect(body.model).toBe("text-embedding-3-small");
|
|
});
|
|
|
|
it("omits the dimensions field when EMBEDDING_DIMENSIONS is not explicitly set", async () => {
|
|
const fetchMock = vi.fn(async () => mockEmbeddingsResponse([vectorOf(1536)]));
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(fetchMock as unknown as typeof fetch);
|
|
|
|
await handleTextEmbedding(createRuntime(), { text: "hi" });
|
|
|
|
const body = JSON.parse((fetchMock.mock.calls[0] as [string, RequestInit])[1].body as string);
|
|
expect(body).not.toHaveProperty("dimensions");
|
|
});
|
|
|
|
it("sends the dimensions field when EMBEDDING_DIMENSIONS is explicitly set", async () => {
|
|
const fetchMock = vi.fn(async () => mockEmbeddingsResponse([vectorOf(512)]));
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(fetchMock as unknown as typeof fetch);
|
|
|
|
await handleTextEmbedding(createRuntime({ EMBEDDING_DIMENSIONS: "512" }), { text: "hi" });
|
|
|
|
const body = JSON.parse((fetchMock.mock.calls[0] as [string, RequestInit])[1].body as string);
|
|
expect(body.dimensions).toBe(512);
|
|
});
|
|
|
|
it("throws on a dimension mismatch (never returns the wrong-width vector)", async () => {
|
|
const fetchMock = vi.fn(async () => mockEmbeddingsResponse([vectorOf(768)]));
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(fetchMock as unknown as typeof fetch);
|
|
|
|
await expect(
|
|
handleTextEmbedding(createRuntime({ EMBEDDING_DIMENSIONS: "1536" }), { text: "hi" })
|
|
).rejects.toThrow(/dimension mismatch/i);
|
|
});
|
|
|
|
it("throws on empty text before calling the provider", async () => {
|
|
const fetchMock = vi.fn();
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(fetchMock as typeof fetch);
|
|
|
|
await expect(handleTextEmbedding(createRuntime(), { text: " " })).rejects.toThrow(
|
|
/empty text/i
|
|
);
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("throws on an unsupported configured dimension", async () => {
|
|
const fetchMock = vi.fn();
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(fetchMock as typeof fetch);
|
|
|
|
await expect(
|
|
handleTextEmbedding(createRuntime({ EMBEDDING_DIMENSIONS: "999" }), { text: "hi" })
|
|
).rejects.toThrow(/Invalid embedding dimension/i);
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("throws when no endpoint is configured (no silent default, no zero vector)", async () => {
|
|
const fetchMock = vi.fn();
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(fetchMock as typeof fetch);
|
|
// Only a key, no base URL — a real text embed cannot resolve an endpoint.
|
|
const runtime = {
|
|
character: { name: "Ada" },
|
|
emitEvent: vi.fn(),
|
|
getSetting: vi.fn((key: string) => (key === "EMBEDDING_API_KEY" ? "k" : null)),
|
|
} as unknown as IAgentRuntime;
|
|
|
|
await expect(handleTextEmbedding(runtime, { text: "hi" })).rejects.toThrow(
|
|
/No embedding endpoint configured/i
|
|
);
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("throws on a non-OK HTTP response instead of returning a fabricated vector", async () => {
|
|
const fetchMock = vi.fn(async () => ({
|
|
ok: false,
|
|
status: 502,
|
|
statusText: "Bad Gateway",
|
|
text: async () => "upstream down",
|
|
}));
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(fetchMock as unknown as typeof fetch);
|
|
|
|
await expect(handleTextEmbedding(createRuntime(), { text: "hi" })).rejects.toThrow(/502/);
|
|
});
|
|
|
|
it("emits the configured dimension as a member of VECTOR_DIMS (contract)", async () => {
|
|
const dims = Object.values(VECTOR_DIMS) as number[];
|
|
for (const dim of dims) {
|
|
const fetchMock = vi.fn(async () => mockEmbeddingsResponse([vectorOf(dim)]));
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(fetchMock as unknown as typeof fetch);
|
|
const result = await handleTextEmbedding(
|
|
createRuntime({ EMBEDDING_DIMENSIONS: String(dim) }),
|
|
{ text: "contract" }
|
|
);
|
|
expect(result).toHaveLength(dim);
|
|
expect(dims).toContain(result.length);
|
|
vi.restoreAllMocks();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("plugin-embeddings handleBatchTextEmbedding", () => {
|
|
it("returns one vector per input in order from a single request", async () => {
|
|
const v0 = vectorOf(1536).map((x) => x * 0.5);
|
|
const v1 = vectorOf(1536);
|
|
const fetchMock = vi.fn(async () => mockEmbeddingsResponse([v0, v1]));
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(fetchMock as unknown as typeof fetch);
|
|
|
|
const result = await handleBatchTextEmbedding(createRuntime(), ["a", "b"]);
|
|
|
|
expect(result).toEqual([v0, v1]);
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
const body = JSON.parse((fetchMock.mock.calls[0] as [string, RequestInit])[1].body as string);
|
|
expect(body.input).toEqual(["a", "b"]);
|
|
});
|
|
|
|
it("returns [] for an empty batch without calling the provider", async () => {
|
|
const fetchMock = vi.fn();
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(fetchMock as typeof fetch);
|
|
await expect(handleBatchTextEmbedding(createRuntime(), [])).resolves.toEqual([]);
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("throws on an empty text inside a batch", async () => {
|
|
const fetchMock = vi.fn();
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(fetchMock as typeof fetch);
|
|
await expect(handleBatchTextEmbedding(createRuntime(), ["ok", " "])).rejects.toThrow(
|
|
/empty text at index 1/i
|
|
);
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("throws when the response repeats an index (a hole must never be returned)", async () => {
|
|
// Count check alone passes (2 items for 2 inputs) but slot 1 stays unfilled;
|
|
// returning [B, undefined] would silently persist a corrupt vector.
|
|
const fetchMock = vi.fn(
|
|
async () =>
|
|
({
|
|
ok: true,
|
|
status: 200,
|
|
statusText: "OK",
|
|
json: async () => ({
|
|
object: "list",
|
|
data: [
|
|
{ object: "embedding", embedding: vectorOf(1536), index: 0 },
|
|
{ object: "embedding", embedding: vectorOf(1536), index: 0 },
|
|
],
|
|
model: "text-embedding-3-small",
|
|
}),
|
|
text: async () => "",
|
|
}) as unknown as Response
|
|
);
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(fetchMock as unknown as typeof fetch);
|
|
|
|
await expect(handleBatchTextEmbedding(createRuntime(), ["one", "two"])).rejects.toThrow(
|
|
/duplicate index 0/i
|
|
);
|
|
});
|
|
|
|
it("throws when the response index is not an integer", async () => {
|
|
const fetchMock = vi.fn(
|
|
async () =>
|
|
({
|
|
ok: true,
|
|
status: 200,
|
|
statusText: "OK",
|
|
json: async () => ({
|
|
object: "list",
|
|
data: [
|
|
{ object: "embedding", embedding: vectorOf(1536), index: 0.5 },
|
|
{ object: "embedding", embedding: vectorOf(1536), index: 1 },
|
|
],
|
|
model: "text-embedding-3-small",
|
|
}),
|
|
text: async () => "",
|
|
}) as unknown as Response
|
|
);
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(fetchMock as unknown as typeof fetch);
|
|
|
|
await expect(handleBatchTextEmbedding(createRuntime(), ["one", "two"])).rejects.toThrow(
|
|
/out-of-range index 0.5/i
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("plugin-embeddings input truncation", () => {
|
|
const MAX_EMBEDDING_CHARS = 8_000 * 4;
|
|
|
|
it("never splits a surrogate pair at the truncation boundary", async () => {
|
|
// The 😀 spans code units MAX-1..MAX, so a blind slice(0, MAX) would keep a
|
|
// lone high surrogate — mojibake (U+FFFD) or a reject at the endpoint.
|
|
const text = `${"a".repeat(MAX_EMBEDDING_CHARS - 1)}😀tail`;
|
|
const fetchMock = vi.fn(async () => mockEmbeddingsResponse([vectorOf(1536)]));
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(fetchMock as unknown as typeof fetch);
|
|
|
|
await handleTextEmbedding(createRuntime(), text);
|
|
|
|
const body = JSON.parse((fetchMock.mock.calls[0] as [string, RequestInit])[1].body as string);
|
|
const sent = body.input as string;
|
|
expect(sent.isWellFormed()).toBe(true);
|
|
expect(sent.length).toBeLessThanOrEqual(MAX_EMBEDDING_CHARS);
|
|
expect(sent).toBe("a".repeat(MAX_EMBEDDING_CHARS - 1));
|
|
});
|
|
|
|
it("keeps an astral char that fits entirely under the cap", async () => {
|
|
// Here the pair ends exactly at the boundary — no back-off should occur.
|
|
const text = `${"a".repeat(MAX_EMBEDDING_CHARS - 2)}😀tail`;
|
|
const fetchMock = vi.fn(async () => mockEmbeddingsResponse([vectorOf(1536)]));
|
|
vi.spyOn(globalThis, "fetch").mockImplementation(fetchMock as unknown as typeof fetch);
|
|
|
|
await handleTextEmbedding(createRuntime(), text);
|
|
|
|
const body = JSON.parse((fetchMock.mock.calls[0] as [string, RequestInit])[1].body as string);
|
|
const sent = body.input as string;
|
|
expect(sent.isWellFormed()).toBe(true);
|
|
expect(sent).toBe(`${"a".repeat(MAX_EMBEDDING_CHARS - 2)}😀`);
|
|
expect(sent.length).toBe(MAX_EMBEDDING_CHARS);
|
|
});
|
|
});
|