Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 45 additions & 2 deletions src/providers/embedding/openrouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,48 @@ import { fetchWithTimeout } from "../_fetch.js";

const API_URL = "https://openrouter.ai/api/v1/embeddings";

const DEFAULT_MODEL = "openai/text-embedding-3-small";

/**
* Known embedding model dimensions accessible via OpenRouter.
* Extend as new models are added. Override in any case via
* OPENROUTER_EMBEDDING_DIMENSIONS for models not listed here.
*/
const MODEL_DIMENSIONS: Record<string, number> = {
"openai/text-embedding-3-small": 1536,
"openai/text-embedding-3-large": 3072,
"openai/text-embedding-ada-002": 1536,
"qwen/qwen3-embedding-8b": 4096,
"google/gemini-embedding-001": 3072,
"cohere/embed-multilingual-v3.0": 1024,
"cohere/embed-english-v3.0": 1024,
"cohere/embed-multilingual-light-v3.0": 384,
"cohere/embed-english-light-v3.0": 384,
};

const DEFAULT_DIMENSIONS = MODEL_DIMENSIONS[DEFAULT_MODEL] ?? 1536;

function resolveDimensions(model: string, override: string | undefined): number {
if (override !== undefined && override.trim().length > 0) {
const parsed = Number(override.trim());
if (!Number.isFinite(parsed) || parsed <= 0 || !Number.isInteger(parsed)) {
throw new Error(
`OPENROUTER_EMBEDDING_DIMENSIONS must be a positive integer, got: ${override}`,
);
}
return parsed;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (!(model in MODEL_DIMENSIONS)) {
console.warn(
`[agentmemory] Unknown embedding model "${model}" not in MODEL_DIMENSIONS; using default dimensions (${DEFAULT_DIMENSIONS}). Set OPENROUTER_EMBEDDING_DIMENSIONS to override.`,
);
}
return MODEL_DIMENSIONS[model] ?? DEFAULT_DIMENSIONS;
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

export class OpenRouterEmbeddingProvider implements EmbeddingProvider {
readonly name = "openrouter";
readonly dimensions = 1536;
readonly dimensions: number;
private apiKey: string;
private model: string;

Expand All @@ -15,7 +54,11 @@ export class OpenRouterEmbeddingProvider implements EmbeddingProvider {
if (!this.apiKey) throw new Error("OPENROUTER_API_KEY is required");
this.model =
getEnvVar("OPENROUTER_EMBEDDING_MODEL") ||
"openai/text-embedding-3-small";
DEFAULT_MODEL;
this.dimensions = resolveDimensions(
this.model,
getEnvVar("OPENROUTER_EMBEDDING_DIMENSIONS"),
);
}

async embed(text: string): Promise<Float32Array> {
Expand Down
93 changes: 93 additions & 0 deletions test/openrouter-embedding.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { OpenRouterEmbeddingProvider } from "../src/providers/embedding/openrouter.js";

describe("OpenRouterEmbeddingProvider", () => {
const originalEnv = { ...process.env };

beforeEach(() => {
process.env = { ...originalEnv };
delete process.env["OPENROUTER_API_KEY"];
delete process.env["OPENROUTER_EMBEDDING_MODEL"];
delete process.env["OPENROUTER_EMBEDDING_DIMENSIONS"];
});

afterEach(() => {
process.env = originalEnv;
});

it("defaults to 1536 dimensions for the default model", () => {
const provider = new OpenRouterEmbeddingProvider("test-key");
expect(provider.name).toBe("openrouter");
expect(provider.dimensions).toBe(1536);
});

it("throws when no API key is provided", () => {
expect(() => new OpenRouterEmbeddingProvider()).toThrow(
/OPENROUTER_API_KEY is required/,
);
});

it("derives dimensions from the known-models table", () => {
process.env["OPENROUTER_EMBEDDING_MODEL"] = "qwen/qwen3-embedding-8b";
const qwen = new OpenRouterEmbeddingProvider("test-key");
expect(qwen.dimensions).toBe(4096);

process.env["OPENROUTER_EMBEDDING_MODEL"] = "openai/text-embedding-3-large";
const large = new OpenRouterEmbeddingProvider("test-key");
expect(large.dimensions).toBe(3072);

process.env["OPENROUTER_EMBEDDING_MODEL"] = "cohere/embed-multilingual-v3.0";
const cohere = new OpenRouterEmbeddingProvider("test-key");
expect(cohere.dimensions).toBe(1024);
});

it("OPENROUTER_EMBEDDING_DIMENSIONS overrides model-derived dimensions", () => {
process.env["OPENROUTER_EMBEDDING_MODEL"] = "openai/text-embedding-3-large";
process.env["OPENROUTER_EMBEDDING_DIMENSIONS"] = "768";
const provider = new OpenRouterEmbeddingProvider("test-key");
expect(provider.dimensions).toBe(768);
});

it("falls back to 1536 for unknown models", () => {
process.env["OPENROUTER_EMBEDDING_MODEL"] = "unknown/custom-model";
const provider = new OpenRouterEmbeddingProvider("test-key");
expect(provider.dimensions).toBe(1536);
});

it("rejects invalid OPENROUTER_EMBEDDING_DIMENSIONS values", () => {
process.env["OPENROUTER_EMBEDDING_DIMENSIONS"] = "not-a-number";
expect(() => new OpenRouterEmbeddingProvider("test-key")).toThrow(
/OPENROUTER_EMBEDDING_DIMENSIONS must be a positive integer/,
);

process.env["OPENROUTER_EMBEDDING_DIMENSIONS"] = "-5";
expect(() => new OpenRouterEmbeddingProvider("test-key")).toThrow(
/OPENROUTER_EMBEDDING_DIMENSIONS must be a positive integer/,
);

process.env["OPENROUTER_EMBEDDING_DIMENSIONS"] = "0";
expect(() => new OpenRouterEmbeddingProvider("test-key")).toThrow(
/OPENROUTER_EMBEDDING_DIMENSIONS must be a positive integer/,
);
});

it("sends correct model in the request body", async () => {
process.env["OPENROUTER_EMBEDDING_MODEL"] = "qwen/qwen3-embedding-8b";
const provider = new OpenRouterEmbeddingProvider("test-key");

const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
JSON.stringify({ data: [{ embedding: Array(4096).fill(0.1) }] }),
{ status: 200 },
),
);

await provider.embed("hello");
const body = JSON.parse(
(fetchSpy.mock.calls[0][1] as RequestInit).body as string,
);
expect(body.model).toBe("qwen/qwen3-embedding-8b");

fetchSpy.mockRestore();
});
});