Why: the user profile only knew what onboarding wrote, so people had to repeat themselves,
and semantic recall needed a hosted or separately run memory service. The profile now grows
from everyday conversations, and recall can run on the user's own server with a local
embeddings endpoint (for example llama.cpp). Both stay optional and are tested offline.
What:
- adapter-kit: EmbeddingProvider contract (describe, embed) and the profile.consolidate job
(payload schema and a replace-keyed job factory, one pending job per user).
- adapters: OpenAI-compatible embeddings (POST {baseUrl}/embeddings through the guarded
OpenAI-compatible fetch, batching, bounded responses, clear errors), a deterministic
emulator, an in-process /v1/embeddings server emulator, and a shared conformance suite.
- adapters: "builtin" semantic memory stored in Postgres (semantic_memory_items with Float[]
embeddings, cosine ranking in process, bot/shared/history scopes matching the existing
providers), registered in the memory provider factory and configured through
memory.connectProvider with an embeddings URL, model and optional API key. Private
endpoints require the deployment owner, like the other local memory providers.
- adapters: profile consolidation reads the user's settled app conversations since
UserProfile.consolidatedAt (never messaging channels), asks the main bot's model for strict
JSON facts and an updated summary, drops restatements (embedding cosine when built-in
memory is configured, normalized text otherwise), stores sensitive categories as pending,
and advances the cursor with a compare-and-set. A reconciler hook enqueues it at most daily
per active user; it skips under the scripted runtime and without a usable model.
- db: additive migration 20261002210400_ws4_semantic_memory.
- web: a "Built-in" option in Memory settings; AI data sharing names the embeddings server.
Tests: new embedding conformance, OpenAI-compatible wire, built-in provider and consolidation
unit tests; new Postgres suites for built-in memory (including a real loopback embeddings
server through the memory provider resolver) and for consolidation and its scheduling query,
both added to the integration harness. The settings E2E spec now opens the new form.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
161 lines
5.2 KiB
TypeScript
161 lines
5.2 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import { OpenAiCompatibleEmbeddingServerEmulator } from "./embedding-emulator.js";
|
|
import {
|
|
OpenAiCompatibleEmbeddingProvider,
|
|
parseEmbeddingResponse,
|
|
} from "./openai-compatible-embeddings.js";
|
|
|
|
afterEach(() => vi.unstubAllEnvs());
|
|
|
|
function provider(
|
|
server = new OpenAiCompatibleEmbeddingServerEmulator(),
|
|
config: { baseUrl?: string; model?: string; apiKey?: string } = {},
|
|
) {
|
|
return new OpenAiCompatibleEmbeddingProvider(
|
|
{
|
|
baseUrl: config.baseUrl ?? "http://127.0.0.1:8080",
|
|
model: config.model ?? "nomic-embed-text",
|
|
...(config.apiKey ? { apiKey: config.apiKey } : {}),
|
|
},
|
|
server.fetch,
|
|
);
|
|
}
|
|
|
|
describe("OpenAiCompatibleEmbeddingProvider", () => {
|
|
it("posts the OpenAI embeddings request to the versioned endpoint in batches", async () => {
|
|
const server = new OpenAiCompatibleEmbeddingServerEmulator();
|
|
const texts = Array.from({ length: 70 }, (_, index) => `text ${index}`);
|
|
|
|
await provider(server).embed(texts);
|
|
|
|
expect(server.requests.map((request) => (request.input as string[]).length)).toEqual([
|
|
32, 32, 6,
|
|
]);
|
|
expect(server.requests[0]).toMatchObject({
|
|
url: "http://127.0.0.1:8080/v1/embeddings",
|
|
authorization: null,
|
|
model: "nomic-embed-text",
|
|
});
|
|
expect(server.requests.flatMap((request) => request.input as string[])).toEqual(texts);
|
|
});
|
|
|
|
it("names the configured model as its vector space", () => {
|
|
expect(provider().describe()).toMatchObject({
|
|
id: "openai-compatible",
|
|
capabilities: { model: "nomic-embed-text" },
|
|
});
|
|
expect(() => provider(undefined, { model: " " })).toThrow(/model is required/);
|
|
});
|
|
|
|
it("sends the API key as a bearer token", async () => {
|
|
const server = new OpenAiCompatibleEmbeddingServerEmulator({ apiKey: "sk-test-key" });
|
|
await provider(server, { apiKey: "sk-test-key" }).embed(["hello"]);
|
|
expect(server.requests[0]?.authorization).toBe("Bearer sk-test-key");
|
|
});
|
|
|
|
it("applies the OpenAI-compatible endpoint policy", () => {
|
|
expect(() => provider(undefined, { baseUrl: "http://169.254.169.254/v1" })).toThrow(/metadata/);
|
|
expect(() => provider(undefined, { baseUrl: "https://embeddings.example.com/v1" })).toThrow(
|
|
/Public model endpoints are blocked/,
|
|
);
|
|
vi.stubEnv("RAKAZO_OPENAI_COMPAT_ALLOW_PUBLIC", "1");
|
|
expect(() =>
|
|
provider(undefined, { baseUrl: "http://embeddings.example.com/v1", apiKey: "sk-test-key" }),
|
|
).toThrow(/must use HTTPS/);
|
|
});
|
|
|
|
it("reports HTTP failures without echoing the response or the key", async () => {
|
|
const server = new OpenAiCompatibleEmbeddingServerEmulator({ apiKey: "sk-test-key" });
|
|
server.failNextStatus = 503;
|
|
const failure = provider(server, { apiKey: "sk-test-key" }).embed(["hello"]);
|
|
await expect(failure).rejects.toThrow("Embeddings server returned 503");
|
|
await expect(failure).rejects.not.toThrow(/sk-test-key|Upstream/);
|
|
await expect(provider(server).embed(["hello"])).rejects.toThrow("returned 401");
|
|
});
|
|
|
|
it("reports a refused connection as an unreachable server", async () => {
|
|
const embeddings = new OpenAiCompatibleEmbeddingProvider(
|
|
{ baseUrl: "http://127.0.0.1:8080", model: "m" },
|
|
async () => {
|
|
throw new TypeError("fetch failed");
|
|
},
|
|
);
|
|
await expect(embeddings.embed(["hello"])).rejects.toThrow("Embeddings server is unreachable");
|
|
});
|
|
|
|
it("rejects vectors of different lengths across batches", async () => {
|
|
let call = 0;
|
|
const fetchImpl: typeof fetch = async (_input, init) => {
|
|
const texts = JSON.parse(String(init?.body)).input as string[];
|
|
call += 1;
|
|
return Response.json({
|
|
data: texts.map((_, index) => ({ index, embedding: call === 1 ? [1, 0] : [1, 0, 0] })),
|
|
});
|
|
};
|
|
const embeddings = new OpenAiCompatibleEmbeddingProvider(
|
|
{ baseUrl: "http://127.0.0.1:8080", model: "m" },
|
|
fetchImpl,
|
|
);
|
|
await expect(embeddings.embed(Array.from({ length: 40 }, () => "x"))).rejects.toThrow(
|
|
/different lengths/,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("parseEmbeddingResponse", () => {
|
|
it("orders vectors by index", () => {
|
|
expect(
|
|
parseEmbeddingResponse(
|
|
{
|
|
data: [
|
|
{ index: 1, embedding: [0, 1] },
|
|
{ index: 0, embedding: [1, 0] },
|
|
],
|
|
},
|
|
2,
|
|
),
|
|
).toEqual([
|
|
[1, 0],
|
|
[0, 1],
|
|
]);
|
|
});
|
|
|
|
it("falls back to position when a server omits the index", () => {
|
|
expect(parseEmbeddingResponse({ data: [{ embedding: [0.5] }] }, 1)).toEqual([[0.5]]);
|
|
});
|
|
|
|
it.each([
|
|
["a missing data list", {}],
|
|
["the wrong count", { data: [{ index: 0, embedding: [1] }] }],
|
|
[
|
|
"a duplicate index",
|
|
{
|
|
data: [
|
|
{ index: 0, embedding: [1] },
|
|
{ index: 0, embedding: [1] },
|
|
],
|
|
},
|
|
],
|
|
[
|
|
"a non-finite value",
|
|
{
|
|
data: [
|
|
{ index: 0, embedding: [1] },
|
|
{ index: 1, embedding: [Number.NaN] },
|
|
],
|
|
},
|
|
],
|
|
[
|
|
"an empty vector",
|
|
{
|
|
data: [
|
|
{ index: 0, embedding: [] },
|
|
{ index: 1, embedding: [1] },
|
|
],
|
|
},
|
|
],
|
|
])("rejects %s", (_name, body) => {
|
|
expect(() => parseEmbeddingResponse(body, 2)).toThrow(/Embeddings server returned/);
|
|
});
|
|
});
|