diff --git a/packages/plugins/official/openrouter-gateway/__tests__/index.test.ts b/packages/plugins/official/openrouter-gateway/__tests__/index.test.ts
new file mode 100644
index 0000000..b9a1159
--- /dev/null
+++ b/packages/plugins/official/openrouter-gateway/__tests__/index.test.ts
@@ -0,0 +1,1261 @@
+///
+/**
+ * OpenRouter Gateway — Unit Tests
+ *
+ * Covers: DB key helpers, todayUtc, estimateCostCents, buildHeaders,
+ * callModel (success + HTTP error), callWithFallback (primary success,
+ * fallback on 429/5xx, all fail), fetchModels (cache hit / miss / fetch),
+ * plugin manifest/settings, app:init (5 endpoints),
+ * GET /models, GET /usage, PUT /config, GET /config, POST /test,
+ * conversation:beforeMessage hook.
+ */
+import plugin, {
+ buildUsageKey,
+ buildConfigKey,
+ todayUtc,
+ estimateCostCents,
+ buildHeaders,
+ callModel,
+ callWithFallback,
+ fetchModels,
+ OPENROUTER_BASE,
+ MODELS_CACHE_KEY,
+ MODELS_CACHE_TTL_MS,
+ DEFAULT_MAX_COST_CENTS,
+ OpenRouterModel,
+ OpenRouterChatMessage,
+ OpenRouterCompletionResponse,
+ ModelsCacheRecord,
+ GatewayConfig,
+ DailyUsage,
+} from "../src/index";
+import {
+ PluginContext,
+ PluginAPI,
+ PluginDatabaseAPI,
+ PluginEventBus,
+ EndpointDefinition,
+ EndpointRequest,
+ EndpointResponse,
+} from "@agentbase/plugin-sdk";
+
+// ── Mock factory ──────────────────────────────────────────────────────────────
+
+function createMockAPI(
+ configOverrides: Record = {},
+): PluginAPI & { _endpoints: EndpointDefinition[] } {
+ const store = new Map();
+ const _endpoints: EndpointDefinition[] = [];
+
+ const db: PluginDatabaseAPI = {
+ set: jest
+ .fn()
+ .mockImplementation(async (k: string, v: unknown) => store.set(k, v)),
+ get: jest
+ .fn()
+ .mockImplementation(async (k: string) => store.get(k) ?? null),
+ delete: jest.fn().mockImplementation(async (k: string) => {
+ const had = store.has(k);
+ store.delete(k);
+ return had;
+ }),
+ keys: jest
+ .fn()
+ .mockImplementation(async (prefix?: string) =>
+ [...store.keys()].filter((k) => !prefix || k.startsWith(prefix)),
+ ),
+ find: jest.fn().mockResolvedValue([]),
+ count: jest.fn().mockResolvedValue(0),
+ };
+
+ const events: PluginEventBus = {
+ emit: jest.fn().mockResolvedValue(undefined),
+ on: jest.fn(),
+ off: jest.fn(),
+ };
+
+ const defaultConfig = new Map([
+ ["openRouterApiKey", "sk-test-key"],
+ ["defaultModel", "openai/gpt-4o"],
+ ["fallbackModels", "openai/gpt-4o-mini,anthropic/claude-3-5-haiku"],
+ ["maxCostPerRequest", 0.5],
+ ["siteUrl", "https://example.com"],
+ ["appName", "TestApp"],
+ ...Object.entries(configOverrides),
+ ]);
+
+ return {
+ _endpoints,
+ getConfig: jest
+ .fn()
+ .mockImplementation((k: string) => defaultConfig.get(k) ?? undefined),
+ setConfig: jest
+ .fn()
+ .mockImplementation(async (k: string, v: unknown) =>
+ defaultConfig.set(k, v),
+ ),
+ makeRequest: jest.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: jest.fn().mockResolvedValue({}),
+ text: jest.fn().mockResolvedValue(""),
+ }),
+ log: jest.fn(),
+ db,
+ events,
+ registerEndpoint: jest
+ .fn()
+ .mockImplementation((def: EndpointDefinition) => _endpoints.push(def)),
+ registerCronJob: jest.fn(),
+ registerWebhook: jest.fn(),
+ registerAdminPage: jest.fn(),
+ } as unknown as PluginAPI & { _endpoints: EndpointDefinition[] };
+}
+
+type MockAPI = ReturnType;
+type MockCtx = PluginContext & { api: MockAPI };
+
+function makeCtx(
+ overrides: Partial = {},
+ configOverrides: Record = {},
+): MockCtx {
+ const api = createMockAPI(configOverrides);
+ return {
+ appId: "app-1",
+ userId: "user-1",
+ config: {},
+ api,
+ ...overrides,
+ } as MockCtx;
+}
+
+interface MockRes {
+ status: jest.Mock;
+ json: jest.Mock;
+ _status: number;
+ _body: unknown;
+}
+
+function makeRes(): MockRes {
+ const r: MockRes = {
+ _status: 200,
+ _body: undefined,
+ status: jest.fn(),
+ json: jest.fn(),
+ };
+ r.status.mockImplementation((code: number) => {
+ r._status = code;
+ return r;
+ });
+ r.json.mockImplementation((body: unknown) => {
+ r._body = body;
+ });
+ return r;
+}
+
+function makeReq(overrides: Partial = {}): EndpointRequest {
+ return {
+ method: "GET",
+ path: "/",
+ params: {},
+ query: {},
+ body: {},
+ headers: {},
+ ...overrides,
+ };
+}
+
+async function runInit(ctx: MockCtx): Promise {
+ const hook = plugin.definition.hooks?.["app:init"];
+ if (!hook) throw new Error("app:init hook not registered");
+ await hook(ctx);
+}
+
+function getEndpoint(
+ api: MockAPI,
+ method: string,
+ path: string,
+): EndpointDefinition {
+ const ep = api._endpoints.find((e) => e.method === method && e.path === path);
+ if (!ep) throw new Error(`Endpoint ${method} ${path} not found`);
+ return ep;
+}
+
+// Build a minimal completion response
+function makeCompletion(
+ model = "openai/gpt-4o",
+ content = "Hello!",
+): OpenRouterCompletionResponse {
+ return {
+ id: "chat-1",
+ model,
+ choices: [
+ {
+ message: { role: "assistant", content },
+ finish_reason: "stop",
+ },
+ ],
+ usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
+ };
+}
+
+// ── DB Key Helpers ────────────────────────────────────────────────────────────
+
+describe("buildUsageKey", () => {
+ it("formats as usage:userId:date", () => {
+ expect(buildUsageKey("user-42", "2025-01-15")).toBe(
+ "usage:user-42:2025-01-15",
+ );
+ });
+
+ it("handles special characters in userId", () => {
+ expect(buildUsageKey("u/1", "2025-06-01")).toBe("usage:u/1:2025-06-01");
+ });
+});
+
+describe("buildConfigKey", () => {
+ it("formats as config:appId", () => {
+ expect(buildConfigKey("app-abc")).toBe("config:app-abc");
+ });
+});
+
+// ── todayUtc ──────────────────────────────────────────────────────────────────
+
+describe("todayUtc", () => {
+ it("returns YYYY-MM-DD format", () => {
+ const result = todayUtc(new Date("2025-06-15T10:30:00Z").getTime());
+ expect(result).toMatch(/^\d{4}-\d{2}-\d{2}$/);
+ expect(result).toBe("2025-06-15");
+ });
+
+ it("uses current time when no argument given", () => {
+ // Just check format is correct
+ expect(todayUtc()).toMatch(/^\d{4}-\d{2}-\d{2}$/);
+ });
+
+ it("returns UTC date not local date", () => {
+ // ~00:30 UTC would give tomorrow in UTC+1 but today in UTC+0
+ const date = todayUtc(new Date("2025-03-01T00:30:00Z").getTime());
+ expect(date).toBe("2025-03-01");
+ });
+});
+
+// ── estimateCostCents ─────────────────────────────────────────────────────────
+
+describe("estimateCostCents", () => {
+ it("calculates cents from token counts and per-token prices", () => {
+ // 1000 prompt tokens @ $0.000001 + 500 completion @ $0.000002
+ // = 0.001 + 0.001 = 0.002 USD = 0.2 cents → ceil = 1
+ const result = estimateCostCents(1000, 500, "0.000001", "0.000002");
+ expect(result).toBeGreaterThan(0);
+ });
+
+ it("returns 0 when both prices are zero string", () => {
+ expect(estimateCostCents(1000, 1000, "0", "0")).toBe(0);
+ });
+
+ it("returns 0 when both prices are empty string", () => {
+ expect(estimateCostCents(100, 100, "", "")).toBe(0);
+ });
+
+ it("rounds up fractional cents", () => {
+ // 1 token @ 0.000001 = 0.0001 cents → ceil = 1
+ const result = estimateCostCents(1, 0, "0.000001", "0");
+ expect(result).toBe(1);
+ });
+
+ it("handles larger token counts", () => {
+ // 100k prompt @ 0.000001 + 50k completion @ 0.000002 = 0.1 + 0.1 = 0.2 USD = 20 cents
+ const result = estimateCostCents(100_000, 50_000, "0.000001", "0.000002");
+ expect(result).toBe(20);
+ });
+});
+
+// ── buildHeaders ──────────────────────────────────────────────────────────────
+
+describe("buildHeaders", () => {
+ it("includes Authorization header", () => {
+ const h = buildHeaders("my-key", "", "");
+ expect(h["Authorization"]).toBe("Bearer my-key");
+ });
+
+ it("includes Content-Type", () => {
+ const h = buildHeaders("k", "", "");
+ expect(h["Content-Type"]).toBe("application/json");
+ });
+
+ it("includes HTTP-Referer when siteUrl provided", () => {
+ const h = buildHeaders("k", "https://example.com", "");
+ expect(h["HTTP-Referer"]).toBe("https://example.com");
+ });
+
+ it("omits HTTP-Referer when siteUrl empty", () => {
+ const h = buildHeaders("k", "", "App");
+ expect(h["HTTP-Referer"]).toBeUndefined();
+ });
+
+ it("includes X-Title when appName provided", () => {
+ const h = buildHeaders("k", "", "MyApp");
+ expect(h["X-Title"]).toBe("MyApp");
+ });
+
+ it("omits X-Title when appName empty", () => {
+ const h = buildHeaders("k", "https://site.com", "");
+ expect(h["X-Title"]).toBeUndefined();
+ });
+});
+
+// ── callModel ─────────────────────────────────────────────────────────────────
+
+describe("callModel", () => {
+ it("returns parsed response on 200", async () => {
+ const completion = makeCompletion();
+ const makeRequest = jest.fn().mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: jest.fn().mockResolvedValue(completion),
+ });
+
+ const result = await callModel(makeRequest, "key", "", "", {
+ model: "openai/gpt-4o",
+ messages: [{ role: "user", content: "Hi" }],
+ });
+
+ expect(result.model).toBe("openai/gpt-4o");
+ expect(result.choices[0]?.message.content).toBe("Hello!");
+ });
+
+ it("posts to the correct URL", async () => {
+ const makeRequest = jest.fn().mockResolvedValue({
+ ok: true,
+ json: jest.fn().mockResolvedValue(makeCompletion()),
+ });
+
+ await callModel(makeRequest, "k", "", "", {
+ model: "openai/gpt-4o",
+ messages: [],
+ });
+
+ expect(makeRequest).toHaveBeenCalledWith(
+ `${OPENROUTER_BASE}/chat/completions`,
+ expect.objectContaining({ method: "POST" }),
+ );
+ });
+
+ it("throws on HTTP 429", async () => {
+ const makeRequest = jest.fn().mockResolvedValue({
+ ok: false,
+ status: 429,
+ json: jest.fn(),
+ });
+
+ await expect(
+ callModel(makeRequest, "k", "", "", {
+ model: "openai/gpt-4o",
+ messages: [],
+ }),
+ ).rejects.toThrow("429");
+ });
+
+ it("throws on HTTP 500", async () => {
+ const makeRequest = jest.fn().mockResolvedValue({
+ ok: false,
+ status: 500,
+ json: jest.fn(),
+ });
+
+ await expect(
+ callModel(makeRequest, "k", "", "", { model: "m", messages: [] }),
+ ).rejects.toThrow("500");
+ });
+
+ it("throws on HTTP 401 (auth failure)", async () => {
+ const makeRequest = jest.fn().mockResolvedValue({
+ ok: false,
+ status: 401,
+ json: jest.fn(),
+ });
+
+ await expect(
+ callModel(makeRequest, "bad-key", "", "", { model: "m", messages: [] }),
+ ).rejects.toThrow("401");
+ });
+
+ it("passes API key in Authorization header", async () => {
+ const makeRequest = jest.fn().mockResolvedValue({
+ ok: true,
+ json: jest.fn().mockResolvedValue(makeCompletion()),
+ });
+
+ await callModel(makeRequest, "sk-abc", "", "", {
+ model: "m",
+ messages: [],
+ });
+
+ const opts = makeRequest.mock.calls[0]?.[1] as RequestInit | undefined;
+ const headers = opts?.headers as Record;
+ expect(headers["Authorization"]).toBe("Bearer sk-abc");
+ });
+});
+
+// ── callWithFallback ──────────────────────────────────────────────────────────
+
+describe("callWithFallback", () => {
+ it("returns primary model result when it succeeds", async () => {
+ const completion = makeCompletion("openai/gpt-4o");
+ const makeRequest = jest.fn().mockResolvedValue({
+ ok: true,
+ json: jest.fn().mockResolvedValue(completion),
+ });
+
+ const result = await callWithFallback(
+ makeRequest,
+ "key",
+ "",
+ "",
+ [{ role: "user", content: "Hi" }],
+ "openai/gpt-4o",
+ ["openai/gpt-4o-mini"],
+ );
+
+ expect(result.model).toBe("openai/gpt-4o");
+ // Should only call once (primary succeeded)
+ expect(makeRequest).toHaveBeenCalledTimes(1);
+ });
+
+ it("falls back to second model on 429 from primary", async () => {
+ const makeRequest = jest
+ .fn()
+ .mockResolvedValueOnce({ ok: false, status: 429, json: jest.fn() })
+ .mockResolvedValueOnce({
+ ok: true,
+ json: jest.fn().mockResolvedValue(makeCompletion("openai/gpt-4o-mini")),
+ });
+
+ const result = await callWithFallback(
+ makeRequest,
+ "key",
+ "",
+ "",
+ [{ role: "user", content: "Hi" }],
+ "openai/gpt-4o",
+ ["openai/gpt-4o-mini"],
+ );
+
+ expect(result.model).toBe("openai/gpt-4o-mini");
+ expect(makeRequest).toHaveBeenCalledTimes(2);
+ });
+
+ it("falls back to second model on 500 from primary", async () => {
+ const makeRequest = jest
+ .fn()
+ .mockResolvedValueOnce({ ok: false, status: 500, json: jest.fn() })
+ .mockResolvedValueOnce({
+ ok: true,
+ json: jest.fn().mockResolvedValue(makeCompletion("fallback-model")),
+ });
+
+ const result = await callWithFallback(
+ makeRequest,
+ "key",
+ "",
+ "",
+ [],
+ "primary",
+ ["fallback-model"],
+ );
+
+ expect(result.model).toBe("fallback-model");
+ });
+
+ it("falls back through multiple models until one succeeds", async () => {
+ const makeRequest = jest
+ .fn()
+ .mockResolvedValueOnce({ ok: false, status: 429, json: jest.fn() })
+ .mockResolvedValueOnce({ ok: false, status: 503, json: jest.fn() })
+ .mockResolvedValueOnce({
+ ok: true,
+ json: jest.fn().mockResolvedValue(makeCompletion("third-model")),
+ });
+
+ const result = await callWithFallback(
+ makeRequest,
+ "key",
+ "",
+ "",
+ [],
+ "model-a",
+ ["model-b", "third-model"],
+ );
+
+ expect(result.model).toBe("third-model");
+ expect(makeRequest).toHaveBeenCalledTimes(3);
+ });
+
+ it("throws when all models in chain fail with retryable errors", async () => {
+ const makeRequest = jest.fn().mockResolvedValue({
+ ok: false,
+ status: 503,
+ json: jest.fn(),
+ });
+
+ await expect(
+ callWithFallback(makeRequest, "k", "", "", [], "m1", ["m2"]),
+ ).rejects.toThrow("503");
+ });
+
+ it("throws immediately on non-retryable error (401) without trying fallbacks", async () => {
+ const makeRequest = jest.fn().mockResolvedValue({
+ ok: false,
+ status: 401,
+ json: jest.fn(),
+ });
+
+ await expect(
+ callWithFallback(makeRequest, "bad-key", "", "", [], "m1", ["m2"]),
+ ).rejects.toThrow("401");
+
+ // 401 is not retryable — only called once
+ expect(makeRequest).toHaveBeenCalledTimes(1);
+ });
+
+ it("works with empty fallback list (primary only)", async () => {
+ const makeRequest = jest.fn().mockResolvedValue({
+ ok: true,
+ json: jest.fn().mockResolvedValue(makeCompletion("only-model")),
+ });
+
+ const result = await callWithFallback(
+ makeRequest,
+ "key",
+ "",
+ "",
+ [],
+ "only-model",
+ [],
+ );
+ expect(result.model).toBe("only-model");
+ });
+});
+
+// ── fetchModels ───────────────────────────────────────────────────────────────
+
+const SAMPLE_MODELS: OpenRouterModel[] = [
+ {
+ id: "openai/gpt-4o",
+ name: "GPT-4o",
+ pricing: { prompt: "0.000001", completion: "0.000002" },
+ context_length: 128000,
+ },
+ {
+ id: "openai/gpt-4o-mini",
+ name: "GPT-4o Mini",
+ pricing: { prompt: "0.0000001", completion: "0.0000002" },
+ context_length: 128000,
+ },
+];
+
+describe("fetchModels", () => {
+ it("returns cached models when cache is fresh", async () => {
+ const makeRequest = jest.fn();
+ const cachedAt = Date.now() - 1000; // 1 second ago — fresh
+ const cached: ModelsCacheRecord = { models: SAMPLE_MODELS, cachedAt };
+
+ const result = await fetchModels(
+ makeRequest,
+ "k",
+ "",
+ "",
+ cached,
+ Date.now(),
+ );
+
+ expect(result).toBe(SAMPLE_MODELS);
+ expect(makeRequest).not.toHaveBeenCalled();
+ });
+
+ it("fetches fresh models when cache is stale (> 1 hour)", async () => {
+ const freshModels: OpenRouterModel[] = [
+ { id: "new-model", name: "New Model" },
+ ];
+ const makeRequest = jest.fn().mockResolvedValue({
+ ok: true,
+ json: jest.fn().mockResolvedValue({ data: freshModels }),
+ });
+
+ const staleCache: ModelsCacheRecord = {
+ models: SAMPLE_MODELS,
+ cachedAt: Date.now() - MODELS_CACHE_TTL_MS - 1,
+ };
+
+ const result = await fetchModels(
+ makeRequest,
+ "k",
+ "",
+ "",
+ staleCache,
+ Date.now(),
+ );
+
+ expect(result).toEqual(freshModels);
+ expect(makeRequest).toHaveBeenCalledTimes(1);
+ });
+
+ it("fetches when no cache record", async () => {
+ const makeRequest = jest.fn().mockResolvedValue({
+ ok: true,
+ json: jest.fn().mockResolvedValue({ data: SAMPLE_MODELS }),
+ });
+
+ const result = await fetchModels(
+ makeRequest,
+ "k",
+ "",
+ "",
+ null,
+ Date.now(),
+ );
+
+ expect(result).toEqual(SAMPLE_MODELS);
+ expect(makeRequest).toHaveBeenCalledTimes(1);
+ });
+
+ it("throws when HTTP request fails", async () => {
+ const makeRequest = jest.fn().mockResolvedValue({
+ ok: false,
+ status: 503,
+ json: jest.fn(),
+ });
+
+ await expect(
+ fetchModels(makeRequest, "k", "", "", null, Date.now()),
+ ).rejects.toThrow("503");
+ });
+
+ it("requests the correct models URL", async () => {
+ const makeRequest = jest.fn().mockResolvedValue({
+ ok: true,
+ json: jest.fn().mockResolvedValue({ data: [] }),
+ });
+
+ await fetchModels(makeRequest, "k", "", "", null, Date.now());
+
+ expect(makeRequest).toHaveBeenCalledWith(
+ `${OPENROUTER_BASE}/models`,
+ expect.objectContaining({
+ headers: expect.objectContaining({ Authorization: "Bearer k" }),
+ }),
+ );
+ });
+});
+
+// ── Plugin manifest ───────────────────────────────────────────────────────────
+
+describe("plugin manifest", () => {
+ it("has correct name", () => {
+ expect(plugin.manifest.name).toBe("openrouter-gateway");
+ });
+
+ it("has correct version", () => {
+ expect(plugin.manifest.version).toBe("1.0.0");
+ });
+
+ it("has a description", () => {
+ expect(typeof plugin.manifest.description).toBe("string");
+ expect((plugin.manifest.description ?? "").length).toBeGreaterThan(10);
+ });
+});
+
+// ── Plugin settings ───────────────────────────────────────────────────────────
+
+describe("plugin settings", () => {
+ const settings = plugin.definition.settings!;
+
+ it("defines exactly 6 settings", () => {
+ expect(Object.keys(settings)).toHaveLength(6);
+ });
+
+ it("has openRouterApiKey with encrypted flag", () => {
+ expect(settings["openRouterApiKey"]).toBeDefined();
+ expect(
+ (settings["openRouterApiKey"] as { encrypted?: boolean }).encrypted,
+ ).toBe(true);
+ });
+
+ it("has defaultModel with a default value", () => {
+ expect(settings["defaultModel"]).toBeDefined();
+ expect(
+ (settings["defaultModel"] as { default?: unknown }).default,
+ ).toBeTruthy();
+ });
+
+ it("has fallbackModels as string type (comma-separated)", () => {
+ expect((settings["fallbackModels"] as { type: string }).type).toBe(
+ "string",
+ );
+ });
+
+ it("has maxCostPerRequest as number type", () => {
+ expect((settings["maxCostPerRequest"] as { type: string }).type).toBe(
+ "number",
+ );
+ });
+
+ it("has siteUrl setting", () => {
+ expect(settings["siteUrl"]).toBeDefined();
+ });
+
+ it("has appName setting", () => {
+ expect(settings["appName"]).toBeDefined();
+ });
+});
+
+// ── app:init — endpoint registration ─────────────────────────────────────────
+
+describe("app:init", () => {
+ it("registers exactly 5 endpoints", async () => {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ expect(ctx.api._endpoints).toHaveLength(5);
+ });
+
+ it("registers GET /models", async () => {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ expect(() => getEndpoint(ctx.api, "GET", "/models")).not.toThrow();
+ });
+
+ it("registers GET /usage", async () => {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ expect(() => getEndpoint(ctx.api, "GET", "/usage")).not.toThrow();
+ });
+
+ it("registers PUT /config", async () => {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ expect(() => getEndpoint(ctx.api, "PUT", "/config")).not.toThrow();
+ });
+
+ it("registers GET /config", async () => {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ expect(() => getEndpoint(ctx.api, "GET", "/config")).not.toThrow();
+ });
+
+ it("registers POST /test", async () => {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ expect(() => getEndpoint(ctx.api, "POST", "/test")).not.toThrow();
+ });
+
+ it("all endpoints require auth", async () => {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ for (const ep of ctx.api._endpoints) {
+ expect(ep.auth).toBe(true);
+ }
+ });
+});
+
+// ── GET /models ───────────────────────────────────────────────────────────────
+
+describe("GET /models", () => {
+ it("returns 400 when API key not configured", async () => {
+ const ctx = makeCtx({}, { openRouterApiKey: "" });
+ await runInit(ctx);
+ const ep = getEndpoint(ctx.api, "GET", "/models");
+ const res = makeRes();
+ await ep.handler(makeReq(), res as unknown as EndpointResponse);
+ expect(res._status).toBe(400);
+ });
+
+ it("returns cached models from DB without calling API", async () => {
+ const ctx = makeCtx();
+ await runInit(ctx);
+
+ // Pre-populate cache
+ const cache: ModelsCacheRecord = {
+ models: SAMPLE_MODELS,
+ cachedAt: Date.now() - 1000, // fresh
+ };
+ await ctx.api.db.set(MODELS_CACHE_KEY, cache);
+
+ const ep = getEndpoint(ctx.api, "GET", "/models");
+ const res = makeRes();
+ await ep.handler(makeReq(), res as unknown as EndpointResponse);
+
+ expect(res._status).toBe(200);
+ expect((res._body as { models: unknown[] }).models).toHaveLength(2);
+ // makeRequest should NOT have been called for models
+ expect(ctx.api.makeRequest).not.toHaveBeenCalled();
+ });
+
+ it("fetches from OpenRouter when cache is stale", async () => {
+ const ctx = makeCtx();
+
+ // Prime stale cache
+ const staleCache: ModelsCacheRecord = {
+ models: SAMPLE_MODELS,
+ cachedAt: Date.now() - MODELS_CACHE_TTL_MS - 1000,
+ };
+ await ctx.api.db.set(MODELS_CACHE_KEY, staleCache);
+
+ // Mock fresh response
+ (ctx.api.makeRequest as jest.Mock).mockResolvedValue({
+ ok: true,
+ json: jest.fn().mockResolvedValue({ data: SAMPLE_MODELS }),
+ });
+
+ await runInit(ctx);
+ const ep = getEndpoint(ctx.api, "GET", "/models");
+ const res = makeRes();
+ await ep.handler(makeReq(), res as unknown as EndpointResponse);
+
+ expect(res._status).toBe(200);
+ expect(ctx.api.makeRequest).toHaveBeenCalledWith(
+ `${OPENROUTER_BASE}/models`,
+ expect.anything(),
+ );
+ });
+
+ it("returns 502 on OpenRouter API error", async () => {
+ const ctx = makeCtx();
+ (ctx.api.makeRequest as jest.Mock).mockResolvedValue({
+ ok: false,
+ status: 503,
+ json: jest.fn(),
+ });
+
+ await runInit(ctx);
+ const ep = getEndpoint(ctx.api, "GET", "/models");
+ const res = makeRes();
+ await ep.handler(makeReq(), res as unknown as EndpointResponse);
+
+ expect(res._status).toBe(502);
+ });
+});
+
+// ── GET /usage ────────────────────────────────────────────────────────────────
+
+describe("GET /usage", () => {
+ it("returns null when no usage record for user+date", async () => {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ const ep = getEndpoint(ctx.api, "GET", "/usage");
+ const res = makeRes();
+ await ep.handler(
+ makeReq({ query: { userId: "u1", date: "2025-06-01" } }),
+ res as unknown as EndpointResponse,
+ );
+ expect(res._status).toBe(200);
+ expect((res._body as { usage: unknown }).usage).toBeNull();
+ });
+
+ it("returns usage record for a specific user+date", async () => {
+ const ctx = makeCtx();
+ const record: DailyUsage = {
+ userId: "u1",
+ date: "2025-06-01",
+ entries: [
+ {
+ model: "openai/gpt-4o",
+ promptTokens: 10,
+ completionTokens: 5,
+ totalTokens: 15,
+ costCents: 1,
+ timestamp: Date.now(),
+ },
+ ],
+ totalCostCents: 1,
+ totalTokens: 15,
+ };
+ await ctx.api.db.set("usage:u1:2025-06-01", record);
+
+ await runInit(ctx);
+ const ep = getEndpoint(ctx.api, "GET", "/usage");
+ const res = makeRes();
+ await ep.handler(
+ makeReq({ query: { userId: "u1", date: "2025-06-01" } }),
+ res as unknown as EndpointResponse,
+ );
+
+ expect(res._status).toBe(200);
+ expect((res._body as { usage: DailyUsage }).usage.totalTokens).toBe(15);
+ });
+
+ it("returns empty array when no records exist for aggregate query", async () => {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ const ep = getEndpoint(ctx.api, "GET", "/usage");
+ const res = makeRes();
+ await ep.handler(
+ makeReq({ query: { date: "2025-06-01" } }),
+ res as unknown as EndpointResponse,
+ );
+ expect(res._status).toBe(200);
+ expect((res._body as { usage: unknown[] }).usage).toEqual([]);
+ });
+
+ it("aggregates multiple user records for a date", async () => {
+ const ctx = makeCtx();
+ const record1: DailyUsage = {
+ userId: "u1",
+ date: "2025-06-01",
+ entries: [],
+ totalCostCents: 20,
+ totalTokens: 100,
+ };
+ const record2: DailyUsage = {
+ userId: "u2",
+ date: "2025-06-01",
+ entries: [],
+ totalCostCents: 5,
+ totalTokens: 50,
+ };
+ await ctx.api.db.set("usage:u1:2025-06-01", record1);
+ await ctx.api.db.set("usage:u2:2025-06-01", record2);
+
+ await runInit(ctx);
+ const ep = getEndpoint(ctx.api, "GET", "/usage");
+ const res = makeRes();
+ await ep.handler(
+ makeReq({ query: { date: "2025-06-01" } }),
+ res as unknown as EndpointResponse,
+ );
+
+ expect(res._status).toBe(200);
+ const body = res._body as { usage: DailyUsage[] };
+ expect(body.usage).toHaveLength(2);
+ // Sorted by totalCostCents descending
+ expect(body.usage[0]!.totalCostCents).toBe(20);
+ });
+});
+
+// ── PUT /config ───────────────────────────────────────────────────────────────
+
+describe("PUT /config", () => {
+ it("returns 400 when no fields provided", async () => {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ const ep = getEndpoint(ctx.api, "PUT", "/config");
+ const res = makeRes();
+ await ep.handler(makeReq({ body: {} }), res as unknown as EndpointResponse);
+ expect(res._status).toBe(400);
+ });
+
+ it("saves defaultModel to DB", async () => {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ const ep = getEndpoint(ctx.api, "PUT", "/config");
+ const res = makeRes();
+ await ep.handler(
+ makeReq({ body: { defaultModel: "anthropic/claude-3-5-sonnet" } }),
+ res as unknown as EndpointResponse,
+ );
+ expect(res._status).toBe(200);
+ const saved = (await ctx.api.db.get(
+ buildConfigKey("app-1"),
+ )) as GatewayConfig;
+ expect(saved.defaultModel).toBe("anthropic/claude-3-5-sonnet");
+ });
+
+ it("parses comma-separated fallbackModels into array", async () => {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ const ep = getEndpoint(ctx.api, "PUT", "/config");
+ const res = makeRes();
+ await ep.handler(
+ makeReq({
+ body: {
+ defaultModel: "openai/gpt-4o",
+ fallbackModels: "m1, m2, m3",
+ },
+ }),
+ res as unknown as EndpointResponse,
+ );
+ const saved = (await ctx.api.db.get(
+ buildConfigKey("app-1"),
+ )) as GatewayConfig;
+ expect(saved.fallbackModels).toEqual(["m1", "m2", "m3"]);
+ });
+
+ it("converts maxCostPerRequest USD to cents", async () => {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ const ep = getEndpoint(ctx.api, "PUT", "/config");
+ const res = makeRes();
+ await ep.handler(
+ makeReq({ body: { defaultModel: "m", maxCostPerRequest: 0.25 } }),
+ res as unknown as EndpointResponse,
+ );
+ const saved = (await ctx.api.db.get(
+ buildConfigKey("app-1"),
+ )) as GatewayConfig;
+ expect(saved.maxCostCents).toBe(25);
+ });
+
+ it("returns the updated config in response", async () => {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ const ep = getEndpoint(ctx.api, "PUT", "/config");
+ const res = makeRes();
+ await ep.handler(
+ makeReq({ body: { defaultModel: "new-model" } }),
+ res as unknown as EndpointResponse,
+ );
+ const body = res._body as { config: GatewayConfig };
+ expect(body.config.defaultModel).toBe("new-model");
+ });
+});
+
+// ── GET /config ───────────────────────────────────────────────────────────────
+
+describe("GET /config", () => {
+ it("returns defaults from plugin settings when no DB config saved", async () => {
+ const ctx = makeCtx();
+ await runInit(ctx);
+ const ep = getEndpoint(ctx.api, "GET", "/config");
+ const res = makeRes();
+ await ep.handler(makeReq(), res as unknown as EndpointResponse);
+
+ expect(res._status).toBe(200);
+ const body = res._body as { config: GatewayConfig };
+ expect(body.config.defaultModel).toBe("openai/gpt-4o");
+ });
+
+ it("returns stored config when saved", async () => {
+ const ctx = makeCtx();
+ const stored: GatewayConfig = {
+ defaultModel: "anthropic/claude-opus",
+ fallbackModels: ["openai/gpt-4o"],
+ maxCostCents: 100,
+ updatedAt: Date.now(),
+ };
+ await ctx.api.db.set(buildConfigKey("app-1"), stored);
+
+ await runInit(ctx);
+ const ep = getEndpoint(ctx.api, "GET", "/config");
+ const res = makeRes();
+ await ep.handler(makeReq(), res as unknown as EndpointResponse);
+
+ const body = res._body as { config: GatewayConfig };
+ expect(body.config.defaultModel).toBe("anthropic/claude-opus");
+ expect(body.config.fallbackModels).toEqual(["openai/gpt-4o"]);
+ expect(body.config.maxCostCents).toBe(100);
+ });
+});
+
+// ── POST /test ────────────────────────────────────────────────────────────────
+
+describe("POST /test", () => {
+ it("returns 400 when API key not configured", async () => {
+ const ctx = makeCtx({}, { openRouterApiKey: "" });
+ await runInit(ctx);
+ const ep = getEndpoint(ctx.api, "POST", "/test");
+ const res = makeRes();
+ await ep.handler(makeReq({ body: {} }), res as unknown as EndpointResponse);
+ expect(res._status).toBe(400);
+ });
+
+ it("returns 200 with model, reply, and usage on success", async () => {
+ const ctx = makeCtx();
+ (ctx.api.makeRequest as jest.Mock).mockResolvedValue({
+ ok: true,
+ json: jest
+ .fn()
+ .mockResolvedValue(makeCompletion("openai/gpt-4o-mini", "OK")),
+ });
+
+ await runInit(ctx);
+ const ep = getEndpoint(ctx.api, "POST", "/test");
+ const res = makeRes();
+ await ep.handler(
+ makeReq({ body: { prompt: "Say OK", model: "openai/gpt-4o-mini" } }),
+ res as unknown as EndpointResponse,
+ );
+
+ expect(res._status).toBe(200);
+ const body = res._body as { ok: boolean; model: string; reply: string };
+ expect(body.ok).toBe(true);
+ expect(body.model).toBe("openai/gpt-4o-mini");
+ expect(body.reply).toBe("OK");
+ });
+
+ it("returns 502 on OpenRouter API error", async () => {
+ const ctx = makeCtx();
+ (ctx.api.makeRequest as jest.Mock).mockResolvedValue({
+ ok: false,
+ status: 401,
+ json: jest.fn(),
+ });
+
+ await runInit(ctx);
+ const ep = getEndpoint(ctx.api, "POST", "/test");
+ const res = makeRes();
+ await ep.handler(makeReq({ body: {} }), res as unknown as EndpointResponse);
+
+ expect(res._status).toBe(502);
+ const body = res._body as { ok: boolean; error: string };
+ expect(body.ok).toBe(false);
+ expect(body.error).toBeTruthy();
+ });
+
+ it("uses default model when model not specified in body", async () => {
+ const ctx = makeCtx();
+ (ctx.api.makeRequest as jest.Mock).mockResolvedValue({
+ ok: true,
+ json: jest.fn().mockResolvedValue(makeCompletion("openai/gpt-4o")),
+ });
+
+ await runInit(ctx);
+ const ep = getEndpoint(ctx.api, "POST", "/test");
+ const res = makeRes();
+ await ep.handler(makeReq({ body: {} }), res as unknown as EndpointResponse);
+
+ const requestBody = JSON.parse(
+ (ctx.api.makeRequest as jest.Mock).mock.calls[0]?.[1]?.body as string,
+ );
+ expect(requestBody.model).toBe("openai/gpt-4o");
+ });
+});
+
+// ── conversation:beforeMessage ────────────────────────────────────────────────
+
+describe("conversation:beforeMessage hook", () => {
+ it("does nothing when API key is not configured", async () => {
+ const ctx = makeCtx(
+ { message: "Hello" } as unknown as Partial,
+ {
+ openRouterApiKey: "",
+ },
+ );
+
+ const hook = plugin.definition.hooks?.["conversation:beforeMessage"];
+ await hook?.(ctx);
+
+ expect(ctx.api.makeRequest).not.toHaveBeenCalled();
+ });
+
+ it("does nothing when message is missing from context", async () => {
+ const ctx = makeCtx();
+ const hook = plugin.definition.hooks?.["conversation:beforeMessage"];
+ await hook?.(ctx);
+
+ expect(ctx.api.makeRequest).not.toHaveBeenCalled();
+ });
+
+ it("calls OpenRouter with the message content", async () => {
+ const ctx = {
+ ...makeCtx(),
+ message: "What is 2+2?",
+ } as MockCtx & { message: string };
+
+ (ctx.api.makeRequest as jest.Mock).mockResolvedValue({
+ ok: true,
+ json: jest.fn().mockResolvedValue(makeCompletion()),
+ });
+
+ const hook = plugin.definition.hooks?.["conversation:beforeMessage"];
+ await hook?.(ctx);
+
+ expect(ctx.api.makeRequest).toHaveBeenCalledWith(
+ `${OPENROUTER_BASE}/chat/completions`,
+ expect.objectContaining({ method: "POST" }),
+ );
+ });
+
+ it("stores usage record after successful completion", async () => {
+ const ctx = {
+ ...makeCtx(),
+ message: "Hello",
+ userId: "user-42",
+ } as MockCtx & { message: string; userId: string };
+
+ (ctx.api.makeRequest as jest.Mock).mockResolvedValue({
+ ok: true,
+ json: jest.fn().mockResolvedValue(makeCompletion()),
+ });
+
+ const hook = plugin.definition.hooks?.["conversation:beforeMessage"];
+ await hook?.(ctx);
+
+ const today = todayUtc();
+ const usageKey = buildUsageKey("user-42", today);
+ const record = (await ctx.api.db.get(usageKey)) as DailyUsage | null;
+ expect(record).not.toBeNull();
+ expect(record!.entries).toHaveLength(1);
+ expect(record!.totalTokens).toBe(15);
+ });
+
+ it("uses config from DB when stored", async () => {
+ const ctx = {
+ ...makeCtx(),
+ message: "Hi",
+ } as MockCtx & { message: string };
+
+ const stored: GatewayConfig = {
+ defaultModel: "anthropic/claude-opus",
+ fallbackModels: [],
+ maxCostCents: 100,
+ updatedAt: Date.now(),
+ };
+ await ctx.api.db.set(buildConfigKey("app-1"), stored);
+
+ (ctx.api.makeRequest as jest.Mock).mockResolvedValue({
+ ok: true,
+ json: jest
+ .fn()
+ .mockResolvedValue(makeCompletion("anthropic/claude-opus")),
+ });
+
+ const hook = plugin.definition.hooks?.["conversation:beforeMessage"];
+ await hook?.(ctx);
+
+ const requestBody = JSON.parse(
+ (ctx.api.makeRequest as jest.Mock).mock.calls[0]?.[1]?.body as string,
+ );
+ expect(requestBody.model).toBe("anthropic/claude-opus");
+ });
+
+ it("logs error but does not throw on API failure", async () => {
+ const ctx = {
+ ...makeCtx(),
+ message: "Hello",
+ } as MockCtx & { message: string };
+
+ (ctx.api.makeRequest as jest.Mock).mockResolvedValue({
+ ok: false,
+ status: 500,
+ json: jest.fn(),
+ });
+
+ const hook = plugin.definition.hooks?.["conversation:beforeMessage"];
+ // Should not throw
+ await expect(hook?.(ctx)).resolves.not.toThrow();
+ expect(ctx.api.log).toHaveBeenCalledWith(
+ expect.stringContaining("error"),
+ "error",
+ );
+ });
+});
+
+// ── Constants ─────────────────────────────────────────────────────────────────
+
+describe("constants", () => {
+ it("OPENROUTER_BASE points to the correct API", () => {
+ expect(OPENROUTER_BASE).toBe("https://openrouter.ai/api/v1");
+ });
+
+ it("MODELS_CACHE_TTL_MS is 1 hour", () => {
+ expect(MODELS_CACHE_TTL_MS).toBe(3_600_000);
+ });
+
+ it("DEFAULT_MAX_COST_CENTS is 50 cents", () => {
+ expect(DEFAULT_MAX_COST_CENTS).toBe(50);
+ });
+
+ it("MODELS_CACHE_KEY is 'models:cache'", () => {
+ expect(MODELS_CACHE_KEY).toBe("models:cache");
+ });
+});
diff --git a/packages/plugins/official/openrouter-gateway/__tests__/tsconfig.json b/packages/plugins/official/openrouter-gateway/__tests__/tsconfig.json
new file mode 100644
index 0000000..a05feed
--- /dev/null
+++ b/packages/plugins/official/openrouter-gateway/__tests__/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "extends": "../tsconfig.json",
+ "compilerOptions": {
+ "rootDir": "../..",
+ "baseUrl": "..",
+ "lib": ["ES2022", "DOM"],
+ "types": ["jest", "node"],
+ "paths": {
+ "@agentbase/plugin-sdk": ["../../../sdk/src/index.ts"]
+ }
+ },
+ "include": ["../src/**/*", "./**/*"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/packages/plugins/official/openrouter-gateway/manifest.json b/packages/plugins/official/openrouter-gateway/manifest.json
new file mode 100644
index 0000000..976553b
--- /dev/null
+++ b/packages/plugins/official/openrouter-gateway/manifest.json
@@ -0,0 +1,57 @@
+{
+ "name": "openrouter-gateway",
+ "version": "1.0.0",
+ "description": "Route all AI model calls through OpenRouter — one key for 200+ models with automatic fallbacks and cost tracking.",
+ "author": "Agentbase",
+ "license": "GPL-3.0",
+ "main": "dist/index.js",
+ "agentbase": {
+ "type": "plugin",
+ "apiVersion": "1"
+ },
+ "hooks": ["app:init", "conversation:beforeMessage"],
+ "endpoints": [
+ { "method": "GET", "path": "/models" },
+ { "method": "GET", "path": "/usage" },
+ { "method": "PUT", "path": "/config" },
+ { "method": "GET", "path": "/config" },
+ { "method": "POST", "path": "/test" }
+ ],
+ "settings": [
+ {
+ "key": "openRouterApiKey",
+ "type": "string",
+ "label": "OpenRouter API Key",
+ "encrypted": true
+ },
+ {
+ "key": "defaultModel",
+ "type": "string",
+ "label": "Default Model (e.g. openai/gpt-4o)",
+ "default": "openai/gpt-4o"
+ },
+ {
+ "key": "fallbackModels",
+ "type": "string",
+ "label": "Fallback Models (comma-separated)",
+ "default": "openai/gpt-4o-mini,anthropic/claude-3-5-haiku"
+ },
+ {
+ "key": "maxCostPerRequest",
+ "type": "number",
+ "label": "Max Cost Per Request (USD)",
+ "default": 0.5
+ },
+ {
+ "key": "siteUrl",
+ "type": "string",
+ "label": "Site URL (sent as HTTP-Referer to OpenRouter)"
+ },
+ {
+ "key": "appName",
+ "type": "string",
+ "label": "App Name (sent as X-Title to OpenRouter)"
+ }
+ ],
+ "permissions": ["network:external", "db:readwrite"]
+}
diff --git a/packages/plugins/official/openrouter-gateway/package.json b/packages/plugins/official/openrouter-gateway/package.json
new file mode 100644
index 0000000..a928101
--- /dev/null
+++ b/packages/plugins/official/openrouter-gateway/package.json
@@ -0,0 +1,35 @@
+{
+ "name": "@agentbase/plugin-openrouter-gateway",
+ "version": "1.0.0",
+ "description": "Route all AI model calls through OpenRouter — one key for 200+ models with automatic fallbacks and cost tracking.",
+ "main": "dist/index.js",
+ "types": "dist/index.d.ts",
+ "scripts": {
+ "build": "tsc",
+ "test": "jest",
+ "lint": "eslint src --ext .ts"
+ },
+ "dependencies": {
+ "@agentbase/plugin-sdk": "workspace:*"
+ },
+ "devDependencies": {
+ "@types/jest": "^29.5.0",
+ "@types/node": "^20.0.0",
+ "jest": "^29.5.0",
+ "ts-jest": "^29.1.0",
+ "typescript": "^5.0.0"
+ },
+ "jest": {
+ "preset": "ts-jest",
+ "testEnvironment": "node",
+ "roots": [
+ "/__tests__"
+ ],
+ "globals": {
+ "ts-jest": {
+ "tsconfig": "__tests__/tsconfig.json"
+ }
+ }
+ },
+ "license": "GPL-3.0"
+}
diff --git a/packages/plugins/official/openrouter-gateway/src/index.ts b/packages/plugins/official/openrouter-gateway/src/index.ts
new file mode 100644
index 0000000..42645df
--- /dev/null
+++ b/packages/plugins/official/openrouter-gateway/src/index.ts
@@ -0,0 +1,587 @@
+/**
+ * OpenRouter Gateway
+ *
+ * Routes AI completions through OpenRouter's unified API, providing access
+ * to 200+ models with a single API key. Implements:
+ *
+ * - Completion requests via the OpenAI-compatible endpoint
+ * - Automatic fallback chain: on 429 / 5xx from the primary model, retries
+ * each fallback in order
+ * - Per-user per-day cost tracking in USD cents (stored in plugin DB)
+ * - Model catalogue caching with a 1-hour TTL
+ * - All costs stored as integer cents to avoid floating-point drift
+ *
+ * OpenRouter-specific headers:
+ * HTTP-Referer: siteUrl setting (for analytics dashboard)
+ * X-Title: appName setting (displayed in OpenRouter logs)
+ *
+ * @package @agentbase/plugin-openrouter-gateway
+ * @version 1.0.0
+ */
+import { createPlugin, PluginContext } from "@agentbase/plugin-sdk";
+
+// ── Constants ─────────────────────────────────────────────────────────────────
+
+export const OPENROUTER_BASE = "https://openrouter.ai/api/v1";
+export const MODELS_CACHE_KEY = "models:cache";
+/** Cache TTL for the model list (1 hour). */
+export const MODELS_CACHE_TTL_MS = 60 * 60 * 1000;
+/** Default maximum cost per request in USD cents (50¢). */
+export const DEFAULT_MAX_COST_CENTS = 50;
+
+// ── Types ─────────────────────────────────────────────────────────────────────
+
+export interface OpenRouterModel {
+ id: string;
+ name: string;
+ description?: string;
+ pricing?: {
+ prompt: string; // USD per token as string, e.g. "0.000001"
+ completion: string;
+ };
+ context_length?: number;
+ top_provider?: { max_completion_tokens?: number };
+}
+
+export interface OpenRouterChatMessage {
+ role: "system" | "user" | "assistant";
+ content: string;
+}
+
+export interface OpenRouterCompletionRequest {
+ model: string;
+ messages: OpenRouterChatMessage[];
+ temperature?: number;
+ max_tokens?: number;
+}
+
+export interface OpenRouterCompletionResponse {
+ id: string;
+ model: string;
+ choices: Array<{
+ message: { role: string; content: string };
+ finish_reason: string | null;
+ }>;
+ usage?: {
+ prompt_tokens: number;
+ completion_tokens: number;
+ total_tokens: number;
+ };
+}
+
+export interface UsageRecord {
+ model: string;
+ promptTokens: number;
+ completionTokens: number;
+ totalTokens: number;
+ /** Cost in USD cents (integer). */
+ costCents: number;
+ timestamp: number;
+}
+
+export interface DailyUsage {
+ userId: string;
+ date: string;
+ entries: UsageRecord[];
+ totalCostCents: number;
+ totalTokens: number;
+}
+
+export interface ModelsCacheRecord {
+ models: OpenRouterModel[];
+ cachedAt: number;
+}
+
+export interface GatewayConfig {
+ defaultModel: string;
+ fallbackModels: string[];
+ maxCostCents: number;
+ updatedAt: number;
+}
+
+// ── DB Key Helpers ────────────────────────────────────────────────────────────
+
+export function buildUsageKey(userId: string, date: string): string {
+ return `usage:${userId}:${date}`;
+}
+
+export function buildConfigKey(appId: string): string {
+ return `config:${appId}`;
+}
+
+/** Format a date as YYYY-MM-DD in UTC. */
+export function todayUtc(nowMs: number = Date.now()): string {
+ return new Date(nowMs).toISOString().slice(0, 10);
+}
+
+// ── Cost Calculation ──────────────────────────────────────────────────────────
+
+/**
+ * Estimate cost in USD cents from token counts and per-token pricing strings.
+ * Pricing strings from OpenRouter are USD per token (e.g. "0.000001").
+ * Returns integer cents, rounded up.
+ */
+export function estimateCostCents(
+ promptTokens: number,
+ completionTokens: number,
+ promptPricePerToken: string,
+ completionPricePerToken: string,
+): number {
+ const promptUsd = promptTokens * parseFloat(promptPricePerToken || "0");
+ const completionUsd =
+ completionTokens * parseFloat(completionPricePerToken || "0");
+ return Math.ceil((promptUsd + completionUsd) * 100);
+}
+
+// ── Model Cache ───────────────────────────────────────────────────────────────
+
+/**
+ * Fetch the OpenRouter model list. Returns cached result if valid (< 1h old).
+ */
+export async function fetchModels(
+ makeRequest: (url: string, opts?: RequestInit) => Promise,
+ apiKey: string,
+ siteUrl: string,
+ appName: string,
+ cachedRecord: ModelsCacheRecord | null,
+ nowMs: number = Date.now(),
+): Promise {
+ if (cachedRecord && nowMs - cachedRecord.cachedAt < MODELS_CACHE_TTL_MS) {
+ return cachedRecord.models;
+ }
+
+ const response = await makeRequest(`${OPENROUTER_BASE}/models`, {
+ headers: buildHeaders(apiKey, siteUrl, appName),
+ });
+
+ if (!response.ok) {
+ throw new Error(`OpenRouter models fetch failed: HTTP ${response.status}`);
+ }
+
+ const data = (await response.json()) as { data: OpenRouterModel[] };
+ return data.data ?? [];
+}
+
+// ── OpenRouter API Helpers ────────────────────────────────────────────────────
+
+export function buildHeaders(
+ apiKey: string,
+ siteUrl: string,
+ appName: string,
+): Record {
+ return {
+ Authorization: `Bearer ${apiKey}`,
+ "Content-Type": "application/json",
+ ...(siteUrl ? { "HTTP-Referer": siteUrl } : {}),
+ ...(appName ? { "X-Title": appName } : {}),
+ };
+}
+
+/**
+ * Call the OpenRouter chat completions endpoint for a single model.
+ * Throws on HTTP 4xx/5xx so the fallback chain can catch and continue.
+ */
+export async function callModel(
+ makeRequest: (url: string, opts?: RequestInit) => Promise,
+ apiKey: string,
+ siteUrl: string,
+ appName: string,
+ body: OpenRouterCompletionRequest,
+): Promise {
+ const response = await makeRequest(`${OPENROUTER_BASE}/chat/completions`, {
+ method: "POST",
+ headers: buildHeaders(apiKey, siteUrl, appName),
+ body: JSON.stringify(body),
+ });
+
+ if (!response.ok) {
+ throw new Error(
+ `OpenRouter HTTP ${response.status} for model ${body.model}`,
+ );
+ }
+
+ return (await response.json()) as OpenRouterCompletionResponse;
+}
+
+/**
+ * Attempt the primary model, then each fallback in order, stopping at the
+ * first successful response. Throws if all models fail.
+ *
+ * @param maxCostCents If > 0, abort if estimated cost exceeds this before
+ * even making the call.
+ */
+export async function callWithFallback(
+ makeRequest: (url: string, opts?: RequestInit) => Promise,
+ apiKey: string,
+ siteUrl: string,
+ appName: string,
+ messages: OpenRouterChatMessage[],
+ primaryModel: string,
+ fallbackModels: string[],
+ maxCostCents: number = 0,
+): Promise {
+ const chain = [primaryModel, ...fallbackModels].filter(Boolean);
+ let lastError: Error = new Error("No models in chain");
+
+ for (const model of chain) {
+ try {
+ const result = await callModel(makeRequest, apiKey, siteUrl, appName, {
+ model,
+ messages,
+ });
+ return result;
+ } catch (err) {
+ lastError = err as Error;
+ // Only continue to fallback on rate-limit / server errors
+ const msg = lastError.message;
+ const isRetryable =
+ msg.includes("429") ||
+ msg.includes("500") ||
+ msg.includes("502") ||
+ msg.includes("503") ||
+ msg.includes("504");
+ if (!isRetryable) throw lastError;
+ }
+ }
+
+ throw lastError;
+}
+
+// ── Plugin Definition ─────────────────────────────────────────────────────────
+
+export default createPlugin({
+ name: "openrouter-gateway",
+ version: "1.0.0",
+ description:
+ "Route all AI model calls through OpenRouter — one key for 200+ models with automatic fallbacks and cost tracking.",
+ permissions: ["network:external", "db:readwrite"],
+ settings: {
+ openRouterApiKey: {
+ type: "string",
+ label: "OpenRouter API Key",
+ encrypted: true,
+ },
+ defaultModel: {
+ type: "string",
+ label: "Default Model (e.g. openai/gpt-4o)",
+ default: "openai/gpt-4o",
+ },
+ fallbackModels: {
+ type: "string",
+ label: "Fallback Models (comma-separated)",
+ default: "openai/gpt-4o-mini,anthropic/claude-3-5-haiku",
+ },
+ maxCostPerRequest: {
+ type: "number",
+ label: "Max Cost Per Request (USD, e.g. 0.50)",
+ default: 0.5,
+ },
+ siteUrl: {
+ type: "string",
+ label: "Site URL (sent as HTTP-Referer to OpenRouter)",
+ },
+ appName: {
+ type: "string",
+ label: "App Name (sent as X-Title to OpenRouter)",
+ },
+ },
+
+ hooks: {
+ "app:init": async (context) => {
+ const { api } = context;
+
+ // ── GET /models ─────────────────────────────────────────────────────────
+ api.registerEndpoint({
+ method: "GET",
+ path: "/models",
+ auth: true,
+ description: "List available OpenRouter models with pricing",
+ handler: async (_req, res) => {
+ const apiKey = (api.getConfig("openRouterApiKey") as string) ?? "";
+ if (!apiKey) {
+ res
+ .status(400)
+ .json({ error: "OpenRouter API key not configured" });
+ return;
+ }
+
+ const siteUrl = (api.getConfig("siteUrl") as string) ?? "";
+ const appName = (api.getConfig("appName") as string) ?? "";
+ const cached = (await api.db.get(
+ MODELS_CACHE_KEY,
+ )) as ModelsCacheRecord | null;
+
+ try {
+ const models = await fetchModels(
+ api.makeRequest,
+ apiKey,
+ siteUrl,
+ appName,
+ cached,
+ );
+ // Refresh cache if stale
+ if (
+ !cached ||
+ Date.now() - cached.cachedAt >= MODELS_CACHE_TTL_MS
+ ) {
+ await api.db.set(MODELS_CACHE_KEY, {
+ models,
+ cachedAt: Date.now(),
+ } satisfies ModelsCacheRecord);
+ }
+ res.status(200).json({ models });
+ } catch (err) {
+ res.status(502).json({ error: (err as Error).message });
+ }
+ },
+ });
+
+ // ── GET /usage ──────────────────────────────────────────────────────────
+ api.registerEndpoint({
+ method: "GET",
+ path: "/usage",
+ auth: true,
+ description: "Cost and token breakdown by user and date",
+ handler: async (req, res) => {
+ const userId = req.query?.["userId"] as string | undefined;
+ const date =
+ (req.query?.["date"] as string | undefined) ?? todayUtc();
+ const limitParam = req.query?.["limit"] as string | undefined;
+ const limit = Math.min(parseInt(limitParam ?? "30", 10) || 30, 90);
+
+ if (userId) {
+ const record = (await api.db.get(
+ buildUsageKey(userId, date),
+ )) as DailyUsage | null;
+ res.status(200).json({ usage: record ?? null });
+ return;
+ }
+
+ // Aggregate app-wide: collect all usage keys for the date
+ const keys = await api.db.keys("usage:");
+ const dateKeys = keys.filter((k) => k.endsWith(`:${date}`));
+ const records = (
+ await Promise.all(dateKeys.map((k) => api.db.get(k)))
+ ).filter((r): r is DailyUsage => r !== null);
+
+ records.sort((a, b) => b.totalCostCents - a.totalCostCents);
+ res.status(200).json({ usage: records.slice(0, limit), date });
+ },
+ });
+
+ // ── PUT /config ─────────────────────────────────────────────────────────
+ api.registerEndpoint({
+ method: "PUT",
+ path: "/config",
+ auth: true,
+ description: "Update default model and fallback chain",
+ handler: async (req, res) => {
+ const { defaultModel, fallbackModels, maxCostPerRequest } =
+ req.body as {
+ defaultModel?: string;
+ fallbackModels?: string;
+ maxCostPerRequest?: number;
+ };
+
+ if (
+ !defaultModel &&
+ fallbackModels === undefined &&
+ maxCostPerRequest === undefined
+ ) {
+ res.status(400).json({ error: "No fields to update" });
+ return;
+ }
+
+ const existing = ((await api.db.get(buildConfigKey(context.appId))) ??
+ {}) as Partial;
+
+ const updated: GatewayConfig = {
+ defaultModel:
+ defaultModel ??
+ existing.defaultModel ??
+ ((api.getConfig("defaultModel") as string) || "openai/gpt-4o"),
+ fallbackModels: fallbackModels
+ ? fallbackModels
+ .split(",")
+ .map((s) => s.trim())
+ .filter(Boolean)
+ : (existing.fallbackModels ?? []),
+ maxCostCents:
+ maxCostPerRequest !== undefined
+ ? Math.round(maxCostPerRequest * 100)
+ : (existing.maxCostCents ?? DEFAULT_MAX_COST_CENTS),
+ updatedAt: Date.now(),
+ };
+
+ await api.db.set(buildConfigKey(context.appId), updated);
+ res.status(200).json({ config: updated });
+ },
+ });
+
+ // ── GET /config ─────────────────────────────────────────────────────────
+ api.registerEndpoint({
+ method: "GET",
+ path: "/config",
+ auth: true,
+ description: "Get current gateway configuration",
+ handler: async (_req, res) => {
+ const stored = (await api.db.get(
+ buildConfigKey(context.appId),
+ )) as GatewayConfig | null;
+
+ const config: GatewayConfig = stored ?? {
+ defaultModel:
+ (api.getConfig("defaultModel") as string) || "openai/gpt-4o",
+ fallbackModels: ((api.getConfig("fallbackModels") as string) || "")
+ .split(",")
+ .map((s) => s.trim())
+ .filter(Boolean),
+ maxCostCents: Math.round(
+ ((api.getConfig("maxCostPerRequest") as number) ?? 0.5) * 100,
+ ),
+ updatedAt: 0,
+ };
+
+ res.status(200).json({ config });
+ },
+ });
+
+ // ── POST /test ──────────────────────────────────────────────────────────
+ api.registerEndpoint({
+ method: "POST",
+ path: "/test",
+ auth: true,
+ description: "Send a test prompt to verify API key and model",
+ handler: async (req, res) => {
+ const apiKey = (api.getConfig("openRouterApiKey") as string) ?? "";
+ if (!apiKey) {
+ res
+ .status(400)
+ .json({ error: "OpenRouter API key not configured" });
+ return;
+ }
+
+ const { prompt = "Say 'OK' in one word.", model } = req.body as {
+ prompt?: string;
+ model?: string;
+ };
+
+ const targetModel =
+ model ??
+ (api.getConfig("defaultModel") as string) ??
+ "openai/gpt-4o-mini";
+ const siteUrl = (api.getConfig("siteUrl") as string) ?? "";
+ const appName = (api.getConfig("appName") as string) ?? "";
+
+ try {
+ const result = await callModel(
+ api.makeRequest,
+ apiKey,
+ siteUrl,
+ appName,
+ {
+ model: targetModel,
+ messages: [{ role: "user", content: prompt }],
+ },
+ );
+ res.status(200).json({
+ ok: true,
+ model: result.model,
+ reply: result.choices[0]?.message.content ?? "",
+ usage: result.usage,
+ });
+ } catch (err) {
+ res.status(502).json({ ok: false, error: (err as Error).message });
+ }
+ },
+ });
+ },
+
+ // ── conversation:beforeMessage ────────────────────────────────────────────
+ "conversation:beforeMessage": async (context) => {
+ const { api } = context;
+ const apiKey = (api.getConfig("openRouterApiKey") as string) ?? "";
+ if (!apiKey) return; // Not configured — let default AI handle it
+
+ const stored = (await api.db.get(
+ buildConfigKey(context.appId),
+ )) as GatewayConfig | null;
+
+ const primaryModel =
+ stored?.defaultModel ??
+ (api.getConfig("defaultModel") as string) ??
+ "openai/gpt-4o";
+ const fallbackModels =
+ stored?.fallbackModels ??
+ ((api.getConfig("fallbackModels") as string) ?? "")
+ .split(",")
+ .map((s) => s.trim())
+ .filter(Boolean);
+
+ const message = (context as unknown as Record)[
+ "message"
+ ] as string | undefined;
+ if (!message) return;
+
+ const siteUrl = (api.getConfig("siteUrl") as string) ?? "";
+ const appName = (api.getConfig("appName") as string) ?? "";
+
+ try {
+ const result = await callWithFallback(
+ api.makeRequest,
+ apiKey,
+ siteUrl,
+ appName,
+ [{ role: "user", content: message }],
+ primaryModel,
+ fallbackModels,
+ );
+
+ // Track usage
+ const usage = result.usage;
+ if (usage && context.userId) {
+ const date = todayUtc();
+ const usageKey = buildUsageKey(context.userId, date);
+ const existing = ((await api.db.get(usageKey)) ?? {
+ userId: context.userId,
+ date,
+ entries: [],
+ totalCostCents: 0,
+ totalTokens: 0,
+ }) as DailyUsage;
+
+ // Find pricing from cache if available
+ const modelsCache = (await api.db.get(
+ MODELS_CACHE_KEY,
+ )) as ModelsCacheRecord | null;
+ const modelInfo = modelsCache?.models.find(
+ (m) => m.id === result.model,
+ );
+ const costCents = modelInfo?.pricing
+ ? estimateCostCents(
+ usage.prompt_tokens,
+ usage.completion_tokens,
+ modelInfo.pricing.prompt,
+ modelInfo.pricing.completion,
+ )
+ : 0;
+
+ existing.entries.push({
+ model: result.model,
+ promptTokens: usage.prompt_tokens,
+ completionTokens: usage.completion_tokens,
+ totalTokens: usage.total_tokens,
+ costCents,
+ timestamp: Date.now(),
+ });
+ existing.totalCostCents += costCents;
+ existing.totalTokens += usage.total_tokens;
+
+ await api.db.set(usageKey, existing);
+ }
+ } catch (err) {
+ api.log(`OpenRouter gateway error: ${(err as Error).message}`, "error");
+ }
+ },
+ },
+});
diff --git a/packages/plugins/official/openrouter-gateway/tsconfig.json b/packages/plugins/official/openrouter-gateway/tsconfig.json
new file mode 100644
index 0000000..5628f37
--- /dev/null
+++ b/packages/plugins/official/openrouter-gateway/tsconfig.json
@@ -0,0 +1,24 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "module": "commonjs",
+ "lib": ["ES2022", "DOM"],
+ "outDir": "./dist",
+ "rootDir": "../..",
+ "baseUrl": ".",
+ "declaration": true,
+ "declarationMap": true,
+ "sourceMap": true,
+ "strict": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "noEmit": true,
+ "ignoreDeprecations": "6.0",
+ "types": ["node"],
+ "paths": {
+ "@agentbase/plugin-sdk": ["../../sdk/src/index.ts"]
+ }
+ },
+ "include": ["src/**/*"],
+ "exclude": ["node_modules", "dist", "__tests__"]
+}
diff --git a/packages/plugins/official/openrouter-gateway/tsconfig.test.json b/packages/plugins/official/openrouter-gateway/tsconfig.test.json
new file mode 100644
index 0000000..4a1b61e
--- /dev/null
+++ b/packages/plugins/official/openrouter-gateway/tsconfig.test.json
@@ -0,0 +1,13 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "rootDir": "..",
+ "outDir": "dist-test",
+ "baseUrl": ".",
+ "paths": {
+ "@agentbase/plugin-sdk": ["../../../sdk/src/index.ts"]
+ }
+ },
+ "include": ["src/**/*", "__tests__/**/*"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 6360fc3..546e128 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -269,7 +269,7 @@ importers:
version: 10.4.24(postcss@8.5.6)
jest:
specifier: ^29.7.0
- version: 29.7.0(@types/node@22.19.11)
+ version: 29.7.0(@types/node@22.19.11)(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3))
jest-environment-jsdom:
specifier: ^30.2.0
version: 30.2.0
@@ -284,7 +284,7 @@ importers:
version: 1.0.7(tailwindcss@3.4.19)
ts-jest:
specifier: ^29.4.6
- version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@22.19.11))(typescript@5.9.3)
+ version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@22.19.11)(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)))(typescript@5.9.3)
typescript:
specifier: ^5.7.0
version: 5.9.3
@@ -373,10 +373,10 @@ importers:
version: 29.5.14
jest:
specifier: ^29.7.0
- version: 29.7.0
+ version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3))
ts-jest:
specifier: ^29.2.0
- version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0)(typescript@5.9.3)
+ version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)))(typescript@5.9.3)
typescript:
specifier: ^5.7.0
version: 5.9.3
@@ -392,10 +392,10 @@ importers:
version: 29.5.14
jest:
specifier: ^29.7.0
- version: 29.7.0
+ version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3))
ts-jest:
specifier: ^29.2.0
- version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0)(typescript@5.9.3)
+ version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)))(typescript@5.9.3)
typescript:
specifier: ^5.7.0
version: 5.9.3
@@ -414,10 +414,10 @@ importers:
version: 25.5.2
jest:
specifier: ^29.7.0
- version: 29.7.0(@types/node@25.5.2)
+ version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3))
ts-jest:
specifier: ^29.2.0
- version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2))(typescript@5.9.3)
+ version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)))(typescript@5.9.3)
typescript:
specifier: ^5.7.0
version: 5.9.3
@@ -436,14 +436,36 @@ importers:
version: 25.5.2
jest:
specifier: ^29.7.0
- version: 29.7.0(@types/node@25.5.2)
+ version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3))
ts-jest:
specifier: ^29.2.0
- version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2))(typescript@5.9.3)
+ version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)))(typescript@5.9.3)
typescript:
specifier: ^5.7.0
version: 5.9.3
+ packages/plugins/official/openrouter-gateway:
+ dependencies:
+ '@agentbase/plugin-sdk':
+ specifier: workspace:*
+ version: link:../..
+ devDependencies:
+ '@types/jest':
+ specifier: ^29.5.0
+ version: 29.5.14
+ '@types/node':
+ specifier: ^20.0.0
+ version: 20.19.33
+ jest:
+ specifier: ^29.5.0
+ version: 29.7.0(@types/node@20.19.33)(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3))
+ ts-jest:
+ specifier: ^29.1.0
+ version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@20.19.33)(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3)))(typescript@5.9.3)
+ typescript:
+ specifier: ^5.0.0
+ version: 5.9.3
+
packages/plugins/official/slack-connector:
dependencies:
'@agentbase/plugin-sdk':
@@ -458,10 +480,10 @@ importers:
version: 25.5.2
jest:
specifier: ^29.7.0
- version: 29.7.0(@types/node@25.5.2)
+ version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3))
ts-jest:
specifier: ^29.2.0
- version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2))(typescript@5.9.3)
+ version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)))(typescript@5.9.3)
typescript:
specifier: ^5.7.0
version: 5.9.3
@@ -480,10 +502,10 @@ importers:
version: 25.5.2
jest:
specifier: ^29.7.0
- version: 29.7.0(@types/node@25.5.2)
+ version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3))
ts-jest:
specifier: ^29.2.0
- version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2))(typescript@5.9.3)
+ version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)))(typescript@5.9.3)
typescript:
specifier: ^5.7.0
version: 5.9.3
@@ -499,10 +521,10 @@ importers:
version: 29.5.14
jest:
specifier: ^29.7.0
- version: 29.7.0
+ version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3))
ts-jest:
specifier: ^29.2.0
- version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0)(typescript@5.9.3)
+ version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)))(typescript@5.9.3)
typescript:
specifier: ^5.7.0
version: 5.9.3
@@ -520,10 +542,10 @@ importers:
version: 29.5.14
jest:
specifier: ^29.7.0
- version: 29.7.0
+ version: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3))
ts-jest:
specifier: ^29.2.0
- version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0)(typescript@5.9.3)
+ version: 29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)))(typescript@5.9.3)
typescript:
specifier: ^5.3.0
version: 5.9.3
@@ -6342,6 +6364,41 @@ snapshots:
jest-util: 29.7.0
slash: 3.0.0
+ '@jest/core@29.7.0(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3))':
+ dependencies:
+ '@jest/console': 29.7.0
+ '@jest/reporters': 29.7.0
+ '@jest/test-result': 29.7.0
+ '@jest/transform': 29.7.0
+ '@jest/types': 29.6.3
+ '@types/node': 22.19.11
+ ansi-escapes: 4.3.2
+ chalk: 4.1.2
+ ci-info: 3.9.0
+ exit: 0.1.2
+ graceful-fs: 4.2.11
+ jest-changed-files: 29.7.0
+ jest-config: 29.7.0(@types/node@22.19.11)(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3))
+ jest-haste-map: 29.7.0
+ jest-message-util: 29.7.0
+ jest-regex-util: 29.6.3
+ jest-resolve: 29.7.0
+ jest-resolve-dependencies: 29.7.0
+ jest-runner: 29.7.0
+ jest-runtime: 29.7.0
+ jest-snapshot: 29.7.0
+ jest-util: 29.7.0
+ jest-validate: 29.7.0
+ jest-watcher: 29.7.0
+ micromatch: 4.0.8
+ pretty-format: 29.7.0
+ slash: 3.0.0
+ strip-ansi: 6.0.1
+ transitivePeerDependencies:
+ - babel-plugin-macros
+ - supports-color
+ - ts-node
+
'@jest/core@29.7.0(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3))':
dependencies:
'@jest/console': 29.7.0
@@ -8336,6 +8393,21 @@ snapshots:
optionalDependencies:
typescript: 5.7.2
+ create-jest@29.7.0(@types/node@20.19.33)(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3)):
+ dependencies:
+ '@jest/types': 29.6.3
+ chalk: 4.1.2
+ exit: 0.1.2
+ graceful-fs: 4.2.11
+ jest-config: 29.7.0(@types/node@20.19.33)(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3))
+ jest-util: 29.7.0
+ prompts: 2.4.2
+ transitivePeerDependencies:
+ - '@types/node'
+ - babel-plugin-macros
+ - supports-color
+ - ts-node
+
create-jest@29.7.0(@types/node@22.19.11)(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)):
dependencies:
'@jest/types': 29.6.3
@@ -9543,16 +9615,16 @@ snapshots:
- babel-plugin-macros
- supports-color
- jest-cli@29.7.0:
+ jest-cli@29.7.0(@types/node@20.19.33)(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3)):
dependencies:
- '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3))
+ '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3))
'@jest/test-result': 29.7.0
'@jest/types': 29.6.3
chalk: 4.1.2
- create-jest: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3))
+ create-jest: 29.7.0(@types/node@20.19.33)(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3))
exit: 0.1.2
import-local: 3.2.0
- jest-config: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3))
+ jest-config: 29.7.0(@types/node@20.19.33)(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3))
jest-util: 29.7.0
jest-validate: 29.7.0
yargs: 17.7.2
@@ -9562,7 +9634,7 @@ snapshots:
- supports-color
- ts-node
- jest-cli@29.7.0(@types/node@22.19.11):
+ jest-cli@29.7.0(@types/node@22.19.11)(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)):
dependencies:
'@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3))
'@jest/test-result': 29.7.0
@@ -9581,16 +9653,16 @@ snapshots:
- supports-color
- ts-node
- jest-cli@29.7.0(@types/node@22.19.11)(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)):
+ jest-cli@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)):
dependencies:
- '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3))
+ '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3))
'@jest/test-result': 29.7.0
'@jest/types': 29.6.3
chalk: 4.1.2
- create-jest: 29.7.0(@types/node@22.19.11)(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3))
+ create-jest: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3))
exit: 0.1.2
import-local: 3.2.0
- jest-config: 29.7.0(@types/node@22.19.11)(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3))
+ jest-config: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3))
jest-util: 29.7.0
jest-validate: 29.7.0
yargs: 17.7.2
@@ -9600,43 +9672,67 @@ snapshots:
- supports-color
- ts-node
- jest-cli@29.7.0(@types/node@25.5.2):
+ jest-config@29.7.0(@types/node@20.19.33)(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3)):
dependencies:
- '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3))
- '@jest/test-result': 29.7.0
+ '@babel/core': 7.29.0
+ '@jest/test-sequencer': 29.7.0
'@jest/types': 29.6.3
+ babel-jest: 29.7.0(@babel/core@7.29.0)
chalk: 4.1.2
- create-jest: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3))
- exit: 0.1.2
- import-local: 3.2.0
- jest-config: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3))
+ ci-info: 3.9.0
+ deepmerge: 4.3.1
+ glob: 7.2.3
+ graceful-fs: 4.2.11
+ jest-circus: 29.7.0
+ jest-environment-node: 29.7.0
+ jest-get-type: 29.6.3
+ jest-regex-util: 29.6.3
+ jest-resolve: 29.7.0
+ jest-runner: 29.7.0
jest-util: 29.7.0
jest-validate: 29.7.0
- yargs: 17.7.2
+ micromatch: 4.0.8
+ parse-json: 5.2.0
+ pretty-format: 29.7.0
+ slash: 3.0.0
+ strip-json-comments: 3.1.1
+ optionalDependencies:
+ '@types/node': 20.19.33
+ ts-node: 10.9.2(@types/node@20.19.33)(typescript@5.9.3)
transitivePeerDependencies:
- - '@types/node'
- babel-plugin-macros
- supports-color
- - ts-node
- jest-cli@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)):
+ jest-config@29.7.0(@types/node@22.19.11)(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3)):
dependencies:
- '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3))
- '@jest/test-result': 29.7.0
+ '@babel/core': 7.29.0
+ '@jest/test-sequencer': 29.7.0
'@jest/types': 29.6.3
+ babel-jest: 29.7.0(@babel/core@7.29.0)
chalk: 4.1.2
- create-jest: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3))
- exit: 0.1.2
- import-local: 3.2.0
- jest-config: 29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3))
+ ci-info: 3.9.0
+ deepmerge: 4.3.1
+ glob: 7.2.3
+ graceful-fs: 4.2.11
+ jest-circus: 29.7.0
+ jest-environment-node: 29.7.0
+ jest-get-type: 29.6.3
+ jest-regex-util: 29.6.3
+ jest-resolve: 29.7.0
+ jest-runner: 29.7.0
jest-util: 29.7.0
jest-validate: 29.7.0
- yargs: 17.7.2
+ micromatch: 4.0.8
+ parse-json: 5.2.0
+ pretty-format: 29.7.0
+ slash: 3.0.0
+ strip-json-comments: 3.1.1
+ optionalDependencies:
+ '@types/node': 22.19.11
+ ts-node: 10.9.2(@types/node@20.19.33)(typescript@5.9.3)
transitivePeerDependencies:
- - '@types/node'
- babel-plugin-macros
- supports-color
- - ts-node
jest-config@29.7.0(@types/node@22.19.11)(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)):
dependencies:
@@ -10034,24 +10130,12 @@ snapshots:
merge-stream: 2.0.0
supports-color: 8.1.1
- jest@29.7.0:
+ jest@29.7.0(@types/node@20.19.33)(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3)):
dependencies:
- '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3))
+ '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3))
'@jest/types': 29.6.3
import-local: 3.2.0
- jest-cli: 29.7.0
- transitivePeerDependencies:
- - '@types/node'
- - babel-plugin-macros
- - supports-color
- - ts-node
-
- jest@29.7.0(@types/node@22.19.11):
- dependencies:
- '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3))
- '@jest/types': 29.6.3
- import-local: 3.2.0
- jest-cli: 29.7.0(@types/node@22.19.11)
+ jest-cli: 29.7.0(@types/node@20.19.33)(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3))
transitivePeerDependencies:
- '@types/node'
- babel-plugin-macros
@@ -10070,18 +10154,6 @@ snapshots:
- supports-color
- ts-node
- jest@29.7.0(@types/node@25.5.2):
- dependencies:
- '@jest/core': 29.7.0(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3))
- '@jest/types': 29.6.3
- import-local: 3.2.0
- jest-cli: 29.7.0(@types/node@25.5.2)
- transitivePeerDependencies:
- - '@types/node'
- - babel-plugin-macros
- - supports-color
- - ts-node
-
jest@29.7.0(@types/node@25.5.2)(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3)):
dependencies:
'@jest/core': 29.7.0(ts-node@10.9.2(@types/node@25.5.2)(typescript@5.9.3))
@@ -12159,12 +12231,12 @@ snapshots:
ts-interface-checker@0.1.13: {}
- ts-jest@29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@22.19.11)(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)))(typescript@5.9.3):
+ ts-jest@29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@20.19.33)(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3)))(typescript@5.9.3):
dependencies:
bs-logger: 0.2.6
fast-json-stable-stringify: 2.1.0
handlebars: 4.7.8
- jest: 29.7.0(@types/node@22.19.11)(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3))
+ jest: 29.7.0(@types/node@20.19.33)(ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3))
json5: 2.2.3
lodash.memoize: 4.1.2
make-error: 1.3.6
@@ -12179,12 +12251,12 @@ snapshots:
babel-jest: 29.7.0(@babel/core@7.29.0)
jest-util: 30.3.0
- ts-jest@29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@22.19.11))(typescript@5.9.3):
+ ts-jest@29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@22.19.11)(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3)))(typescript@5.9.3):
dependencies:
bs-logger: 0.2.6
fast-json-stable-stringify: 2.1.0
handlebars: 4.7.8
- jest: 29.7.0(@types/node@22.19.11)
+ jest: 29.7.0(@types/node@22.19.11)(ts-node@10.9.2(@types/node@22.19.11)(typescript@5.9.3))
json5: 2.2.3
lodash.memoize: 4.1.2
make-error: 1.3.6
@@ -12219,46 +12291,6 @@ snapshots:
babel-jest: 29.7.0(@babel/core@7.29.0)
jest-util: 30.3.0
- ts-jest@29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@25.5.2))(typescript@5.9.3):
- dependencies:
- bs-logger: 0.2.6
- fast-json-stable-stringify: 2.1.0
- handlebars: 4.7.8
- jest: 29.7.0(@types/node@25.5.2)
- json5: 2.2.3
- lodash.memoize: 4.1.2
- make-error: 1.3.6
- semver: 7.7.4
- type-fest: 4.41.0
- typescript: 5.9.3
- yargs-parser: 21.1.1
- optionalDependencies:
- '@babel/core': 7.29.0
- '@jest/transform': 29.7.0
- '@jest/types': 30.3.0
- babel-jest: 29.7.0(@babel/core@7.29.0)
- jest-util: 30.3.0
-
- ts-jest@29.4.6(@babel/core@7.29.0)(@jest/transform@29.7.0)(@jest/types@30.3.0)(babel-jest@29.7.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0)(typescript@5.9.3):
- dependencies:
- bs-logger: 0.2.6
- fast-json-stable-stringify: 2.1.0
- handlebars: 4.7.8
- jest: 29.7.0
- json5: 2.2.3
- lodash.memoize: 4.1.2
- make-error: 1.3.6
- semver: 7.7.4
- type-fest: 4.41.0
- typescript: 5.9.3
- yargs-parser: 21.1.1
- optionalDependencies:
- '@babel/core': 7.29.0
- '@jest/transform': 29.7.0
- '@jest/types': 30.3.0
- babel-jest: 29.7.0(@babel/core@7.29.0)
- jest-util: 30.3.0
-
ts-node@10.9.2(@types/node@20.19.33)(typescript@5.9.3):
dependencies:
'@cspotcode/source-map-support': 0.8.1