From 36a92179dedffb1ce0bf8db69865917ba4672c9c Mon Sep 17 00:00:00 2001 From: DeWitt Gibson Date: Mon, 6 Apr 2026 14:56:47 -0700 Subject: [PATCH 1/2] Add openrouter-gateway plugin and tests Introduce a new @agentbase/plugin-openrouter-gateway plugin that routes AI model calls through OpenRouter. Implements model fetching with 1h cache, callWithFallback (automatic fallbacks on 429/5xx), per-user per-day cost tracking (cents), header helpers, and conversation/app hooks. Registers endpoints (GET /models, GET /usage, PUT /config, GET /config, POST /test) and includes manifest, package/tsconfig files, and comprehensive Jest tests for helpers, endpoints, hooks, and constants. --- .../__tests__/index.test.ts | 1261 +++++++++++++++++ .../__tests__/tsconfig.json | 14 + .../official/openrouter-gateway/manifest.json | 57 + .../official/openrouter-gateway/package.json | 35 + .../official/openrouter-gateway/src/index.ts | 587 ++++++++ .../official/openrouter-gateway/tsconfig.json | 23 + .../openrouter-gateway/tsconfig.test.json | 13 + pnpm-lock.yaml | 268 ++-- 8 files changed, 2140 insertions(+), 118 deletions(-) create mode 100644 packages/plugins/official/openrouter-gateway/__tests__/index.test.ts create mode 100644 packages/plugins/official/openrouter-gateway/__tests__/tsconfig.json create mode 100644 packages/plugins/official/openrouter-gateway/manifest.json create mode 100644 packages/plugins/official/openrouter-gateway/package.json create mode 100644 packages/plugins/official/openrouter-gateway/src/index.ts create mode 100644 packages/plugins/official/openrouter-gateway/tsconfig.json create mode 100644 packages/plugins/official/openrouter-gateway/tsconfig.test.json 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..de7aa63 --- /dev/null +++ b/packages/plugins/official/openrouter-gateway/tsconfig.json @@ -0,0 +1,23 @@ +{ + "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, + "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 From 2b44bd1aaf6c5abe2c7fbbb4dcd4fa64302fedba Mon Sep 17 00:00:00 2001 From: DeWitt Gibson Date: Mon, 6 Apr 2026 14:57:46 -0700 Subject: [PATCH 2/2] Ignore deprecations in OpenRouter tsconfig Add "ignoreDeprecations": "6.0" to the OpenRouter plugin tsconfig to suppress TypeScript 6.0 deprecation diagnostics during type checking. This reduces noise from deprecation warnings while keeping existing compiler options intact. --- packages/plugins/official/openrouter-gateway/tsconfig.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/plugins/official/openrouter-gateway/tsconfig.json b/packages/plugins/official/openrouter-gateway/tsconfig.json index de7aa63..5628f37 100644 --- a/packages/plugins/official/openrouter-gateway/tsconfig.json +++ b/packages/plugins/official/openrouter-gateway/tsconfig.json @@ -13,6 +13,7 @@ "esModuleInterop": true, "skipLibCheck": true, "noEmit": true, + "ignoreDeprecations": "6.0", "types": ["node"], "paths": { "@agentbase/plugin-sdk": ["../../sdk/src/index.ts"]