## 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
115 lines
4.1 KiB
JavaScript
115 lines
4.1 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";
|
|
|
|
// The dashboard's Add/Edit Model modal stores only the toggles the user touched.
|
|
// That is only safe if the storage layer merges per key: a second POST carrying
|
|
// { vision:false } must not erase an earlier { reasoning:true } assertion.
|
|
|
|
const originalDataDir = process.env.DATA_DIR;
|
|
let tempDir = null;
|
|
|
|
function setup() {
|
|
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "9router-caps-roundtrip-"));
|
|
process.env.DATA_DIR = tempDir;
|
|
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(() => {
|
|
// driver.js caches the open handle on `global`; release it before unlinking.
|
|
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 request(method, url, body) {
|
|
return new Request(url, {
|
|
method,
|
|
headers: { "Content-Type": "application/json" },
|
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
describe("POST /api/models/custom caps round-trip", () => {
|
|
it("stores whitelisted booleans and merges later partial edits", async () => {
|
|
setup();
|
|
const { POST, GET } = await import("@/app/api/models/custom/route.js");
|
|
const { getCustomModels } = await import("@/models/index.js");
|
|
|
|
const NODE = "openai-compatible-chat-abc123";
|
|
const first = await POST(request("POST", "https://9router.local/api/models/custom", {
|
|
providerAlias: NODE,
|
|
id: "my-model",
|
|
type: "llm",
|
|
// junk keys / non-booleans must be dropped by sanitizeCaps
|
|
caps: { vision: true, reasoning: true, bogus: "yes", contextWindow: "128k" },
|
|
}));
|
|
expect(first.status).toBe(200);
|
|
expect(await first.json()).toEqual({ success: true, added: true });
|
|
|
|
const [row] = await getCustomModels();
|
|
expect(row.caps).toEqual({ vision: true, reasoning: true });
|
|
|
|
// Edit mode sends only the changed toggle.
|
|
await POST(request("POST", "https://9router.local/api/models/custom", {
|
|
providerAlias: NODE,
|
|
id: "my-model",
|
|
type: "llm",
|
|
caps: { vision: false },
|
|
}));
|
|
|
|
// GET /api/models/custom returns the raw stored rows.
|
|
const listed = await (await GET()).json();
|
|
const raw = listed.models.find((m) => m.providerAlias === NODE && m.id === "my-model");
|
|
expect(raw).toBeTruthy();
|
|
expect(raw.caps).toEqual({ vision: false, reasoning: true });
|
|
|
|
// GET /api/models is what the dashboard badges read; stored caps must win there too.
|
|
const { GET: getList } = await import("@/app/api/models/route.js");
|
|
const view = (await (await getList()).json()).models.find(
|
|
(m) => m.provider === NODE && m.model === "my-model",
|
|
);
|
|
expect(view).toBeTruthy();
|
|
expect(view.caps.vision).toBe(false);
|
|
expect(view.caps.reasoning).toBe(true);
|
|
});
|
|
|
|
it("keeps an edit with no changed toggles from clearing stored caps", async () => {
|
|
setup();
|
|
const { POST } = await import("@/app/api/models/custom/route.js");
|
|
const { getCustomModels } = await import("@/models/index.js");
|
|
|
|
await POST(request("POST", "https://9router.local/api/models/custom", {
|
|
providerAlias: "openai-compatible-chat-abc123",
|
|
id: "stable-model",
|
|
type: "llm",
|
|
caps: { vision: false },
|
|
}));
|
|
await POST(request("POST", "https://9router.local/api/models/custom", {
|
|
providerAlias: "openai-compatible-chat-abc123",
|
|
id: "stable-model",
|
|
type: "llm",
|
|
name: "Renamed",
|
|
// no caps at all: sanitizeCaps -> null, so the assertion must survive
|
|
}));
|
|
|
|
const [row] = await getCustomModels();
|
|
expect(row.name).toBe("Renamed");
|
|
expect(row.caps).toEqual({ vision: false });
|
|
});
|
|
});
|