## Features - **Providers**: creating a compatible / custom-embedding node now registers the endpoint only — the API key is added afterwards from the node's page, like the built-in providers. The create dialogs drop the API Key / Model ID / Check fields, and `POST /api/provider-nodes` no longer accepts credentials at all, so a node can never be half-created - **Providers**: compatible nodes now use the same model rows as built-in providers — capability badges, copy, per-model test, alias handling and the Add/Edit Model modal with vision + reasoning toggles, replacing the weaker read-only list - **Providers**: compatible nodes get the built-in bulk toolbars: Test All / Disable / Active / Select All over connections, and Test All Models / Disable All / Active All over models, with per-row disable and a restore strip for disabled models - **Providers**: wire the dead "Fetch Models" button on compatible nodes to the live upstream catalog, de-duplicating against already-added models ## Fixes - **Models**: persist per-model capability assertions for custom and compatible providers and honor them everywhere — unsupported media is stripped on the chat path, `/v1/models` and `/api/models` report what the user asserted, and thinking translation follows it (asserting `reasoning:false` now actually strips thinking fields, `reasoning:true` emits them) - **Models**: partial capability edits merge instead of overwriting, so toggling vision off no longer erases a stored reasoning assertion - **Capabilities**: keep server-injected readers (synced catalog, user-asserted capabilities) in process-wide state — Next.js compiles startup and each API route into separate bundles with their own module instances, so a boot-time install was invisible to every request handler and the models.dev catalog contributed nothing to upstream requests since0532f00d- **Dashboard**: thinking-level picker and model-row suffix reflect user-asserted reasoning on compatible nodes - **Providers**: `Default Model` is optional when adding an API key to a compatible node — the node's own model list (and the picker in the test modals) already determine what gets probed, and the built-in fallback still covers connection checks - **Providers**: restore the `useCopyToClipboard` import dropped from the provider detail page, which crashed the route with `ReferenceError` for every provider - **DB**: restore `getModelAliases` / `setModelAlias` / `deleteModelAlias` re-exports dropped from the `localDb` shim by86112cee, which broke `GET /api/models` and `GET /v1/models` at import time - **Providers**: remove dead `PassthroughModelsSection` (never passed props, superseded by the shared model rows) - **Media Providers**: creating a custom embedding node reports that a key still has to be added, instead of claiming a key was saved; the edit dialog keeps its API Key + Check affordance since a stored key already exists there - **Build**: self-host Inter instead of fetching it through `next/font/google` at build time — a Docker / mirrored builder with no route to `fonts.googleapis.com` failed the entire image build on `Failed to fetch 'Inter' from Google Fonts`. The seven `@font-face` rules and their `unicode-range`s copy what `next/font` emitted (a `latin`-only file would have dropped Vietnamese diacritics) and the latin subset is preloaded as before, so rendered metrics are unchanged
206 lines
7.2 KiB
JavaScript
206 lines
7.2 KiB
JavaScript
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { describe, it, expect, afterEach, vi } from "vitest";
|
|
|
|
// POST /api/provider-nodes registers a node only — API keys are added later
|
|
// from the node page through POST /api/providers. The node row it creates MUST
|
|
// carry the same providerSpecificData the connection created later will copy,
|
|
// otherwise a key added after the fact routes differently from the old
|
|
// one-call flow.
|
|
|
|
const originalDataDir = process.env.DATA_DIR;
|
|
let tempDir = null;
|
|
|
|
function resetDataDir(prefix) {
|
|
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
|
process.env.DATA_DIR = tempDir;
|
|
// src/lib/db/driver.js caches the opened adapter on `global` to survive Next
|
|
// hot-reload; without this every test would share the first temp DB.
|
|
global._dbAdapter = { instance: null, initPromise: null, logged: false };
|
|
vi.resetModules();
|
|
vi.doMock("next/server", () => ({
|
|
NextResponse: {
|
|
json(body, init = {}) {
|
|
return new Response(JSON.stringify(body), {
|
|
status: init.status || 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
},
|
|
},
|
|
}));
|
|
}
|
|
|
|
afterEach(() => {
|
|
// Drop the cached adapter BEFORE deleting its file: driver.js keeps the
|
|
// opened handle on `global`, so any later file in this worker would otherwise
|
|
// write into a removed database.
|
|
global._dbAdapter = { instance: null, initPromise: null, logged: false };
|
|
if (tempDir) fs.rmSync(tempDir, { recursive: true, force: true });
|
|
tempDir = null;
|
|
if (originalDataDir === undefined) delete process.env.DATA_DIR;
|
|
else process.env.DATA_DIR = originalDataDir;
|
|
});
|
|
|
|
function post(url, body) {
|
|
return new Request(url, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
async function loadRoutes() {
|
|
const nodes = await import("@/app/api/provider-nodes/route.js");
|
|
const providers = await import("@/app/api/providers/route.js");
|
|
const models = await import("@/models/index.js");
|
|
return { nodes, providers, models };
|
|
}
|
|
|
|
describe("provider-nodes POST creates a node without a key", () => {
|
|
it("creates an openai-compatible node and stores no connection", async () => {
|
|
resetDataDir("9router-node-create-");
|
|
const { nodes, models } = await loadRoutes();
|
|
|
|
const res = await nodes.POST(post("https://9router.local/api/provider-nodes", {
|
|
type: "openai-compatible",
|
|
apiType: "responses",
|
|
name: "My Relay",
|
|
prefix: "relay",
|
|
baseUrl: "https://relay.example.com/v1",
|
|
}));
|
|
expect(res.status).toBe(201);
|
|
const created = await res.json();
|
|
|
|
expect(created.node?.id).toContain("openai-compatible-");
|
|
expect(created.node.name).toBe("My Relay");
|
|
expect(created.node.baseUrl).toBe("https://relay.example.com/v1");
|
|
expect(created.node.prefix).toBe("relay");
|
|
expect(created.node.apiType).toBe("responses");
|
|
// Creating a node must never fabricate a connection.
|
|
expect(created.connection).toBeUndefined();
|
|
expect(created.connectionError).toBeUndefined();
|
|
expect(await models.getProviderConnections()).toEqual([]);
|
|
});
|
|
|
|
it("ignores a stray apiKey instead of opening a connection", async () => {
|
|
resetDataDir("9router-node-create-stray-key-");
|
|
const { nodes, models } = await loadRoutes();
|
|
|
|
const res = await nodes.POST(post("https://9router.local/api/provider-nodes", {
|
|
type: "openai-compatible",
|
|
apiType: "chat",
|
|
name: "Legacy Client",
|
|
prefix: "lc",
|
|
baseUrl: "https://lc.example.com/v1",
|
|
apiKey: "sk-should-be-dropped",
|
|
defaultModel: "gpt-5.1",
|
|
testStatus: "active",
|
|
}));
|
|
|
|
expect(res.status).toBe(201);
|
|
const body = await res.json();
|
|
expect(body.connection).toBeUndefined();
|
|
expect(JSON.stringify(body)).not.toContain("sk-should-be-dropped");
|
|
expect(await models.getProviderConnections()).toEqual([]);
|
|
});
|
|
|
|
it("strips /messages for anthropic-compatible and omits apiType", async () => {
|
|
resetDataDir("9router-node-create-anthropic-");
|
|
const { nodes } = await loadRoutes();
|
|
|
|
const body = await (
|
|
await nodes.POST(post("https://9router.local/api/provider-nodes", {
|
|
type: "anthropic-compatible",
|
|
name: "Claude Proxy",
|
|
prefix: "cpx",
|
|
baseUrl: "https://proxy.example.com/v1/messages",
|
|
}))
|
|
).json();
|
|
|
|
expect(body.node.baseUrl).toBe("https://proxy.example.com/v1");
|
|
expect(body.node.providerSpecificData).toBeUndefined();
|
|
expect(body.node).toMatchObject({
|
|
type: "anthropic-compatible",
|
|
prefix: "cpx",
|
|
baseUrl: "https://proxy.example.com/v1",
|
|
name: "Claude Proxy",
|
|
});
|
|
});
|
|
|
|
it("creates a custom-embedding node", async () => {
|
|
resetDataDir("9router-node-create-embedding-");
|
|
const { nodes, models } = await loadRoutes();
|
|
|
|
const res = await nodes.POST(post("https://9router.local/api/provider-nodes", {
|
|
type: "custom-embedding",
|
|
name: "Voyage",
|
|
prefix: "voyage",
|
|
baseUrl: "https://api.voyageai.com/v1",
|
|
}));
|
|
|
|
expect(res.status).toBe(201);
|
|
const body = await res.json();
|
|
expect(body.node.id).toContain("custom-embedding-");
|
|
expect(await models.getProviderConnections()).toEqual([]);
|
|
});
|
|
|
|
it("rejects a bad proxy before creating anything", async () => {
|
|
resetDataDir("9router-node-proxy-guard-");
|
|
const { nodes, models } = await loadRoutes();
|
|
|
|
const res = await nodes.POST(post("https://9router.local/api/provider-nodes", {
|
|
type: "openai-compatible",
|
|
apiType: "chat",
|
|
name: "Nope",
|
|
prefix: "np",
|
|
baseUrl: "https://np.example.com/v1",
|
|
connectionProxyEnabled: true,
|
|
}));
|
|
|
|
expect(res.status).toBe(400);
|
|
expect((await res.json()).error).toContain("proxy URL is required");
|
|
expect(await models.getProviderNodes()).toEqual([]);
|
|
expect(await models.getProviderConnections()).toEqual([]);
|
|
});
|
|
|
|
it("keys added afterwards land on the node with the node's own data", async () => {
|
|
resetDataDir("9router-node-later-key-");
|
|
const { nodes, providers, models } = await loadRoutes();
|
|
|
|
const node = (
|
|
await (
|
|
await nodes.POST(post("https://9router.local/api/provider-nodes", {
|
|
type: "openai-compatible",
|
|
apiType: "responses",
|
|
name: "Relay",
|
|
prefix: "relay",
|
|
baseUrl: "https://relay.example.com/v1",
|
|
}))
|
|
).json()
|
|
).node;
|
|
|
|
const added = await (
|
|
await providers.POST(post("https://9router.local/api/providers", {
|
|
provider: node.id,
|
|
name: "First Key",
|
|
apiKey: "sk-later",
|
|
}))
|
|
).json();
|
|
|
|
expect(added.connection).toBeTruthy();
|
|
expect(added.connection.provider).toBe(node.id);
|
|
expect(added.connection.authType).toBe("apikey");
|
|
expect(added.connection.isActive).toBe(true);
|
|
expect(added.connection.apiKey).toBeUndefined();
|
|
// Connection copies the node row, so the prefix/apiType survive.
|
|
expect(added.connection.providerSpecificData.prefix).toBe("relay");
|
|
expect(added.connection.providerSpecificData.apiType).toBe("responses");
|
|
|
|
const stored = (await models.getProviderConnections())
|
|
.filter((c) => c.provider === node.id)
|
|
.map((c) => c.apiKey);
|
|
expect(stored).toEqual(["sk-later"]);
|
|
});
|
|
});
|