## 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
82 lines
2.8 KiB
JavaScript
82 lines
2.8 KiB
JavaScript
import { describe, it, expect, vi, afterEach } from "vitest";
|
|
|
|
// /v1/models is what external CLIs (Codex, Claude Code, aider) read. A capability
|
|
// the user asserted on the dashboard must show up there, and it must win over the
|
|
// name heuristic / live-catalog guess.
|
|
|
|
const NODE_ID = "openai-compatible-chat-abc123";
|
|
|
|
vi.mock("@/lib/localDb", () => ({
|
|
getProviderConnections: vi.fn(async () => [
|
|
{
|
|
provider: NODE_ID,
|
|
isActive: true,
|
|
providerSpecificData: { prefix: "relay", apiType: "chat", baseUrl: "https://relay.example.com/v1" },
|
|
},
|
|
]),
|
|
getCombos: vi.fn(async () => []),
|
|
getCustomModels: vi.fn(async () => []),
|
|
getModelAliases: vi.fn(async () => ({})),
|
|
}));
|
|
|
|
vi.mock("@/lib/disabledModelsDb", () => ({
|
|
getDisabledModels: vi.fn(async () => ({})),
|
|
}));
|
|
|
|
vi.mock("@/sse/services/auth.js", () => ({
|
|
getApiKeyRecord: vi.fn(async () => null),
|
|
extractApiKey: vi.fn(() => null),
|
|
}));
|
|
|
|
vi.mock("@/sse/services/tokenRefresh", () => ({
|
|
updateProviderCredentials: vi.fn(async () => {}),
|
|
}));
|
|
|
|
afterEach(() => vi.clearAllMocks());
|
|
|
|
async function buildWithCustomModels(customModels) {
|
|
const localDb = await import("@/lib/localDb");
|
|
localDb.getCustomModels.mockResolvedValue(customModels);
|
|
const { buildModelsList } = await import("@/app/api/v1/models/route.js");
|
|
// skipDynamicFetch: a real GET /models against the node URL would be network I/O.
|
|
return buildModelsList(["llm"], { skipDynamicFetch: true });
|
|
}
|
|
|
|
describe("/v1/models honors stored per-model capabilities", () => {
|
|
it("reports asserted vision:false over the name heuristic", async () => {
|
|
const models = await buildWithCustomModels([
|
|
{ providerAlias: NODE_ID, id: "qwen3-vl-plus", type: "llm", caps: { vision: false } },
|
|
]);
|
|
const entry = models.find((m) => m.id === "relay/qwen3-vl-plus");
|
|
|
|
expect(entry).toBeTruthy();
|
|
expect(entry.capabilities.vision).toBe(false);
|
|
});
|
|
|
|
it("reports asserted reasoning:true and token limits", async () => {
|
|
const models = await buildWithCustomModels([
|
|
{
|
|
providerAlias: NODE_ID,
|
|
id: "my-private-model",
|
|
type: "llm",
|
|
caps: { reasoning: true, contextWindow: 32000, maxOutput: 4096 },
|
|
},
|
|
]);
|
|
const entry = models.find((m) => m.id === "relay/my-private-model");
|
|
|
|
expect(entry.capabilities.reasoning).toBe(true);
|
|
// Snake_case names are what clients actually read for compaction.
|
|
expect(entry.context_length).toBe(32000);
|
|
expect(entry.max_completion_tokens).toBe(4096);
|
|
});
|
|
|
|
it("leaves models with no assertions to the heuristics", async () => {
|
|
const models = await buildWithCustomModels([
|
|
{ providerAlias: NODE_ID, id: "qwen3-vl-plus", type: "llm" },
|
|
]);
|
|
const entry = models.find((m) => m.id === "relay/qwen3-vl-plus");
|
|
|
|
expect(entry.capabilities.vision).toBe(true);
|
|
});
|
|
});
|