# v0.5.70 (2026-09-08)

## 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 since 0532f00d
- **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 by 86112cee, 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
This commit is contained in:
2026-09-10 11:11:24 +07:00
parent 302795a613
commit a84ba559f3
40 changed files with 1562 additions and 802 deletions

View File

@@ -1,3 +1,23 @@
# v0.5.70 (2026-09-08)
## 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 since 0532f00d
- **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 by 86112cee, 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
# v0.5.69 (2026-09-05) # v0.5.69 (2026-09-05)
## Features ## Features

View File

@@ -14,6 +14,8 @@ RUN npm install --registry=https://registry.npmmirror.com
COPY . ./ COPY . ./
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
# Inter is self-hosted (public/fonts + src/app/fonts-inter.css), so this build needs no
# route to fonts.googleapis.com / fonts.gstatic.com — only the npm mirror above is required.
RUN npm run build RUN npm run build
FROM ${NODE_IMAGE} AS runner FROM ${NODE_IMAGE} AS runner

View File

@@ -1,6 +1,6 @@
{ {
"name": "9router", "name": "9router",
"version": "0.5.69", "version": "0.5.70",
"description": "9Router CLI - Start and manage 9Router server", "description": "9Router CLI - Start and manage 9Router server",
"bin": { "bin": {
"9router": "./cli.js" "9router": "./cli.js"

View File

@@ -414,16 +414,60 @@ const TRUST_UPSTREAM_VISION = new Set(["openrouter"]);
*/ */
const MODALITY_KEYS = ["vision", "pdf", "audioInput", "videoInput"]; const MODALITY_KEYS = ["vision", "pdf", "audioInput", "videoInput"];
// Catalog lookups, installed by the server at startup. Left as no-ops in the // ── Server-injected readers ──────────────────────────────────────────
// browser bundle, where there is no file to read. // Next.js compiles instrumentation and each API route into SEPARATE server
let catalogSource = null; // bundles, so a plain module-local `let` would give every bundle its own copy of
// this file and a source installed at boot would be invisible to the request
// handlers (silently: the setters still "succeed"). The slots therefore live on
// globalThis, which IS shared across server bundles in the same process.
// Same reason the browser bundle is safe: it never calls a setter, so the slots
// stay empty and every consumer below short-circuits.
const SOURCE_SLOTS = (globalThis.__9R_CAPABILITY_SOURCES ||= {
catalog: null, // { getModalities, getLimits } — synced models.dev catalog
userCaps: null, // (provider, model) => asserted caps — dashboard toggles
});
/** /**
* Install the synced catalog reader (server only). * Install the synced catalog reader (server only).
* @param {{ getModalities: Function, getLimits: Function } | null} source * @param {{ getModalities: Function, getLimits: Function } | null} source
*/ */
export function setCatalogSource(source) { export function setCatalogSource(source) {
catalogSource = source; SOURCE_SLOTS.catalog = source || null;
}
// Capabilities the user asserted per provider+model (dashboard "Add/Edit Model"
// toggles), installed by the server from the custom-model store. Unlike the
// catalog and name heuristics this is authoritative and two-directional: it can
// turn a capability OFF as well as on.
const USER_CAPS_KEYS = ["vision", "pdf", "audioInput", "videoInput", "imageOutput", "audioOutput", "search", "tools", "reasoning", "thinkingFormat", "contextWindow", "maxOutput"];
// (slot lives in SOURCE_SLOTS above — see the cross-bundle note)
/**
* Install the user-asserted caps reader (server only).
* @param {(provider: string|null, model: string) => object|null} source sync lookup
*/
export function setUserCapsSource(source) {
SOURCE_SLOTS.userCaps = typeof source === "function" ? source : null;
}
// Last step of every resolution path: the user's own assertion wins over any
// heuristic, including the tables above (a hand-typed model id can collide with
// a pattern entry that describes a different product).
function applyUserCaps(result, provider, model) {
const userCapsSource = SOURCE_SLOTS.userCaps;
if (!userCapsSource) return result;
let asserted = null;
try {
asserted = userCapsSource(provider, model);
} catch {
return result;
}
if (!asserted || typeof asserted !== "object") return result;
for (const key of USER_CAPS_KEYS) {
if (asserted[key] === undefined) continue;
result[key] = asserted[key];
}
return result;
} }
// Apply the synced catalog + name heuristic on top of a table-resolved result. // Apply the synced catalog + name heuristic on top of a table-resolved result.
@@ -431,7 +475,7 @@ export function setCatalogSource(source) {
// flips when an outside source positively declares support. // flips when an outside source positively declares support.
function refine(base, provider, model) { function refine(base, provider, model) {
const result = { ...DEFAULT_CAPABILITIES, ...base }; const result = { ...DEFAULT_CAPABILITIES, ...base };
const catalogSource = SOURCE_SLOTS.catalog;
if (catalogSource) { if (catalogSource) {
const modalities = catalogSource.getModalities(model); const modalities = catalogSource.getModalities(model);
if (modalities) { if (modalities) {
@@ -457,7 +501,7 @@ export function getCapabilitiesForModel(provider, model) {
// Canonical exact lookup strips vendor prefix: "anthropic/claude-opus-4.7" -> "claude-opus-4.7". // Canonical exact lookup strips vendor prefix: "anthropic/claude-opus-4.7" -> "claude-opus-4.7".
const baseModel = model.includes("/") ? model.split("/").pop() : model; const baseModel = model.includes("/") ? model.split("/").pop() : model;
const resolve = () => {
// 1. Provider-specific override // 1. Provider-specific override
if (provider) { if (provider) {
const providerCaps = PROVIDER_CAPABILITIES[provider]; const providerCaps = PROVIDER_CAPABILITIES[provider];
@@ -481,4 +525,7 @@ export function getCapabilitiesForModel(provider, model) {
return { ...refine(null, provider, model), vision: true }; return { ...refine(null, provider, model), vision: true };
} }
return refine(null, provider, model); return refine(null, provider, model);
};
return applyUserCaps(resolve(), provider, model);
} }

View File

@@ -54,6 +54,12 @@ const PATTERN_THINKING = [
{ provider: "codebuddy-cn", pattern: "hy4*", levels: ["high"] }, { provider: "codebuddy-cn", pattern: "hy4*", levels: ["high"] },
]; ];
// The generic level set used when a model's thinking format is unknown. Exported
// for UI callers that must list levels for a model whose reasoning capability is
// user-asserted: the browser bundle has no access to the capability store, so it
// cannot derive a format the way getThinkingLevels does on the server.
export const BASE_THINKING_LEVELS = L.base;
// Returns valid thinking levels for a model, or null when the model has no reasoning. // Returns valid thinking levels for a model, or null when the model has no reasoning.
export function getThinkingLevels(provider, model) { export function getThinkingLevels(provider, model) {
if (provider === "kiro" && resolveKiroEffortPath(model) === null) return null; if (provider === "kiro" && resolveKiroEffortPath(model) === null) return null;

View File

@@ -1,6 +1,6 @@
{ {
"name": "9router-app", "name": "9router-app",
"version": "0.5.69", "version": "0.5.70",
"description": "9Router web dashboard", "description": "9Router web dashboard",
"private": true, "private": true,
"scripts": { "scripts": {

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -6,6 +6,7 @@ import { useEffect, useState } from "react";
import { Card, Badge, Button, Toggle, AddCustomEmbeddingModal } from "@/shared/components"; import { Card, Badge, Button, Toggle, AddCustomEmbeddingModal } from "@/shared/components";
import ProviderIcon from "@/shared/components/ProviderIcon"; import ProviderIcon from "@/shared/components/ProviderIcon";
import { MEDIA_PROVIDER_KINDS, AI_PROVIDERS, getProvidersByKind } from "@/shared/constants/providers"; import { MEDIA_PROVIDER_KINDS, AI_PROVIDERS, getProvidersByKind } from "@/shared/constants/providers";
import { useNotificationStore } from "@/store/notificationStore";
// Kinds that support combos (currently disabled for image/tts — temporarily hidden). // Kinds that support combos (currently disabled for image/tts — temporarily hidden).
// webSearch/webFetch handled by /web page. // webSearch/webFetch handled by /web page.
@@ -278,9 +279,19 @@ export default function MediaProviderKindPage() {
<AddCustomEmbeddingModal <AddCustomEmbeddingModal
isOpen={showAddCustomEmbedding} isOpen={showAddCustomEmbedding}
onClose={() => setShowAddCustomEmbedding(false)} onClose={() => setShowAddCustomEmbedding(false)}
onCreated={(node) => { onCreated={(result) => {
setCustomNodes((prev) => [...prev, node]); // POST /api/provider-nodes registers the endpoint only; keys and
// models are added from the node's own page afterwards.
const notify = useNotificationStore.getState();
const node = result?.node || result;
if (node) setCustomNodes((prev) => [...prev, node]);
setShowAddCustomEmbedding(false); setShowAddCustomEmbedding(false);
if (node) {
notify.success(
`${node.name || "Provider"} created. Add an API key from its page to start using it.`,
"Provider created",
);
}
}} }}
/> />
)} )}

View File

@@ -97,7 +97,6 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
// Non-ollama providers require a name // Non-ollama providers require a name
if (!formData.name) return; if (!formData.name) return;
} }
if (isCompatible && !formData.defaultModel.trim()) return;
setSaving(true); setSaving(true);
try { try {
@@ -298,6 +297,7 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
value={formData.defaultModel} value={formData.defaultModel}
onChange={(e) => setFormData({ ...formData, defaultModel: e.target.value })} onChange={(e) => setFormData({ ...formData, defaultModel: e.target.value })}
placeholder={isAnthropic ? "claude-3-5-sonnet-latest" : "gpt-4o-mini"} placeholder={isAnthropic ? "claude-3-5-sonnet-latest" : "gpt-4o-mini"}
hint="Optional. Used only as the model for connection checks when no model is picked. The node's own model list takes precedence."
/> />
)} )}
{isOllamaLocal && ( {isOllamaLocal && (
@@ -313,11 +313,6 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
{error && ( {error && (
<p className="text-xs text-red-500 break-words">{error}</p> <p className="text-xs text-red-500 break-words">{error}</p>
)} )}
{isCompatible && (
<p className="text-xs text-text-muted">
Enter the model ID exactly as your compatible endpoint expects it. This model will be saved as the connection default.
</p>
)}
{isCloudflareAi && ( {isCloudflareAi && (
<div className="bg-sidebar/50 p-4 rounded-lg border border-accent/20"> <div className="bg-sidebar/50 p-4 rounded-lg border border-accent/20">
<h3 className="font-semibold mb-3 text-sm">Cloudflare Workers AI</h3> <h3 className="font-semibold mb-3 text-sm">Cloudflare Workers AI</h3>
@@ -393,7 +388,7 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
</p> </p>
<div className="flex gap-2"> <div className="flex gap-2">
<Button onClick={handleSubmit} fullWidth disabled={saving || (!isOllamaLocal && (!formData.name || !formData.apiKey)) || (isCompatible && !formData.defaultModel.trim()) || (isAzure && (!azureData.azureEndpoint || !azureData.deployment || !azureData.organization)) || (isCloudflareAi && !cloudflareData.accountId)}> <Button onClick={handleSubmit} fullWidth disabled={saving || (!isOllamaLocal && (!formData.name || !formData.apiKey)) || (isAzure && (!azureData.azureEndpoint || !azureData.deployment || !azureData.organization)) || (isCloudflareAi && !cloudflareData.accountId)}>
{saving ? "Saving..." : "Save"} {saving ? "Saving..." : "Save"}
</Button> </Button>
<Button onClick={onClose} variant="ghost" fullWidth> <Button onClick={onClose} variant="ghost" fullWidth>

View File

@@ -5,19 +5,50 @@ import PropTypes from "prop-types";
import { Button, Modal, Toggle } from "@/shared/components"; import { Button, Modal, Toggle } from "@/shared/components";
import { CAPACITY_META } from "@/shared/constants/models"; import { CAPACITY_META } from "@/shared/constants/models";
const defaultCaps = () => Object.fromEntries(Object.keys(CAPACITY_META).map((key) => [key, false])); const emptyCaps = () => Object.fromEntries(Object.keys(CAPACITY_META).map((key) => [key, false]));
export default function AddCustomModelModal({ isOpen, providerAlias, providerDisplayAlias, onSave, onClose }) { // Seed = stored assertion where known, otherwise "off".
const [modelId, setModelId] = useState(""); const seedFrom = (initialCaps) => ({ ...emptyCaps(), ...(initialCaps || {}) });
const [caps, setCaps] = useState(defaultCaps);
/**
* Add or edit a custom model's asserted capabilities.
*
* Only capabilities the user actually changed are submitted. Sending an
* untouched `vision:false` would (a) silence the upstream fail-open that lets a
* hand-typed model id accept images and (b) pin a default the user never chose
* above the catalog/name heuristics.
*/
export default function AddCustomModelModal({
isOpen,
providerAlias,
initialModelId = "",
initialCaps = null,
onSave,
onClose,
}) {
const isEdit = !!initialModelId;
const [modelId, setModelId] = useState(initialModelId);
const [seed, setSeed] = useState(() => seedFrom(initialCaps));
const [caps, setCaps] = useState(() => seedFrom(initialCaps));
const [testStatus, setTestStatus] = useState(null); // null | "testing" | "ok" | "error" const [testStatus, setTestStatus] = useState(null); // null | "testing" | "ok" | "error"
const [testError, setTestError] = useState(""); const [testError, setTestError] = useState("");
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
// Reset state when modal opens // Re-seeded on open and when the target model changes while already open
// (row-click editing reuses one mounted modal). `capsKey` is a primitive so a
// parent passing a fresh object literal cannot re-trigger this every render.
const capsKey = JSON.stringify(initialCaps || {});
useEffect(() => { useEffect(() => {
if (isOpen) { setModelId(""); setCaps(defaultCaps()); setTestStatus(null); setTestError(""); } if (!isOpen) return;
}, [isOpen]); // Seed from `capsKey` rather than `initialCaps`: same value (capsKey is its
// JSON), and the dependency list is then complete.
const next = seedFrom(JSON.parse(capsKey));
setModelId(initialModelId);
setSeed(next);
setCaps(next);
setTestStatus(null);
setTestError("");
}, [isOpen, initialModelId, capsKey]);
// Strip provider's own alias prefix (e.g. "cc/model" -> "model" for cc provider) // Strip provider's own alias prefix (e.g. "cc/model" -> "model" for cc provider)
const stripAlias = (id) => { const stripAlias = (id) => {
@@ -50,7 +81,11 @@ export default function AddCustomModelModal({ isOpen, providerAlias, providerDis
if (!cleanId || saving) return; if (!cleanId || saving) return;
setSaving(true); setSaving(true);
try { try {
await onSave(cleanId, caps); const changed = {};
for (const key of Object.keys(CAPACITY_META)) {
if (!!caps[key] !== !!seed[key]) changed[key] = !!caps[key];
}
await onSave(cleanId, Object.keys(changed).length ? changed : undefined);
} finally { } finally {
setSaving(false); setSaving(false);
} }
@@ -61,7 +96,7 @@ export default function AddCustomModelModal({ isOpen, providerAlias, providerDis
}; };
return ( return (
<Modal isOpen={isOpen} onClose={onClose} title="Add Custom Model"> <Modal isOpen={isOpen} onClose={onClose} title={isEdit ? "Edit Custom Model" : "Add Custom Model"}>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div> <div>
<label className="text-sm font-medium mb-1.5 block">Model ID</label> <label className="text-sm font-medium mb-1.5 block">Model ID</label>
@@ -71,9 +106,11 @@ export default function AddCustomModelModal({ isOpen, providerAlias, providerDis
value={modelId} value={modelId}
onChange={(e) => { setModelId(e.target.value); setTestStatus(null); setTestError(""); }} onChange={(e) => { setModelId(e.target.value); setTestStatus(null); setTestError(""); }}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder="e.g. claude-opus-4-5" placeholder={isEdit ? undefined : "e.g. claude-opus-4-5"}
className="flex-1 px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary" className="flex-1 px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
autoFocus autoFocus
readOnly={isEdit}
title={isEdit ? "Remove and re-add the model to change its ID" : undefined}
/> />
<Button <Button
variant="secondary" variant="secondary"
@@ -92,6 +129,9 @@ export default function AddCustomModelModal({ isOpen, providerAlias, providerDis
<div> <div>
<label className="text-sm font-medium mb-1.5 block">Capabilities</label> <label className="text-sm font-medium mb-1.5 block">Capabilities</label>
<p className="text-xs text-text-muted mb-2">
{"Only toggles you change are saved. Unset capabilities fall back to the provider/name catalog; changing Vision to off also stops images being forwarded."}
</p>
<div className="flex flex-wrap gap-4"> <div className="flex flex-wrap gap-4">
{Object.entries(CAPACITY_META).map(([key, meta]) => ( {Object.entries(CAPACITY_META).map(([key, meta]) => (
<Toggle <Toggle
@@ -128,7 +168,7 @@ export default function AddCustomModelModal({ isOpen, providerAlias, providerDis
size="sm" size="sm"
disabled={!modelId.trim() || saving} disabled={!modelId.trim() || saving}
> >
{saving ? "Adding..." : "Add Model"} {saving ? "Saving..." : (isEdit ? "Save" : "Add Model")}
</Button> </Button>
</div> </div>
</div> </div>
@@ -139,7 +179,8 @@ export default function AddCustomModelModal({ isOpen, providerAlias, providerDis
AddCustomModelModal.propTypes = { AddCustomModelModal.propTypes = {
isOpen: PropTypes.bool.isRequired, isOpen: PropTypes.bool.isRequired,
providerAlias: PropTypes.string.isRequired, providerAlias: PropTypes.string.isRequired,
providerDisplayAlias: PropTypes.string.isRequired, initialModelId: PropTypes.string,
initialCaps: PropTypes.object,
onSave: PropTypes.func.isRequired, onSave: PropTypes.func.isRequired,
onClose: PropTypes.func.isRequired, onClose: PropTypes.func.isRequired,
}; };

View File

@@ -1,263 +1,170 @@
"use client"; "use client";
import { useState } from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { Button } from "@/shared/components"; import { Button } from "@/shared/components";
import { getProviderCustomModelRows } from "@/shared/utils/providerCustomModels"; import { getProviderCustomModelRows } from "@/shared/utils/providerCustomModels";
function CompatibleModelRow({ modelId, fullModel, copied, onCopy, onDeleteAlias, onTest, testStatus, isTesting }) { import { translate } from "@/i18n/runtime";
const borderColor = testStatus === "ok" import ModelRow from "./ModelRow";
? "border-green-500/40"
: testStatus === "error"
? "border-red-500/40"
: "border-border";
const iconColor = testStatus === "ok"
? "#22c55e"
: testStatus === "error"
? "#ef4444"
: undefined;
return (
<div className={`flex items-center gap-3 p-3 rounded-lg border ${borderColor} hover:bg-sidebar/50`}>
<span
className="material-symbols-outlined text-base text-text-muted"
style={iconColor ? { color: iconColor } : undefined}
>
{testStatus === "ok" ? "check_circle" : testStatus === "error" ? "cancel" : "smart_toy"}
</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{modelId}</p>
<div className="flex items-center gap-1 mt-1">
<code className="min-w-0 break-all text-xs text-text-muted font-mono bg-sidebar px-1.5 py-0.5 rounded">{fullModel}</code>
<div className="relative group/btn">
<button
onClick={() => onCopy(fullModel, `model-${modelId}`)}
className="p-0.5 hover:bg-sidebar rounded text-text-muted hover:text-primary"
>
<span className="material-symbols-outlined text-sm">
{copied === `model-${modelId}` ? "check" : "content_copy"}
</span>
</button>
<span className="pointer-events-none absolute top-5 left-1/2 -translate-x-1/2 text-[10px] text-text-muted whitespace-nowrap opacity-0 group-hover/btn:opacity-100 transition-opacity">
{copied === `model-${modelId}` ? "Copied!" : "Copy"}
</span>
</div>
{onTest && (
<div className="relative group/btn">
<button
onClick={onTest}
disabled={isTesting}
className="p-0.5 hover:bg-sidebar rounded text-text-muted hover:text-primary transition-colors"
>
<span className="material-symbols-outlined text-sm" style={isTesting ? { animation: "spin 1s linear infinite" } : undefined}>
{isTesting ? "progress_activity" : "science"}
</span>
</button>
<span className="pointer-events-none absolute top-5 left-1/2 -translate-x-1/2 text-[10px] text-text-muted whitespace-nowrap opacity-0 group-hover/btn:opacity-100 transition-opacity">
{isTesting ? "Testing..." : "Test"}
</span>
</div>
)}
</div>
</div>
<button
onClick={onDeleteAlias}
className="p-1 hover:bg-red-50 rounded text-red-500"
title="Remove model"
>
<span className="material-symbols-outlined text-sm">delete</span>
</button>
</div>
);
}
export default function CompatibleModelsSection({ providerStorageAlias, providerDisplayAlias, modelAliases, customModels, copied, onCopy, onDeleteAlias, onAddCustomModel, onDeleteCustomModel, connections, isAnthropic, onFetchModels, fetchingModels }) {
const [newModel, setNewModel] = useState("");
const [adding, setAdding] = useState(false);
const [testingModelId, setTestingModelId] = useState(null);
const [modelTestResults, setModelTestResults] = useState({});
const [testAllRunning, setTestAllRunning] = useState(false);
const [testAllResults, setTestAllResults] = useState(null);
const [failedIds, setFailedIds] = useState([]);
const [cleaning, setCleaning] = useState(false);
const handleTestModel = async (modelId) => {
if (testingModelId) return;
setTestingModelId(modelId);
try {
const res = await fetch("/api/models/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: `${providerStorageAlias}/${modelId}` }),
});
const data = await res.json();
setModelTestResults((prev) => ({ ...prev, [modelId]: data.ok ? "ok" : "error" }));
} catch {
setModelTestResults((prev) => ({ ...prev, [modelId]: "error" }));
} finally {
setTestingModelId(null);
}
};
/**
* Model management for compatible (node-backed) providers.
*
* Shares `ModelRow` with built-in providers so custom nodes get the same
* capabilities badges, copy, test and edit affordances instead of a weaker
* read-only row. Adding/editing goes through the page-level
* `AddCustomModelModal`, which owns the capability toggles.
*
* Testing is deliberately driven by the page: it already owns the model test
* state and the "Test All Models" toolbar used by built-in providers, so this
* component renders results rather than keeping a second set in sync.
*
* Disable/enable uses the same `/api/models/disabled` store as built-ins,
* keyed by the node id (`providerStorageAlias`), so two nodes never share a
* disabled list.
*/
export default function CompatibleModelsSection({
providerStorageAlias,
providerDisplayAlias,
modelAliases,
customModels,
copied,
onCopy,
onDeleteAlias,
onOpenAddModel,
onEditModel,
onDeleteCustomModel,
onFetchModels,
fetchingModels,
connections,
getCaps,
thinkingSuffix,
disabledModelIds = [],
onDisableModel,
onEnableModel,
modelTestResults = {},
testingModelIds,
onTestModel,
onCleanFailed,
cleaningFailed = false,
}) {
const disabledSet = new Set(disabledModelIds);
const allModels = getProviderCustomModelRows({ const allModels = getProviderCustomModelRows({
customModels, customModels,
modelAliases, modelAliases,
providerAlias: providerStorageAlias, providerAlias: providerStorageAlias,
type: "llm", type: "llm",
}); });
const activeModels = allModels.filter((model) => !disabledSet.has(model.id));
const handleAdd = async () => { const disabledModels = allModels.filter((model) => disabledSet.has(model.id));
if (!newModel.trim() || adding) return;
const modelId = newModel.trim();
if (allModels.some((model) => model.id === modelId)) {
alert("Model already exists for this provider.");
return;
}
setAdding(true);
try {
await onAddCustomModel(modelId);
setNewModel("");
} catch (error) {
console.log("Error adding model:", error);
} finally {
setAdding(false);
}
};
const canFetch = connections.some((conn) => conn.isActive !== false); const canFetch = connections.some((conn) => conn.isActive !== false);
const hasActiveConnection = canFetch;
const handleTestAllClick = async () => { const failedIds = allModels
if (testAllRunning || allModels.length === 0) return; .filter((model) => model.source === "custom" && modelTestResults[model.id] === "error")
setTestAllRunning(true); .map((model) => model.id);
setTestAllResults(null);
setFailedIds([]);
setModelTestResults({});
const targets = [...allModels];
const settled = await Promise.allSettled(
targets.map(async (model) => {
const res = await fetch("/api/models/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ model: `${providerStorageAlias}/${model.id}` }),
});
const data = await res.json().catch(() => ({}));
return { id: model.id, ok: res.ok ? !!data.ok : false, error: res.ok ? (data.ok ? null : (data.error || "Test failed")) : (data.error || `HTTP ${res.status}`) };
}),
);
const currentResults = { passed: 0, failed: 0, failedIds: [] };
settled.forEach((result, index) => {
const id = targets[index].id;
const ok = result.status === "fulfilled" && result.value.ok;
setModelTestResults((prev) => ({ ...prev, [id]: ok ? "ok" : "error" }));
if (ok) {
currentResults.passed += 1;
} else {
currentResults.failed += 1;
currentResults.failedIds.push(id);
}
});
setTestAllResults({ passed: currentResults.passed, failed: currentResults.failed });
setFailedIds(currentResults.failedIds);
setTestAllRunning(false);
};
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex items-end gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<div className="flex-1 min-w-[240px]"> <Button size="sm" icon="add" onClick={onOpenAddModel}>
<label htmlFor="new-compatible-model-input" className="text-xs text-text-muted mb-1 block">Model ID</label> {"Add Model"}
<input
id="new-compatible-model-input"
type="text"
value={newModel}
onChange={(e) => setNewModel(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
placeholder={isAnthropic ? "claude-3-opus-20240229" : "gpt-4o"}
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
/>
</div>
<Button size="sm" icon="add" onClick={handleAdd} disabled={!newModel.trim() || adding}>
{adding ? "Adding..." : "Add"}
</Button> </Button>
<Button size="sm" variant="secondary" icon="download" onClick={onFetchModels} disabled={!canFetch || fetchingModels}> {onFetchModels && (
{fetchingModels ? "Fetching..." : "Fetch Models"} <Button
</Button> size="sm"
<Button size="sm" variant="secondary" icon="science" onClick={handleTestAllClick} disabled={allModels.length === 0 || testAllRunning}> variant="secondary"
{testAllRunning ? "Testing..." : "Test All"} icon={fetchingModels ? "progress_activity" : "download"}
</Button> onClick={onFetchModels}
</div> disabled={!canFetch || fetchingModels}
{(testAllResults || testAllRunning) && (
<div className="flex flex-col gap-2">
<div className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm border ${
testAllRunning
? "bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/30"
: testAllResults?.failed === 0
? "bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/30"
: "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 border-yellow-500/30"
}`}>
<span className={`material-symbols-outlined text-[16px] ${testAllRunning ? "animate-spin" : ""}`}>
{testAllRunning ? "progress_activity" : testAllResults?.failed === 0 ? "check_circle" : "warning"}
</span>
<span>
{testAllRunning
? `Testing... ${allModels.length} model${allModels.length > 1 ? "s" : ""} in parallel`
: `${testAllResults?.passed || 0} passed, ${testAllResults?.failed || 0} failed`
}
</span>
{!testAllRunning && failedIds.length > 0 && (
<button
onClick={async () => {
if (cleaning) return;
setCleaning(true);
for (const id of failedIds) {
await onDeleteCustomModel(id);
}
setCleaning(false);
setTestAllResults(null);
setFailedIds([]);
setModelTestResults({});
}}
disabled={cleaning}
className="ml-auto flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium bg-red-500/20 text-red-600 dark:text-red-400 hover:bg-red-500/30 transition-colors"
> >
<span className="material-symbols-outlined text-[14px]"> <span
{cleaning ? "progress_activity" : "delete_sweep"} className={fetchingModels ? "animate-spin inline-flex" : "inline-flex"}
style={fetchingModels ? { animation: "spin 1s linear infinite" } : undefined}
>
{fetchingModels ? translate("Fetching...") : translate("Fetch Models")}
</span> </span>
{cleaning ? "Cleaning..." : `Clean ${failedIds.length} failed`} </Button>
</button>
)} )}
</div> {failedIds.length > 0 && (
</div> <Button
size="sm"
variant="danger"
icon={cleaningFailed ? "progress_activity" : "delete_sweep"}
onClick={() => onCleanFailed?.(failedIds)}
disabled={cleaningFailed}
>
{cleaningFailed ? "Cleaning..." : `Clean ${failedIds.length} failed`}
</Button>
)} )}
{!canFetch && ( {!canFetch && (
<p className="text-xs text-text-muted"> <span className="text-xs text-text-muted">
Add a connection to enable fetching models. {"Add a connection to enable fetching models."}
</p> </span>
)} )}
</div>
{allModels.length > 0 && ( {allModels.length === 0 ? (
<div className="flex flex-col gap-3"> <p className="text-xs text-text-muted">
{allModels.map(({ id, alias, source }) => ( {"No models yet — add one or fetch the provider's list."}
<CompatibleModelRow </p>
) : (
<>
<div className="flex flex-wrap gap-3">
{activeModels.map(({ id, alias, name, source }) => {
const isCustom = source === "custom";
return (
<ModelRow
key={`${source}-${providerStorageAlias}/${id}`} key={`${source}-${providerStorageAlias}/${id}`}
modelId={id} model={{ id, name }}
fullModel={`${providerDisplayAlias}/${id}`} fullModel={`${providerDisplayAlias}/${id}`}
alias={alias}
copied={copied} copied={copied}
onCopy={onCopy} onCopy={onCopy}
onDeleteAlias={() => source === "custom" ? onDeleteCustomModel(id) : onDeleteAlias(alias)} onSetAlias={() => {}}
onTest={connections.length > 0 ? () => handleTestModel(id) : undefined} onDeleteAlias={() =>
isCustom ? onDeleteCustomModel(id) : onDeleteAlias(alias)
}
testStatus={modelTestResults[id]} testStatus={modelTestResults[id]}
isTesting={testAllRunning || testingModelId === id} onTest={hasActiveConnection && onTestModel ? () => onTestModel(id) : undefined}
isTesting={testingModelIds?.has(id) || false}
isCustom={isCustom}
// Custom rows are removed (X = delete); legacy alias rows keep
// the built-in semantics (X = disable), same as provider models.
onDisable={isCustom ? undefined : () => onDisableModel?.(id)}
caps={getCaps ? getCaps(`${providerStorageAlias}/${id}`) : undefined}
thinkingSuffix={thinkingSuffix ? thinkingSuffix(id) : null}
onEdit={
isCustom && onEditModel
? () => onEditModel(id)
: undefined
}
/> />
);
})}
</div>
{disabledModels.length > 0 && (
<div className="w-full">
<p className="text-xs text-text-muted mb-2">
{`Disabled models (${disabledModels.length}):`}
</p>
<div className="flex flex-wrap gap-2">
{disabledModels.map((model) => (
<button
key={`${model.source}-${model.id}`}
onClick={() => onEnableModel?.(model.id)}
className="flex items-center gap-1 px-2.5 py-1.5 rounded-lg border border-dashed border-black/10 dark:border-white/10 text-xs text-text-muted hover:text-primary hover:border-primary/40 hover:bg-primary/5 transition-colors"
title="Restore model"
>
<span className="material-symbols-outlined text-[13px]">add</span>
{model.id}
</button>
))} ))}
</div> </div>
</div>
)}
</>
)} )}
</div> </div>
); );
@@ -271,13 +178,23 @@ CompatibleModelsSection.propTypes = {
copied: PropTypes.string, copied: PropTypes.string,
onCopy: PropTypes.func.isRequired, onCopy: PropTypes.func.isRequired,
onDeleteAlias: PropTypes.func.isRequired, onDeleteAlias: PropTypes.func.isRequired,
onAddCustomModel: PropTypes.func.isRequired, onOpenAddModel: PropTypes.func.isRequired,
onEditModel: PropTypes.func,
onDeleteCustomModel: PropTypes.func.isRequired, onDeleteCustomModel: PropTypes.func.isRequired,
onFetchModels: PropTypes.func,
fetchingModels: PropTypes.bool,
connections: PropTypes.arrayOf(PropTypes.shape({ connections: PropTypes.arrayOf(PropTypes.shape({
id: PropTypes.string, id: PropTypes.string,
isActive: PropTypes.bool, isActive: PropTypes.bool,
})).isRequired, })).isRequired,
isAnthropic: PropTypes.bool, getCaps: PropTypes.func,
onFetchModels: PropTypes.func, thinkingSuffix: PropTypes.func,
fetchingModels: PropTypes.bool, disabledModelIds: PropTypes.arrayOf(PropTypes.string),
onDisableModel: PropTypes.func,
onEnableModel: PropTypes.func,
modelTestResults: PropTypes.object,
testingModelIds: PropTypes.instanceOf(Set),
onTestModel: PropTypes.func,
onCleanFailed: PropTypes.func,
cleaningFailed: PropTypes.bool,
}; };

View File

@@ -1,7 +1,7 @@
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { CapacityBadges } from "@/shared/components"; import { CapacityBadges } from "@/shared/components";
export default function ModelRow({ model, fullModel, alias, copied, onCopy, testStatus, isCustom, isFree, onDeleteAlias, onTest, isTesting, onDisable, caps, thinkingSuffix }) { export default function ModelRow({ model, fullModel, alias, copied, onCopy, testStatus, isCustom, isFree, onDeleteAlias, onTest, isTesting, onDisable, onEdit, caps, thinkingSuffix }) {
const displayModel = thinkingSuffix ? `${fullModel}(${thinkingSuffix})` : fullModel; const displayModel = thinkingSuffix ? `${fullModel}(${thinkingSuffix})` : fullModel;
const borderColor = testStatus === "ok" const borderColor = testStatus === "ok"
? "border-green-500/40" ? "border-green-500/40"
@@ -60,6 +60,19 @@ export default function ModelRow({ model, fullModel, alias, copied, onCopy, test
{copied === `model-${model.id}` ? "Copied!" : "Copy"} {copied === `model-${model.id}` ? "Copied!" : "Copy"}
</span> </span>
</div> </div>
{onEdit && (
<div className="relative shrink-0 group/btn">
<button
onClick={onEdit}
className="rounded p-0.5 text-text-muted transition-opacity hover:bg-sidebar hover:text-primary sm:opacity-0 sm:group-hover:opacity-100"
>
<span className="material-symbols-outlined text-sm">tune</span>
</button>
<span className="pointer-events-none absolute mt-1 top-5 left-1/2 -translate-x-1/2 text-[10px] text-text-muted whitespace-nowrap opacity-0 group-hover/btn:opacity-100 transition-opacity">
{"Edit capabilities"}
</span>
</div>
)}
{isCustom ? ( {isCustom ? (
<button <button
onClick={onDeleteAlias} onClick={onDeleteAlias}
@@ -99,4 +112,5 @@ ModelRow.propTypes = {
onDisable: PropTypes.func, onDisable: PropTypes.func,
caps: PropTypes.object, caps: PropTypes.object,
thinkingSuffix: PropTypes.string, thinkingSuffix: PropTypes.string,
onEdit: PropTypes.func,
}; };

View File

@@ -1,174 +0,0 @@
"use client";
import { useState } from "react";
import PropTypes from "prop-types";
import { Button } from "@/shared/components";
import { getProviderCustomModelRows } from "@/shared/utils/providerCustomModels";
function PassthroughModelRow({ modelId, fullModel, copied, onCopy, onDeleteAlias, onTest, testStatus, isTesting }) {
const borderColor = testStatus === "ok"
? "border-green-500/40"
: testStatus === "error"
? "border-red-500/40"
: "border-border";
const iconColor = testStatus === "ok"
? "#22c55e"
: testStatus === "error"
? "#ef4444"
: undefined;
return (
<div className={`flex items-center gap-3 p-3 rounded-lg border ${borderColor} hover:bg-sidebar/50`}>
<span
className="material-symbols-outlined text-base text-text-muted"
style={iconColor ? { color: iconColor } : undefined}
>
{testStatus === "ok" ? "check_circle" : testStatus === "error" ? "cancel" : "smart_toy"}
</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{modelId}</p>
<div className="flex items-center gap-1 mt-1">
<code className="min-w-0 break-all text-xs text-text-muted font-mono bg-sidebar px-1.5 py-0.5 rounded">{fullModel}</code>
<div className="relative group/btn">
<button
onClick={() => onCopy(fullModel, `model-${modelId}`)}
className="p-0.5 hover:bg-sidebar rounded text-text-muted hover:text-primary"
>
<span className="material-symbols-outlined text-sm">
{copied === `model-${modelId}` ? "check" : "content_copy"}
</span>
</button>
<span className="pointer-events-none absolute top-5 left-1/2 -translate-x-1/2 text-[10px] text-text-muted whitespace-nowrap opacity-0 group-hover/btn:opacity-100 transition-opacity">
{copied === `model-${modelId}` ? "Copied!" : "Copy"}
</span>
</div>
{onTest && (
<div className="relative group/btn">
<button
onClick={onTest}
disabled={isTesting}
className="p-0.5 hover:bg-sidebar rounded text-text-muted hover:text-primary transition-colors"
>
<span className="material-symbols-outlined text-sm" style={isTesting ? { animation: "spin 1s linear infinite" } : undefined}>
{isTesting ? "progress_activity" : "science"}
</span>
</button>
<span className="pointer-events-none absolute top-5 left-1/2 -translate-x-1/2 text-[10px] text-text-muted whitespace-nowrap opacity-0 group-hover/btn:opacity-100 transition-opacity">
{isTesting ? "Testing..." : "Test"}
</span>
</div>
)}
</div>
</div>
{/* Delete button */}
<button
onClick={onDeleteAlias}
className="p-1 hover:bg-red-50 rounded text-red-500"
title="Remove model"
>
<span className="material-symbols-outlined text-sm">delete</span>
</button>
</div>
);
}
PassthroughModelRow.propTypes = {
modelId: PropTypes.string.isRequired,
fullModel: PropTypes.string.isRequired,
copied: PropTypes.string,
onCopy: PropTypes.func.isRequired,
onDeleteAlias: PropTypes.func.isRequired,
onTest: PropTypes.func,
testStatus: PropTypes.oneOf(["ok", "error"]),
isTesting: PropTypes.bool,
};
export default function PassthroughModelsSection({ providerAlias, modelAliases, customModels, copied, onCopy, onDeleteAlias, onAddCustomModel, onDeleteCustomModel }) {
const [newModel, setNewModel] = useState("");
const [adding, setAdding] = useState(false);
const allModels = getProviderCustomModelRows({
customModels,
modelAliases,
providerAlias,
type: "llm",
});
const handleAdd = async () => {
if (!newModel.trim() || adding) return;
const modelId = newModel.trim();
if (allModels.some((model) => model.id === modelId)) {
alert("Model already exists for this provider.");
return;
}
setAdding(true);
try {
await onAddCustomModel(modelId);
setNewModel("");
} catch (error) {
console.log("Error adding model:", error);
} finally {
setAdding(false);
}
};
return (
<div className="flex flex-col gap-4">
<p className="text-sm text-text-muted">
OpenRouter supports any model. Add models and create aliases for quick access.
</p>
{/* Add new model */}
<div className="flex items-end gap-2">
<div className="flex-1">
<label htmlFor="new-model-input" className="text-xs text-text-muted mb-1 block">Model ID (from OpenRouter)</label>
<input
id="new-model-input"
type="text"
value={newModel}
onChange={(e) => setNewModel(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
placeholder="anthropic/claude-3-opus"
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
/>
</div>
<Button size="sm" icon="add" onClick={handleAdd} disabled={!newModel.trim() || adding}>
{adding ? "Adding..." : "Add"}
</Button>
</div>
{/* Models list */}
{allModels.length > 0 && (
<div className="flex flex-col gap-3">
{allModels.map(({ id, fullModel, alias, source }) => (
<PassthroughModelRow
key={`${source}-${fullModel}`}
modelId={id}
fullModel={fullModel}
copied={copied}
onCopy={onCopy}
onDeleteAlias={() => source === "custom" ? onDeleteCustomModel(id) : onDeleteAlias(alias)}
/>
))}
</div>
)}
</div>
);
}
PassthroughModelsSection.propTypes = {
providerAlias: PropTypes.string.isRequired,
modelAliases: PropTypes.object.isRequired,
customModels: PropTypes.arrayOf(PropTypes.object),
copied: PropTypes.string,
onCopy: PropTypes.func.isRequired,
onDeleteAlias: PropTypes.func.isRequired,
onAddCustomModel: PropTypes.func.isRequired,
onDeleteCustomModel: PropTypes.func.isRequired,
};

View File

@@ -8,14 +8,13 @@ import { getProviderIconSrc, markProviderIconMissing } from "@/shared/utils/prov
import { Card, Button, Badge, Input, Modal, CardSkeleton, OAuthModal, KiroOAuthWrapper, CursorAuthModal, IFlowCookieModal, GitLabAuthModal, Toggle, Select, EditConnectionModal, NoAuthProxyCard, ConfirmModal } from "@/shared/components"; import { Card, Button, Badge, Input, Modal, CardSkeleton, OAuthModal, KiroOAuthWrapper, CursorAuthModal, IFlowCookieModal, GitLabAuthModal, Toggle, Select, EditConnectionModal, NoAuthProxyCard, ConfirmModal } from "@/shared/components";
import { OAUTH_PROVIDERS, APIKEY_PROVIDERS, FREE_PROVIDERS, FREE_TIER_PROVIDERS, WEB_COOKIE_PROVIDERS, getProviderAlias, isOpenAICompatibleProvider, isAnthropicCompatibleProvider, AI_PROVIDERS } from "@/shared/constants/providers"; import { OAUTH_PROVIDERS, APIKEY_PROVIDERS, FREE_PROVIDERS, FREE_TIER_PROVIDERS, WEB_COOKIE_PROVIDERS, getProviderAlias, isOpenAICompatibleProvider, isAnthropicCompatibleProvider, AI_PROVIDERS } from "@/shared/constants/providers";
import { getModelsByProviderId, getModelKind } from "@/shared/constants/models"; import { getModelsByProviderId, getModelKind } from "@/shared/constants/models";
import { getThinkingLevels } from "open-sse/providers/thinkingLevels.js"; import { getThinkingLevels, BASE_THINKING_LEVELS } from "open-sse/providers/thinkingLevels.js";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import { useModelCaps } from "@/shared/hooks/useModelCaps"; import { useModelCaps } from "@/shared/hooks/useModelCaps";
import { translate } from "@/i18n/runtime"; import { translate } from "@/i18n/runtime";
import { fetchSuggestedModels } from "@/shared/utils/providerModelsFetcher"; import { fetchSuggestedModels } from "@/shared/utils/providerModelsFetcher";
import { getProviderCustomModelRows } from "@/shared/utils/providerCustomModels"; import { getProviderCustomModelRows } from "@/shared/utils/providerCustomModels";
import ModelRow from "./ModelRow"; import ModelRow from "./ModelRow";
import PassthroughModelsSection from "./PassthroughModelsSection";
import CompatibleModelsSection from "./CompatibleModelsSection"; import CompatibleModelsSection from "./CompatibleModelsSection";
import ConnectionRow from "./ConnectionRow"; import ConnectionRow from "./ConnectionRow";
import AddApiKeyModal from "./AddApiKeyModal"; import AddApiKeyModal from "./AddApiKeyModal";
@@ -83,6 +82,9 @@ export default function ProviderDetailPage() {
const [oneByOneSummary, setOneByOneSummary] = useState(null); const [oneByOneSummary, setOneByOneSummary] = useState(null);
const stopOneByOneRef = useRef(false); const stopOneByOneRef = useRef(false);
const [importingQoderModels, setImportingQoderModels] = useState(false); const [importingQoderModels, setImportingQoderModels] = useState(false);
// Compatible-node model fetch + the custom model whose caps are being edited.
const [fetchingCompatibleModels, setFetchingCompatibleModels] = useState(false);
const [editingModel, setEditingModel] = useState(null);
const [showTestAllKeysModal, setShowTestAllKeysModal] = useState(false); const [showTestAllKeysModal, setShowTestAllKeysModal] = useState(false);
const [testAllKeysModelId, setTestAllKeysModelId] = useState(""); const [testAllKeysModelId, setTestAllKeysModelId] = useState("");
const [testAllKeysRunning, setTestAllKeysRunning] = useState(false); const [testAllKeysRunning, setTestAllKeysRunning] = useState(false);
@@ -90,6 +92,7 @@ export default function ProviderDetailPage() {
const [testAllKeysError, setTestAllKeysError] = useState(""); const [testAllKeysError, setTestAllKeysError] = useState("");
const [testAllModelsRunning, setTestAllModelsRunning] = useState(false); const [testAllModelsRunning, setTestAllModelsRunning] = useState(false);
const [testAllModelsSummary, setTestAllModelsSummary] = useState(null); const [testAllModelsSummary, setTestAllModelsSummary] = useState(null);
const [cleaningFailedModels, setCleaningFailedModels] = useState(false);
const { copied, copy } = useCopyToClipboard(); const { copied, copy } = useCopyToClipboard();
const AG_RISK_STORAGE_KEY = "ag_risk_confirmed"; const AG_RISK_STORAGE_KEY = "ag_risk_confirmed";
@@ -175,13 +178,34 @@ export default function ProviderDetailPage() {
: providerId === "kimi" ? "Kimi API Key" : providerId === "kimi" ? "Kimi API Key"
: providerId === "qoder" ? "PAT" : providerId === "qoder" ? "PAT"
: "API Key"; : "API Key";
const providerStorageAlias = isCompatible ? providerId : providerAlias;
// Capability store lives server-side; this bundle cannot read it, so the
// thinking-level picker has to consult the rows it already fetched. A user
// assertion is authoritative in both directions.
const customLlmEntries = customModels.filter(
(entry) =>
entry.providerAlias === providerStorageAlias
&& (entry.kind || entry.type || "llm") === "llm",
);
const assertedCapsById = new Map(
customLlmEntries
.filter((entry) => entry.caps && typeof entry.caps === "object")
.map((entry) => [entry.id, entry.caps]),
);
const thinkingLevelsFor = (modelId) => {
const asserted = assertedCapsById.get(modelId);
if (asserted?.reasoning === false) return null;
const levels = getThinkingLevels(providerId, modelId);
if (levels) return levels;
// Asserted reasoning with no derivable format → generic level set.
return asserted?.reasoning === true ? BASE_THINKING_LEVELS : null;
};
// Resolve suffix "(level)" for a model when a thinking level is picked and the model supports it. // Resolve suffix "(level)" for a model when a thinking level is picked and the model supports it.
const resolveThinkingSuffix = (modelId) => { const resolveThinkingSuffix = (modelId) => {
if (!thinkingMode || thinkingMode === "auto") return null; if (!thinkingMode || thinkingMode === "auto") return null;
const levels = getThinkingLevels(providerId, modelId); const levels = thinkingLevelsFor(modelId);
return levels && levels.includes(thinkingMode) ? thinkingMode : null; return levels && levels.includes(thinkingMode) ? thinkingMode : null;
}; };
const providerStorageAlias = isCompatible ? providerId : providerAlias;
// Union of levels across this provider's reasoning models — drives the level picker options. // Union of levels across this provider's reasoning models — drives the level picker options.
// Include custom models too (e.g. manually added gpt-5.6-sol → max). // Include custom models too (e.g. manually added gpt-5.6-sol → max).
const providerThinkingLevels = (() => { const providerThinkingLevels = (() => {
@@ -190,16 +214,12 @@ export default function ProviderDetailPage() {
const addLevels = (modelId) => { const addLevels = (modelId) => {
if (!modelId || seen.has(modelId)) return; if (!modelId || seen.has(modelId)) return;
seen.add(modelId); seen.add(modelId);
const lv = getThinkingLevels(providerId, modelId); const lv = thinkingLevelsFor(modelId);
if (lv) lv.forEach((l) => { if (l !== "none") set.add(l); }); if (lv) lv.forEach((l) => { if (l !== "none") set.add(l); });
}; };
for (const m of models) addLevels(m.id); for (const m of models) addLevels(m.id);
for (const m of kiloFreeModels) addLevels(m.id); for (const m of kiloFreeModels) addLevels(m.id);
for (const entry of customModels) { for (const entry of customLlmEntries) addLevels(entry.id);
if (entry.providerAlias !== providerStorageAlias) continue;
if ((entry.kind || entry.type || "llm") !== "llm") continue;
addLevels(entry.id);
}
return set.size ? ["auto", ...[...set]] : null; return set.size ? ["auto", ...[...set]] : null;
})(); })();
const providerDisplayAlias = isCompatible const providerDisplayAlias = isCompatible
@@ -682,6 +702,64 @@ export default function ProviderDetailPage() {
} }
}; };
// Pull the node's upstream /models list and store each entry as a custom
// model under this node's storage alias (the node id for compatible nodes).
const handleFetchCompatibleModels = async () => {
if (fetchingCompatibleModels) return;
const activeConnection = connections.find((conn) => conn.isActive !== false);
if (!activeConnection) {
alert(translate("Please add an active connection first"));
return;
}
setFetchingCompatibleModels(true);
try {
const res = await fetch(`/api/providers/${activeConnection.id}/models`);
const data = await res.json();
if (!res.ok) {
alert(data.error || translate("Failed to fetch models"));
return;
}
const list = data.models || [];
if (list.length === 0) {
alert(translate("No models returned"));
return;
}
// `customModels` state is stale inside this loop (each add refetches it),
// so track what we've already sent to avoid duplicate upserts.
const added = new Set();
let importedCount = 0;
for (const model of list) {
const rawId = typeof model === "string" ? model : (model?.id || model?.name);
const modelId = typeof rawId === "string" ? rawId.trim() : "";
if (!modelId || added.has(modelId)) continue;
const exists = customModels.some(
(entry) =>
entry.providerAlias === providerStorageAlias &&
entry.id === modelId &&
(entry.kind || entry.type || "llm") === "llm",
) || Object.values(modelAliases).includes(`${providerStorageAlias}/${modelId}`);
if (exists) continue;
added.add(modelId);
await handleAddCustomModel(modelId, "llm", providerStorageAlias);
importedCount += 1;
}
if (importedCount === 0) {
alert(translate("All models already exist, no new models added"));
} else {
alert(translate("Successfully added") + ` ${importedCount} ` + translate("models"));
}
} catch (error) {
console.log("Error fetching compatible models:", error);
alert(translate("Error fetching models") + ": " + error.message);
} finally {
setFetchingCompatibleModels(false);
}
};
const handleRunOneByOneTest = async () => { const handleRunOneByOneTest = async () => {
if (oneByOneRunning) return; if (oneByOneRunning) return;
// Same population as Test Selected / routing itself: only active // Same population as Test Selected / routing itself: only active
@@ -1158,17 +1236,38 @@ export default function ProviderDetailPage() {
const isSelected = (connectionId) => selectedConnectionIds.includes(connectionId); const isSelected = (connectionId) => selectedConnectionIds.includes(connectionId);
// Every LLM model this provider exposes, before the disabled filter. Built-in
// providers have a static registry; a compatible node has no registry at all —
// its models are exactly its custom rows — so both sources have to feed one
// list or the bulk toolbar and the per-key Test modal disagree about what
// exists.
const allLlmModels = (() => {
const rows = [
...models,
...kiloFreeModels.filter((fm) => !models.some((m) => m.id === fm.id)),
]
.filter((m) => { const k = getModelKind(m); return !k || k === "llm"; })
.map((m) => ({ id: m.id, name: m.name || m.id }));
if (!isCompatible) return rows;
const seen = new Set(rows.map((row) => row.id));
for (const row of getProviderCustomModelRows({
customModels,
modelAliases,
providerAlias: providerStorageAlias,
type: "llm",
})) {
if (seen.has(row.id)) continue;
seen.add(row.id);
rows.push({ id: row.id, name: row.name || row.id });
}
return rows;
})();
// Models currently available for testing (custom + builtin LLM, minus disabled) // Models currently available for testing (custom + builtin LLM, minus disabled)
// Shared by the per-key Test modal (testModels) and the Test All Keys modal. // Shared by the per-key Test modal (testModels) and the Test All Keys modal.
const availableModels = (() => { const availableModels = (() => {
const all = [
...models,
...kiloFreeModels.filter((fm) => !models.some((m) => m.id === fm.id)),
].filter((m) => { const k = getModelKind(m); return !k || k === "llm"; });
const disabledSet = new Set(disabledModelIds); const disabledSet = new Set(disabledModelIds);
return all return allLlmModels.filter((m) => !disabledSet.has(m.id));
.filter((m) => !disabledSet.has(m.id))
.map((m) => ({ id: m.id, name: m.name || m.id }));
})(); })();
const connectionsList = ( const connectionsList = (
@@ -1352,6 +1451,28 @@ export default function ProviderDetailPage() {
setTestAllModelsRunning(false); setTestAllModelsRunning(false);
}; };
// Bulk-remove the models a test run proved unreachable. Driven from the
// section, which derives the failed set from `modelTestResults`.
const handleCleanFailedModels = async (ids) => {
if (cleaningFailedModels || !ids?.length) return;
setCleaningFailedModels(true);
try {
for (const id of ids) {
await handleDeleteCustomModel(id, "llm", providerStorageAlias);
}
setModelTestResults((prev) => {
const next = { ...prev };
for (const id of ids) delete next[id];
return next;
});
// The counts below describe the models that were just removed.
setTestAllModelsSummary(null);
setModelsTestError("");
} finally {
setCleaningFailedModels(false);
}
};
const renderModelsSection = () => { const renderModelsSection = () => {
if (isCompatible) { if (isCompatible) {
return ( return (
@@ -1362,12 +1483,32 @@ export default function ProviderDetailPage() {
customModels={customModels} customModels={customModels}
copied={copied} copied={copied}
onCopy={copy} onCopy={copy}
onSetAlias={handleSetAlias}
onDeleteAlias={handleDeleteAlias} onDeleteAlias={handleDeleteAlias}
onAddCustomModel={(modelId) => handleAddCustomModel(modelId, "llm", providerStorageAlias)} onOpenAddModel={() => {
setEditingModel(null);
setShowAddCustomModel(true);
}}
onEditModel={(modelId) => {
const row = customModels.find(
(m) => m.providerAlias === providerStorageAlias && m.id === modelId,
);
setEditingModel({ id: modelId, caps: row?.caps || null });
setShowAddCustomModel(true);
}}
onDeleteCustomModel={(modelId) => handleDeleteCustomModel(modelId, "llm", providerStorageAlias)} onDeleteCustomModel={(modelId) => handleDeleteCustomModel(modelId, "llm", providerStorageAlias)}
onFetchModels={handleFetchCompatibleModels}
fetchingModels={fetchingCompatibleModels}
connections={connections} connections={connections}
isAnthropic={isAnthropicCompatible} getCaps={getCaps}
thinkingSuffix={resolveThinkingSuffix}
disabledModelIds={disabledModelIds}
onDisableModel={handleDisableModel}
onEnableModel={handleEnableModel}
modelTestResults={modelTestResults}
testingModelIds={testingModelIds}
onTestModel={handleTestModel}
onCleanFailed={handleCleanFailedModels}
cleaningFailed={cleaningFailedModels}
/> />
); );
} }
@@ -1414,6 +1555,13 @@ export default function ProviderDetailPage() {
isFree={false} isFree={false}
caps={getCaps(`${providerId}/${model.id}`)} caps={getCaps(`${providerId}/${model.id}`)}
thinkingSuffix={resolveThinkingSuffix(model.id)} thinkingSuffix={resolveThinkingSuffix(model.id)}
onEdit={model.source === "custom" ? () => {
const row = customModels.find(
(m) => m.providerAlias === providerStorageAlias && m.id === model.id,
);
setEditingModel({ id: model.id, caps: row?.caps || null });
setShowAddCustomModel(true);
} : undefined}
/> />
))} ))}
@@ -1446,7 +1594,10 @@ export default function ProviderDetailPage() {
{/* Add model button — inline, same style as model chips */} {/* Add model button — inline, same style as model chips */}
<button <button
onClick={() => setShowAddCustomModel(true)} onClick={() => {
setEditingModel(null);
setShowAddCustomModel(true);
}}
className="flex w-full items-center justify-center gap-1.5 rounded-lg border border-dashed border-primary/40 px-3 py-2 text-xs text-primary transition-colors hover:border-primary hover:bg-primary/5 sm:w-auto" className="flex w-full items-center justify-center gap-1.5 rounded-lg border border-dashed border-primary/40 px-3 py-2 text-xs text-primary transition-colors hover:border-primary hover:bg-primary/5 sm:w-auto"
> >
<span className="material-symbols-outlined text-sm">add</span> <span className="material-symbols-outlined text-sm">add</span>
@@ -1958,7 +2109,6 @@ export default function ProviderDetailPage() {
</div> </div>
)} )}
{connectionsList} {connectionsList}
{!isCompatible && (
<div className="mt-4 grid grid-cols-1 gap-2 sm:flex"> <div className="mt-4 grid grid-cols-1 gap-2 sm:flex">
{providerId === "iflow" && ( {providerId === "iflow" && (
<Button <Button
@@ -2023,11 +2173,10 @@ export default function ProviderDetailPage() {
onClick={triggerAddConnection} onClick={triggerAddConnection}
className="w-full sm:w-auto" className="w-full sm:w-auto"
> >
Add {isCompatible ? "Add API Key" : "Add"}
</Button> </Button>
)} )}
</div> </div>
)}
</> </>
)} )}
</Card> </Card>
@@ -2053,12 +2202,10 @@ export default function ProviderDetailPage() {
</select> </select>
)} )}
</div> </div>
{!isCompatible && (() => { {(() => {
const allIds = [ const activeIds = allLlmModels
...models, .map((m) => m.id)
...kiloFreeModels.filter((fm) => !models.some((m) => m.id === fm.id)), .filter((id) => !disabledModelIds.includes(id));
].filter((m) => { const k = getModelKind(m); return !k || k === "llm"; }).map((m) => m.id);
const activeIds = allIds.filter((id) => !disabledModelIds.includes(id));
return ( return (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{activeIds.length > 0 && ( {activeIds.length > 0 && (
@@ -2321,18 +2468,21 @@ export default function ProviderDetailPage() {
isAnthropic={isAnthropicCompatible} isAnthropic={isAnthropicCompatible}
/> />
)} )}
{!isCompatible && (
<AddCustomModelModal <AddCustomModelModal
isOpen={showAddCustomModel} isOpen={showAddCustomModel}
providerAlias={providerStorageAlias} providerAlias={providerStorageAlias}
providerDisplayAlias={providerDisplayAlias} initialModelId={editingModel?.id || ""}
initialCaps={editingModel?.caps || null}
onSave={async (modelId, caps) => { onSave={async (modelId, caps) => {
await handleAddCustomModel(modelId, "llm", providerStorageAlias, caps); await handleAddCustomModel(modelId, "llm", providerStorageAlias, caps);
setShowAddCustomModel(false); setShowAddCustomModel(false);
setEditingModel(null);
}}
onClose={() => {
setShowAddCustomModel(false);
setEditingModel(null);
}} }}
onClose={() => setShowAddCustomModel(false)}
/> />
)}
{providerId === "codex" && ( {providerId === "codex" && (
<BulkImportCodexModal <BulkImportCodexModal

View File

@@ -2,7 +2,7 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import PropTypes from "prop-types"; import PropTypes from "prop-types";
import { Badge, Button, Input, Modal, Select } from "@/shared/components"; import { Button, Input, Modal, Select } from "@/shared/components";
const VARIANT_CONFIG = { const VARIANT_CONFIG = {
openai: { openai: {
@@ -12,7 +12,6 @@ const VARIANT_CONFIG = {
namePlaceholder: "OpenAI Compatible (Prod)", namePlaceholder: "OpenAI Compatible (Prod)",
prefixPlaceholder: "oc-prod", prefixPlaceholder: "oc-prod",
baseUrlHint: "Use the base URL (ending in /v1) for your OpenAI-compatible API.", baseUrlHint: "Use the base URL (ending in /v1) for your OpenAI-compatible API.",
modelIdPlaceholder: "e.g. gpt-4, claude-3-opus",
errorLabel: "OpenAI Compatible", errorLabel: "OpenAI Compatible",
hasApiType: true, hasApiType: true,
}, },
@@ -23,7 +22,6 @@ const VARIANT_CONFIG = {
namePlaceholder: "Anthropic Compatible (Prod)", namePlaceholder: "Anthropic Compatible (Prod)",
prefixPlaceholder: "ac-prod", prefixPlaceholder: "ac-prod",
baseUrlHint: "Use the base URL (ending in /v1) for your Anthropic-compatible API. The system will append /messages.", baseUrlHint: "Use the base URL (ending in /v1) for your Anthropic-compatible API. The system will append /messages.",
modelIdPlaceholder: "e.g. claude-3-opus",
errorLabel: "Anthropic Compatible", errorLabel: "Anthropic Compatible",
hasApiType: false, hasApiType: false,
}, },
@@ -34,6 +32,9 @@ const API_TYPE_OPTIONS = [
{ value: "responses", label: "Responses API" }, { value: "responses", label: "Responses API" },
]; ];
// Creating a node only registers the endpoint. API keys are managed on the
// node's own page afterwards, exactly like the built-in providers — so this
// form has no credential field and no connectivity check.
function AddCompatibleModal({ variant, isOpen, onClose, onCreated }) { function AddCompatibleModal({ variant, isOpen, onClose, onCreated }) {
const config = VARIANT_CONFIG[variant]; const config = VARIANT_CONFIG[variant];
const initialFormData = () => ({ const initialFormData = () => ({
@@ -45,21 +46,13 @@ function AddCompatibleModal({ variant, isOpen, onClose, onCreated }) {
const [formData, setFormData] = useState(initialFormData); const [formData, setFormData] = useState(initialFormData);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [checkKey, setCheckKey] = useState("");
const [checkModelId, setCheckModelId] = useState("");
const [validating, setValidating] = useState(false);
const [validationResult, setValidationResult] = useState(null);
// openai: reset baseUrl when apiType changes; anthropic: reset checks when opened // openai re-defaults baseUrl when apiType changes; reset so a cancelled
// attempt doesn't prefill the next one.
useEffect(() => { useEffect(() => {
if (config.hasApiType) { if (!isOpen || !config.hasApiType) return;
setFormData((prev) => ({ ...prev, baseUrl: config.defaultBaseUrl })); setFormData((prev) => ({ ...prev, baseUrl: config.defaultBaseUrl }));
} else if (isOpen) { }, [isOpen, config.hasApiType, config.defaultBaseUrl]);
setValidationResult(null);
setCheckKey("");
setCheckModelId("");
}
}, [config.hasApiType ? formData.apiType : isOpen]);
const handleSubmit = async () => { const handleSubmit = async () => {
if (!formData.name.trim() || !formData.prefix.trim() || !formData.baseUrl.trim()) return; if (!formData.name.trim() || !formData.prefix.trim() || !formData.baseUrl.trim()) return;
@@ -78,10 +71,8 @@ function AddCompatibleModal({ variant, isOpen, onClose, onCreated }) {
}); });
const data = await res.json(); const data = await res.json();
if (res.ok) { if (res.ok) {
onCreated(data.node); onCreated(data);
setFormData(initialFormData()); setFormData(initialFormData());
setCheckKey("");
setValidationResult(null);
} }
} catch (error) { } catch (error) {
console.log(`Error creating ${config.errorLabel} node:`, error); console.log(`Error creating ${config.errorLabel} node:`, error);
@@ -90,49 +81,6 @@ function AddCompatibleModal({ variant, isOpen, onClose, onCreated }) {
} }
}; };
const handleValidate = async () => {
setValidating(true);
try {
const res = await fetch("/api/provider-nodes/validate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
baseUrl: formData.baseUrl,
apiKey: checkKey,
type: config.type,
modelId: checkModelId.trim() || undefined,
}),
});
const data = await res.json();
setValidationResult(data);
} catch {
setValidationResult({ valid: false, error: "Network error" });
} finally {
setValidating(false);
}
};
const renderValidationResult = () => {
if (!validationResult) return null;
const { valid, error, method } = validationResult;
if (valid) {
return (
<>
<Badge variant="success">Valid</Badge>
{method === "chat" && (
<span className="text-sm text-text-muted">(via inference test)</span>
)}
</>
);
}
return (
<div className="flex flex-col gap-1">
<Badge variant="error">Invalid</Badge>
{error && <span className="text-sm text-red-500">{error}</span>}
</div>
);
};
return ( return (
<Modal isOpen={isOpen} title={config.title} onClose={onClose}> <Modal isOpen={isOpen} title={config.title} onClose={onClose}>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
@@ -141,7 +89,7 @@ function AddCompatibleModal({ variant, isOpen, onClose, onCreated }) {
value={formData.name} value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })} onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder={config.namePlaceholder} placeholder={config.namePlaceholder}
hint="Required. A friendly label for this node." hint="Required. A friendly label for this provider node."
/> />
<Input <Input
label="Prefix" label="Prefix"
@@ -165,30 +113,9 @@ function AddCompatibleModal({ variant, isOpen, onClose, onCreated }) {
placeholder={config.defaultBaseUrl} placeholder={config.defaultBaseUrl}
hint={config.baseUrlHint} hint={config.baseUrlHint}
/> />
<Input <p className="text-xs text-text-muted">
label="API Key (for Check)" {"You can add API keys and models from the provider page after creating it."}
type="password" </p>
value={checkKey}
onChange={(e) => setCheckKey(e.target.value)}
/>
<Input
label="Model ID (optional)"
value={checkModelId}
onChange={(e) => setCheckModelId(e.target.value)}
placeholder={config.modelIdPlaceholder}
hint="If provider lacks /models endpoint, enter a model ID to validate via chat/completions instead."
/>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
<Button
onClick={handleValidate}
disabled={!checkKey || validating || !formData.baseUrl.trim()}
variant="secondary"
className="w-full sm:w-auto"
>
{validating ? "Checking..." : "Check"}
</Button>
{renderValidationResult()}
</div>
<div className="flex flex-col gap-2 sm:flex-row"> <div className="flex flex-col gap-2 sm:flex-row">
<Button <Button
onClick={handleSubmit} onClick={handleSubmit}

View File

@@ -174,6 +174,20 @@ export default function ProvidersPage() {
fetchData(); fetchData();
}, []); }, []);
// POST /api/provider-nodes registers the endpoint only; keys and models are
// added from the node's own page, like the built-in providers.
const handleCompatibleCreated = (result, closeModal) => {
const node = result?.node || result;
if (node) setProviderNodes((prev) => [...prev, node]);
closeModal();
if (node) {
notify.success(
`${node.name || "Provider"} created. Add an API key from its page to start using it.`,
"Provider created",
);
}
};
const getProviderStats = (providerId, authType) => { const getProviderStats = (providerId, authType) => {
const authTypes = Array.isArray(authType) ? authType : [authType]; const authTypes = Array.isArray(authType) ? authType : [authType];
const providerConnections = connections.filter( const providerConnections = connections.filter(
@@ -695,19 +709,17 @@ export default function ProvidersPage() {
variant="openai" variant="openai"
isOpen={showAddCompatibleModal} isOpen={showAddCompatibleModal}
onClose={() => setShowAddCompatibleModal(false)} onClose={() => setShowAddCompatibleModal(false)}
onCreated={(node) => { onCreated={(result) =>
setProviderNodes((prev) => [...prev, node]); handleCompatibleCreated(result, () => setShowAddCompatibleModal(false))
setShowAddCompatibleModal(false); }
}}
/> />
<AddCompatibleModal <AddCompatibleModal
variant="anthropic" variant="anthropic"
isOpen={showAddAnthropicCompatibleModal} isOpen={showAddAnthropicCompatibleModal}
onClose={() => setShowAddAnthropicCompatibleModal(false)} onClose={() => setShowAddAnthropicCompatibleModal(false)}
onCreated={(node) => { onCreated={(result) =>
setProviderNodes((prev) => [...prev, node]); handleCompatibleCreated(result, () => setShowAddAnthropicCompatibleModal(false))
setShowAddAnthropicCompatibleModal(false); }
}}
/> />
{/* Test Results Modal */} {/* Test Results Modal */}

View File

@@ -1,6 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { getCustomModels, addCustomModel, deleteCustomModel } from "@/models"; import { getCustomModels, addCustomModel, deleteCustomModel } from "@/models";
import { CAPACITY_META } from "@/shared/constants/models"; import { CAPACITY_META } from "@/shared/constants/models";
import { invalidateUserCaps } from "@/lib/modelCaps/userCaps.js";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -34,6 +35,7 @@ export async function POST(request) {
} }
const cleanCaps = sanitizeCaps(caps); const cleanCaps = sanitizeCaps(caps);
const added = await addCustomModel({ providerAlias, id, type: type || "llm", name, ...(cleanCaps ? { caps: cleanCaps } : {}) }); const added = await addCustomModel({ providerAlias, id, type: type || "llm", name, ...(cleanCaps ? { caps: cleanCaps } : {}) });
invalidateUserCaps();
return NextResponse.json({ success: true, added }); return NextResponse.json({ success: true, added });
} catch (error) { } catch (error) {
console.log("Error adding custom model:", error); console.log("Error adding custom model:", error);
@@ -52,7 +54,7 @@ export async function DELETE(request) {
return NextResponse.json({ error: "providerAlias and id required" }, { status: 400 }); return NextResponse.json({ error: "providerAlias and id required" }, { status: 400 });
} }
await deleteCustomModel({ providerAlias, id, type }); await deleteCustomModel({ providerAlias, id, type });
return NextResponse.json({ success: true }); invalidateUserCaps();
} catch (error) { } catch (error) {
console.log("Error deleting custom model:", error); console.log("Error deleting custom model:", error);
return NextResponse.json({ error: "Failed to delete custom model" }, { status: 500 }); return NextResponse.json({ error: "Failed to delete custom model" }, { status: 500 });

View File

@@ -1,7 +1,11 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { createProviderNode, getProviderNodes } from "@/models"; import {
createProviderNode,
getProviderNodes,
} from "@/models";
import { OPENAI_COMPATIBLE_PREFIX, ANTHROPIC_COMPATIBLE_PREFIX, CUSTOM_EMBEDDING_PREFIX } from "@/shared/constants/providers"; import { OPENAI_COMPATIBLE_PREFIX, ANTHROPIC_COMPATIBLE_PREFIX, CUSTOM_EMBEDDING_PREFIX } from "@/shared/constants/providers";
import { generateId } from "@/shared/utils"; import { generateId } from "@/shared/utils";
import { normalizeProxyConfig } from "@/lib/nodeConnection";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
@@ -28,75 +32,100 @@ export async function GET() {
} }
} }
// POST /api/provider-nodes - Create provider node // Build the node row for a given type, or `{ error }` when the type/fields are invalid.
export async function POST(request) { function planNode({ type, name, prefix, apiType, baseUrl }) {
try { if (type === "openai-compatible") {
const body = await request.json();
const { name, prefix, apiType, baseUrl, type } = body;
if (!name?.trim()) {
return NextResponse.json({ error: "Name is required" }, { status: 400 });
}
if (!prefix?.trim()) {
return NextResponse.json({ error: "Prefix is required" }, { status: 400 });
}
// Determine type
const nodeType = type || "openai-compatible";
if (nodeType === "openai-compatible") {
if (!apiType || !["chat", "responses"].includes(apiType)) { if (!apiType || !["chat", "responses"].includes(apiType)) {
return NextResponse.json({ error: "Invalid OpenAI compatible API type" }, { status: 400 }); return { error: "Invalid OpenAI compatible API type" };
} }
return {
const node = await createProviderNode({ node: {
id: `${OPENAI_COMPATIBLE_PREFIX}${apiType}-${generateId()}`, id: `${OPENAI_COMPATIBLE_PREFIX}${apiType}-${generateId()}`,
type: "openai-compatible", type: "openai-compatible",
prefix: prefix.trim(), prefix,
apiType, apiType,
baseUrl: (baseUrl || OPENAI_COMPATIBLE_DEFAULTS.baseUrl).trim(), baseUrl: (baseUrl || OPENAI_COMPATIBLE_DEFAULTS.baseUrl).trim(),
name: name.trim(), name,
}); },
return NextResponse.json({ node }, { status: 201 }); };
} }
if (nodeType === "custom-embedding") { if (type === "custom-embedding") {
// Strip trailing slash and /embeddings if user pasted full endpoint // Strip trailing slash and /embeddings if user pasted full endpoint
let sanitizedBaseUrl = (baseUrl || CUSTOM_EMBEDDING_DEFAULTS.baseUrl).trim().replace(/\/$/, ""); let sanitizedBaseUrl = (baseUrl || CUSTOM_EMBEDDING_DEFAULTS.baseUrl).trim().replace(/\/$/, "");
if (sanitizedBaseUrl.endsWith("/embeddings")) { if (sanitizedBaseUrl.endsWith("/embeddings")) {
sanitizedBaseUrl = sanitizedBaseUrl.slice(0, -"/embeddings".length); sanitizedBaseUrl = sanitizedBaseUrl.slice(0, -"/embeddings".length);
} }
return {
const node = await createProviderNode({ node: {
id: `${CUSTOM_EMBEDDING_PREFIX}${generateId()}`, id: `${CUSTOM_EMBEDDING_PREFIX}${generateId()}`,
type: "custom-embedding", type: "custom-embedding",
prefix: prefix.trim(), prefix,
baseUrl: sanitizedBaseUrl, baseUrl: sanitizedBaseUrl,
name: name.trim(), name,
}); },
return NextResponse.json({ node }, { status: 201 }); };
} }
if (nodeType === "anthropic-compatible") { if (type === "anthropic-compatible") {
// Sanitize Base URL: remove trailing slash, and remove trailing /messages if user added it // Sanitize Base URL: remove trailing slash, and remove trailing /messages if user added it
// This prevents double-appending /messages at runtime // This prevents double-appending /messages at runtime
let sanitizedBaseUrl = (baseUrl || ANTHROPIC_COMPATIBLE_DEFAULTS.baseUrl).trim().replace(/\/$/, ""); let sanitizedBaseUrl = (baseUrl || ANTHROPIC_COMPATIBLE_DEFAULTS.baseUrl).trim().replace(/\/$/, "");
if (sanitizedBaseUrl.endsWith("/messages")) { if (sanitizedBaseUrl.endsWith("/messages")) {
sanitizedBaseUrl = sanitizedBaseUrl.slice(0, -9); // remove /messages sanitizedBaseUrl = sanitizedBaseUrl.slice(0, -9); // remove /messages
} }
return {
const node = await createProviderNode({ node: {
id: `${ANTHROPIC_COMPATIBLE_PREFIX}${generateId()}`, id: `${ANTHROPIC_COMPATIBLE_PREFIX}${generateId()}`,
type: "anthropic-compatible", type: "anthropic-compatible",
prefix: prefix.trim(), prefix,
baseUrl: sanitizedBaseUrl, baseUrl: sanitizedBaseUrl,
name: name.trim(), name,
}); },
return NextResponse.json({ node }, { status: 201 }); };
} }
return NextResponse.json({ error: "Invalid provider node type" }, { status: 400 }); return { error: "Invalid provider node type" };
}
/**
* POST /api/provider-nodes - Create provider node.
*
* Registers the endpoint only. API keys are added later from the node's own
* page via POST /api/providers, exactly like the built-in providers, so this
* route never touches credentials and cannot half-create a node.
*/
export async function POST(request) {
try {
const body = await request.json();
const { apiType, baseUrl, type } = body;
const name = typeof body.name === "string" ? body.name.trim() : "";
const prefix = typeof body.prefix === "string" ? body.prefix.trim() : "";
if (!name) {
return NextResponse.json({ error: "Name is required" }, { status: 400 });
}
if (!prefix) {
return NextResponse.json({ error: "Prefix is required" }, { status: 400 });
}
// Determine type
const nodeType = type || "openai-compatible";
// Nodes carry no proxy of their own; reject stray proxy fields rather than
// silently dropping them, so a client that means to set one gets told.
const proxyConfig = normalizeProxyConfig(body);
if (proxyConfig.error) {
return NextResponse.json({ error: proxyConfig.error }, { status: 400 });
}
const plan = planNode({ type: nodeType, name, prefix, apiType, baseUrl });
if (plan.error) {
return NextResponse.json({ error: plan.error }, { status: 400 });
}
const node = await createProviderNode(plan.node);
return NextResponse.json({ node }, { status: 201 });
} catch (error) { } catch (error) {
console.log("Error creating provider node:", error); console.log("Error creating provider node:", error);
return NextResponse.json({ error: "Failed to create provider node" }, { status: 500 }); return NextResponse.json({ error: "Failed to create provider node" }, { status: 500 });

View File

@@ -9,25 +9,10 @@ import {
import { APIKEY_PROVIDERS } from "@/shared/constants/config"; import { APIKEY_PROVIDERS } from "@/shared/constants/config";
import { AI_PROVIDERS, FREE_TIER_PROVIDERS, WEB_COOKIE_PROVIDERS, isOpenAICompatibleProvider, isAnthropicCompatibleProvider, isCustomEmbeddingProvider } from "@/shared/constants/providers"; import { AI_PROVIDERS, FREE_TIER_PROVIDERS, WEB_COOKIE_PROVIDERS, isOpenAICompatibleProvider, isAnthropicCompatibleProvider, isCustomEmbeddingProvider } from "@/shared/constants/providers";
import { normalizeProviderId, normalizeProviderSpecificData } from "@/lib/providerNormalization"; import { normalizeProviderId, normalizeProviderSpecificData } from "@/lib/providerNormalization";
import { buildNodeProviderSpecificData, isNodeBackedProvider, normalizeProxyConfig } from "@/lib/nodeConnection";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
function normalizeProxyConfig(body = {}) {
const enabled = body?.connectionProxyEnabled === true;
const url = typeof body?.connectionProxyUrl === "string" ? body.connectionProxyUrl.trim() : "";
const noProxy = typeof body?.connectionNoProxy === "string" ? body.connectionNoProxy.trim() : "";
if (enabled && !url) {
return { error: "Connection proxy URL is required when connection proxy is enabled" };
}
return {
connectionProxyEnabled: enabled,
connectionProxyUrl: url,
connectionNoProxy: noProxy,
};
}
async function normalizeProxyPoolId(proxyPoolId) { async function normalizeProxyPoolId(proxyPoolId) {
if (proxyPoolId === undefined || proxyPoolId === null || proxyPoolId === "" || proxyPoolId === "__none__") { if (proxyPoolId === undefined || proxyPoolId === null || proxyPoolId === "" || proxyPoolId === "__none__") {
return { proxyPoolId: null }; return { proxyPoolId: null };
@@ -128,46 +113,26 @@ export async function POST(request) {
let providerSpecificData = normalizeProviderSpecificData(provider, body, body.providerSpecificData); let providerSpecificData = normalizeProviderSpecificData(provider, body, body.providerSpecificData);
// Compatible LLM nodes support multiple API-key connections (key pool); runtime // Node-backed providers (compatible LLM + custom embedding) support multiple
// rotates/fails over via getProviderCredentials. Embedding nodes stay single-connection. // API-key connections (key pool); runtime rotates/fails over via
if (isOpenAICompatibleProvider(provider)) { // getProviderCredentials. baseUrl/prefix always come from the node row, never
// from the client, so a key can't be pointed at another endpoint.
if (isNodeBackedProvider(provider)) {
const node = await getProviderNodeById(provider); const node = await getProviderNodeById(provider);
if (!node) { if (!node) {
return NextResponse.json({ error: "OpenAI Compatible node not found" }, { status: 404 }); const label = isOpenAICompatibleProvider(provider)
? "OpenAI Compatible"
: isAnthropicCompatibleProvider(provider)
? "Anthropic Compatible"
: "Custom Embedding";
return NextResponse.json({ error: `${label} node not found` }, { status: 404 });
} }
providerSpecificData = { providerSpecificData = buildNodeProviderSpecificData(node);
prefix: node.prefix,
apiType: node.apiType,
baseUrl: node.baseUrl,
nodeName: node.name,
};
} else if (isAnthropicCompatibleProvider(provider)) {
const node = await getProviderNodeById(provider);
if (!node) {
return NextResponse.json({ error: "Anthropic Compatible node not found" }, { status: 404 });
}
providerSpecificData = {
prefix: node.prefix,
baseUrl: node.baseUrl,
nodeName: node.name,
};
} else if (isCustomEmbeddingProvider(provider)) {
const node = await getProviderNodeById(provider);
if (!node) {
return NextResponse.json({ error: "Custom Embedding node not found" }, { status: 404 });
}
providerSpecificData = {
prefix: node.prefix,
baseUrl: node.baseUrl,
nodeName: node.name,
};
} }
const mergedProviderSpecificData = { const mergedProviderSpecificData = {
...(providerSpecificData || {}), ...(providerSpecificData || {}),
connectionProxyEnabled: proxyConfig.connectionProxyEnabled, ...proxyConfig,
connectionProxyUrl: proxyConfig.connectionProxyUrl,
connectionNoProxy: proxyConfig.connectionNoProxy,
}; };
if (proxyPoolId !== null) { if (proxyPoolId !== null) {

View File

@@ -423,6 +423,7 @@ export async function buildModelsList(kindFilter, options = {}) {
.filter((modelId) => typeof modelId === "string" && modelId.trim() !== ""); .filter((modelId) => typeof modelId === "string" && modelId.trim() !== "");
const customModelKindById = new Map(); const customModelKindById = new Map();
const customModelCapsById = new Map();
const customModelIds = customModels const customModelIds = customModels
.filter((m) => { .filter((m) => {
if (!m?.id) return false; if (!m?.id) return false;
@@ -435,7 +436,10 @@ export async function buildModelsList(kindFilter, options = {}) {
}) })
.map((m) => { .map((m) => {
const modelId = String(m.id).trim(); const modelId = String(m.id).trim();
if (modelId) customModelKindById.set(modelId, getModelKind(m) || LLM_KIND); if (modelId) {
customModelKindById.set(modelId, getModelKind(m) || LLM_KIND);
if (m.caps && typeof m.caps === "object") customModelCapsById.set(modelId, m.caps);
}
return modelId; return modelId;
}) })
.filter((modelId) => modelId !== ""); .filter((modelId) => modelId !== "");
@@ -484,10 +488,15 @@ export async function buildModelsList(kindFilter, options = {}) {
// { id, name } — no per-model capability data. Fall back to the same // { id, name } — no per-model capability data. Fall back to the same
// pattern-matched capabilities the dashboard uses (useModelCaps.js) so // pattern-matched capabilities the dashboard uses (useModelCaps.js) so
// dynamically-discovered LLM models still surface vision/reasoning/search/tools. // dynamically-discovered LLM models still surface vision/reasoning/search/tools.
const caps = liveCapabilitiesById.get(modelId) const caps = {
...(liveCapabilitiesById.get(modelId)
|| capabilitiesFromServiceKind(customKind || liveKind) || capabilitiesFromServiceKind(customKind || liveKind)
|| (kind === LLM_KIND ? getCapabilitiesForModel(providerId, modelId) : null); || (kind === LLM_KIND ? getCapabilitiesForModel(providerId, modelId) : null)),
if (caps) model.capabilities = caps; // Explicit user toggles win over service-kind/live caps so external CLIs
// (Codex, Claude Code) see what the user asserted, not a guess.
...(customModelCapsById.get(modelId) || {}),
};
if (Object.keys(caps).length) model.capabilities = caps;
// Token limits under the snake_case names the OpenAI/OpenRouter // Token limits under the snake_case names the OpenAI/OpenRouter
// convention uses. `capabilities.contextWindow` is camelCase and nested, // convention uses. `capabilities.contextWindow` is camelCase and nested,
// so clients matching context_length find nothing, fall back to guessing // so clients matching context_length find nothing, fall back to guessing

13
src/app/fonts-inter.css Normal file
View File

@@ -0,0 +1,13 @@
/* Self-hosted Inter so `npm run build` needs no outbound access to fonts.googleapis.com
* /fonts.gstatic.com (offline and mirrored CI/Docker builders fail on those hosts).
* Files + unicode-range match what next/font/google emitted for `subsets: ["latin"]`
* (all 7 served subsets), so rendered typography is unchanged. */
@font-face{font-family:'Inter';font-style:normal;font-weight:100 900;font-display:swap;src:url(/fonts/inter-latin.woff2)format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
@font-face{font-family:'Inter';font-style:normal;font-weight:100 900;font-display:swap;src:url(/fonts/inter-latin-ext.woff2)format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF}
@font-face{font-family:'Inter';font-style:normal;font-weight:100 900;font-display:swap;src:url(/fonts/inter-vietnamese.woff2)format('woff2');unicode-range:U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329, U+1EA0-1EF9, U+20AB}
@font-face{font-family:'Inter';font-style:normal;font-weight:100 900;font-display:swap;src:url(/fonts/inter-greek.woff2)format('woff2');unicode-range:U+0370-0377, U+037A-037F, U+0384-038A, U+038C, U+038E-03A1, U+03A3-03FF}
@font-face{font-family:'Inter';font-style:normal;font-weight:100 900;font-display:swap;src:url(/fonts/inter-greek-ext.woff2)format('woff2');unicode-range:U+1F00-1FFF}
@font-face{font-family:'Inter';font-style:normal;font-weight:100 900;font-display:swap;src:url(/fonts/inter-cyrillic.woff2)format('woff2');unicode-range:U+0301, U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116}
@font-face{font-family:'Inter';font-style:normal;font-weight:100 900;font-display:swap;src:url(/fonts/inter-cyrillic-ext.woff2)format('woff2');unicode-range:U+0460-052F, U+1C80-1C8A, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F}
@font-face{font-family:'Inter Fallback';src:local(Arial);ascent-override:90.44%;descent-override:22.52%;line-gap-override:0.0%;size-adjust:107.12%}

View File

@@ -1,4 +1,4 @@
import { Inter } from "next/font/google"; import "./fonts-inter.css";
import { GoogleAnalytics } from "@next/third-parties/google"; import { GoogleAnalytics } from "@next/third-parties/google";
import "material-symbols/outlined.css"; import "material-symbols/outlined.css";
import "./globals.css"; import "./globals.css";
@@ -11,11 +11,6 @@ import { RuntimeI18nProvider } from "@/i18n/RuntimeI18nProvider";
// Hook console immediately at module load time (server-side only, runs once) // Hook console immediately at module load time (server-side only, runs once)
initConsoleLogCapture(); initConsoleLogCapture();
const inter = Inter({
subsets: ["latin"],
variable: "--font-inter",
});
export const metadata = { export const metadata = {
title: "9Router - AI Infrastructure Management", title: "9Router - AI Infrastructure Management",
description: "One endpoint for all your AI providers. Manage keys, monitor usage, and scale effortlessly.", description: "One endpoint for all your AI providers. Manage keys, monitor usage, and scale effortlessly.",
@@ -32,6 +27,16 @@ export default function RootLayout({ children }) {
return ( return (
<html lang="en" suppressHydrationWarning> <html lang="en" suppressHydrationWarning>
<head> <head>
{/* Preload the primary latin subset. `crossorigin` is required even same-origin:
font fetches always use CORS mode, so a plain preload would not match the
@font-face request and would double-download. Mirrors next/font output. */}
<link
rel="preload"
href="/fonts/inter-latin.woff2"
as="font"
type="font/woff2"
crossOrigin="anonymous"
/>
{/* Apply persisted theme before first paint so a reload does not flash the {/* Apply persisted theme before first paint so a reload does not flash the
default (light) theme before the client store hydrates. Mirrors the default (light) theme before the client store hydrates. Mirrors the
zustand-persist "theme" key and the `dark` class applyTheme() sets. */} zustand-persist "theme" key and the `dark` class applyTheme() sets. */}
@@ -46,7 +51,7 @@ export default function RootLayout({ children }) {
}} }}
/> />
</head> </head>
<body className={`${inter.variable} font-sans antialiased`}> <body className="font-sans antialiased">
<ThemeProvider> <ThemeProvider>
<RuntimeI18nProvider> <RuntimeI18nProvider>
{children} {children}

View File

@@ -8,6 +8,11 @@ export async function register() {
const { installCatalogSource } = await import("open-sse/providers/catalogOverride.js"); const { installCatalogSource } = await import("open-sse/providers/catalogOverride.js");
await installCatalogSource(); await installCatalogSource();
// Same push-in pattern: lets capabilities.js honor the user's own
// vision/reasoning toggles without importing SQLite into the browser bundle.
const { installUserCapsSource } = await import("@/lib/modelCaps/userCaps.js");
installUserCapsSource();
const { startModelCatalogSync } = await import("@/lib/modelCatalog/sync.js"); const { startModelCatalogSync } = await import("@/lib/modelCatalog/sync.js");
startModelCatalogSync(); startModelCatalogSync();
} }

View File

@@ -31,6 +31,8 @@ export async function getCustomModels() {
// Atomic upsert inside transaction to prevent duplicate races. // Atomic upsert inside transaction to prevent duplicate races.
// Re-adding an existing model updates caps/name without resetting omitted fields. // Re-adding an existing model updates caps/name without resetting omitted fields.
// `caps` is merged key-by-key so a partial edit (one toggled capacity) keeps the
// assertions already stored for the other capacities.
export async function addCustomModel({ providerAlias, id, type = "llm", name, caps }) { export async function addCustomModel({ providerAlias, id, type = "llm", name, caps }) {
const k = customKey(providerAlias, id, type); const k = customKey(providerAlias, id, type);
const db = await getAdapter(); const db = await getAdapter();
@@ -39,7 +41,11 @@ export async function addCustomModel({ providerAlias, id, type = "llm", name, ca
const row = db.get(`SELECT value FROM kv WHERE scope = 'customModels' AND key = ?`, [k]); const row = db.get(`SELECT value FROM kv WHERE scope = 'customModels' AND key = ?`, [k]);
if (row) { if (row) {
const prev = parseJson(row.value) || {}; const prev = parseJson(row.value) || {};
const next = { ...prev, ...(name ? { name } : {}), ...(caps ? { caps } : {}) }; const next = {
...prev,
...(name ? { name } : {}),
...(caps ? { caps: { ...(prev.caps || {}), ...caps } } : {}),
};
db.run(`UPDATE kv SET value = ? WHERE scope = 'customModels' AND key = ?`, [stringifyJson(next), k]); db.run(`UPDATE kv SET value = ? WHERE scope = 'customModels' AND key = ?`, [stringifyJson(next), k]);
return; return;
} }

View File

@@ -13,6 +13,7 @@ export {
getApiKeys, getApiKeyById, createApiKey, updateApiKey, importApiKey, deleteApiKey, validateApiKey, getApiKeyByKey, getApiKeys, getApiKeyById, createApiKey, updateApiKey, importApiKey, deleteApiKey, validateApiKey, getApiKeyByKey,
getCombos, getComboById, getComboByName, getCombos, getComboById, getComboByName,
createCombo, updateCombo, deleteCombo, reorderCombos, createCombo, updateCombo, deleteCombo, reorderCombos,
getModelAliases, setModelAlias, deleteModelAlias,
getCustomModels, addCustomModel, deleteCustomModel, getCustomModels, addCustomModel, deleteCustomModel,
getMitmAlias, setMitmAliasAll, getMitmAlias, setMitmAliasAll,
getPricing, getPricingForModel, updatePricing, resetPricing, resetAllPricing, getPricing, getPricingForModel, updatePricing, resetPricing, resetAllPricing,

View File

@@ -0,0 +1,111 @@
/**
* User-asserted model capabilities.
*
* The dashboard's "Add / Edit Model" toggles store `{ vision, reasoning, ... }`
* on the custom-model row (kv scope `customModels`). `getCapabilitiesForModel`
* is synchronous and runs per request, and its catalog/name layers are strictly
* additive — nothing in open-sse can turn a capability OFF. So an explicit user
* assertion has to be applied as the last step of resolution, which is what this
* module installs via `setUserCapsSource`.
*
* The snapshot is refreshed lazily: a lookup on a stale cache returns the
* previous snapshot and kicks off a background rebuild, so the request path never
* awaits SQLite.
*/
import { getCustomModels } from "@/lib/db/index.js";
import { setUserCapsSource } from "open-sse/providers/capabilities.js";
import { PROVIDER_ID_TO_ALIAS } from "open-sse/config/providerModels.js";
import { getProviderAlias } from "@/shared/constants/providers";
const REFRESH_TTL_MS = 5000;
// Next.js gives instrumentation and each API route their own copy of this module
// (see SOURCE_SLOTS in open-sse/providers/capabilities.js), so the cache lives on
// globalThis: one snapshot for the whole process means a dashboard write becomes
// visible to the request path immediately instead of after a per-bundle TTL.
const STATE = (globalThis.__9R_USER_CAPS ||= {
snapshot: new Map(), // `${storageAlias}|${modelId}` -> caps object
loadedAt: 0,
inflight: null,
});
function mapKey(alias, modelId) {
return `${alias}|${modelId}`;
}
// Custom rows are keyed by display alias for built-ins and by node id for
// compatible providers; a runtime lookup may arrive with either.
function candidateKeys(provider, modelId) {
if (!provider) return [];
const keys = [mapKey(provider, modelId)];
const alias = PROVIDER_ID_TO_ALIAS[provider] || getProviderAlias(provider);
if (alias && alias !== provider) keys.push(mapKey(alias, modelId));
return keys;
}
function build(models) {
const next = new Map();
for (const m of models || []) {
if (!m?.providerAlias || !m?.id) continue;
const caps = m.caps;
if (!caps || typeof caps !== "object") continue;
next.set(mapKey(m.providerAlias, m.id), caps);
}
return next;
}
async function rebuild() {
const models = await getCustomModels();
STATE.snapshot = build(models);
STATE.loadedAt = Date.now();
}
function scheduleRefresh() {
if (STATE.inflight) return;
STATE.inflight = rebuild()
.catch((error) => {
console.log("[userCaps] refresh failed:", error?.message || error);
})
.finally(() => {
STATE.inflight = null;
});
}
/**
* Sync lookup used as the engine's user-caps source.
* @returns {object|null} asserted caps, or null when the user asserted nothing
* (the caller then keeps heuristic results untouched).
*/
export function getUserCapsFor(provider, model) {
if (!model) return null;
if (Date.now() - STATE.loadedAt > REFRESH_TTL_MS) scheduleRefresh();
const snapshot = STATE.snapshot;
const cleanModel = String(model).includes("/") ? String(model).split("/").pop() : String(model);
for (const key of candidateKeys(provider, model)) {
const hit = snapshot.get(key);
if (hit) return hit;
}
if (cleanModel !== model) {
for (const key of candidateKeys(provider, cleanModel)) {
const hit = snapshot.get(key);
if (hit) return hit;
}
}
return null;
}
/** Force the next lookup to rebuild (call after any custom-model write). */
export function invalidateUserCaps() {
STATE.loadedAt = 0;
// Start the rebuild now instead of on the next read: the write route is a
// different bundle, so this is what makes a fresh toggle visible without
// costing the request path a SQLite await.
scheduleRefresh();
}
/** Wire the lookup into the engine. Server startup only. */
export function installUserCapsSource() {
setUserCapsSource(getUserCapsFor);
scheduleRefresh();
}

56
src/lib/nodeConnection.js Normal file
View File

@@ -0,0 +1,56 @@
/**
* Shared helpers for node-backed provider connections.
*
* `/api/providers` POST derives a connection's endpoint fields from the node
* row, never from the client, so a stale form cannot point a key at another
* endpoint. Keep the shaping here.
*/
import {
isOpenAICompatibleProvider,
isAnthropicCompatibleProvider,
isCustomEmbeddingProvider,
} from "@/shared/constants/providers";
/**
* Derive the node-facing part of `providerSpecificData` from a node row.
* OpenAI-compatible nodes carry `apiType` (chat|responses); the other node
* kinds do not, so the key is omitted rather than stored as undefined.
*/
export function buildNodeProviderSpecificData(node) {
if (!node) return {};
const id = node.id || node.provider;
// Key order mirrors /api/providers POST so serialized diffs stay comparable.
return isOpenAICompatibleProvider(id)
? { prefix: node.prefix, apiType: node.apiType, baseUrl: node.baseUrl, nodeName: node.name }
: { prefix: node.prefix, baseUrl: node.baseUrl, nodeName: node.name };
}
/** True when a provider id is backed by a `providerNodes` row. */
export function isNodeBackedProvider(provider) {
return (
isOpenAICompatibleProvider(provider) ||
isAnthropicCompatibleProvider(provider) ||
isCustomEmbeddingProvider(provider)
);
}
/**
* Validate + normalize per-connection proxy fields from a request body.
* Returns `{ error }` when proxy is enabled without a URL.
*/
export function normalizeProxyConfig(body = {}) {
const enabled = body?.connectionProxyEnabled === true;
const url = typeof body?.connectionProxyUrl === "string" ? body.connectionProxyUrl.trim() : "";
const noProxy = typeof body?.connectionNoProxy === "string" ? body.connectionNoProxy.trim() : "";
if (enabled && !url) {
return { error: "Connection proxy URL is required when connection proxy is enabled" };
}
return {
connectionProxyEnabled: enabled,
connectionProxyUrl: url,
connectionNoProxy: noProxy,
};
}

View File

@@ -6,7 +6,12 @@ import { Modal, Input, Button, Badge } from "@/shared/components";
const DEFAULT_BASE_URL = "https://api.openai.com/v1"; const DEFAULT_BASE_URL = "https://api.openai.com/v1";
// Dual-mode modal: edit when `node` provided, add otherwise // Dual-mode modal: edit when `node` provided, add otherwise.
//
// Creating a node only registers the endpoint — API keys are managed on the
// node's own page afterwards, like the built-in providers. The credential
// probe stays available when editing, where it verifies an already-saved
// endpoint instead of being part of creation.
export default function AddCustomEmbeddingModal({ isOpen, onClose, onCreated, onSaved, node }) { export default function AddCustomEmbeddingModal({ isOpen, onClose, onCreated, onSaved, node }) {
const isEdit = !!node; const isEdit = !!node;
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
@@ -57,7 +62,7 @@ export default function AddCustomEmbeddingModal({ isOpen, onClose, onCreated, on
const data = await res.json(); const data = await res.json();
if (res.ok) { if (res.ok) {
if (isEdit) onSaved?.(data.node); if (isEdit) onSaved?.(data.node);
else onCreated?.(data.node); else onCreated?.(data);
} }
} catch (error) { } catch (error) {
console.log("Error saving custom embedding node:", error); console.log("Error saving custom embedding node:", error);
@@ -131,11 +136,14 @@ export default function AddCustomEmbeddingModal({ isOpen, onClose, onCreated, on
placeholder="https://api.voyageai.com/v1" placeholder="https://api.voyageai.com/v1"
hint="Most embedding APIs are OpenAI-compatible: Voyage, Cohere, Jina, Mistral, Together..." hint="Most embedding APIs are OpenAI-compatible: Voyage, Cohere, Jina, Mistral, Together..."
/> />
{isEdit && (
<>
<Input <Input
label="API Key (for Check)" label="API Key"
type="password" type="password"
value={checkKey} value={checkKey}
onChange={(e) => setCheckKey(e.target.value)} onChange={(e) => setCheckKey(e.target.value)}
hint="Used only to Check the endpoint. Keys are stored on the provider page."
/> />
<Input <Input
label="Model ID (for Check)" label="Model ID (for Check)"
@@ -154,6 +162,13 @@ export default function AddCustomEmbeddingModal({ isOpen, onClose, onCreated, on
</Button> </Button>
{renderValidationResult()} {renderValidationResult()}
</div> </div>
</>
)}
{!isEdit && (
<p className="text-xs text-text-muted">
{"You can add API keys and models from the provider page after creating it."}
</p>
)}
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
onClick={handleSubmit} onClick={handleSubmit}

View File

@@ -282,11 +282,18 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re
const chatSettings = await getSettings(); const chatSettings = await getSettings();
const providerThinking = (chatSettings.providerThinking || {})[provider] || null; const providerThinking = (chatSettings.providerThinking || {})[provider] || null;
// User-registered models (Provider page → "Add Model") trust upstream: keep // User-registered models (Provider page → "Add Model") trust upstream: keep
// images/media in the request instead of stripping by static-capability guess. // images/media in the request instead of stripping by static-capability guess —
// unless the user explicitly asserted capabilities for this model, in which case
// those win (including vision:false, which stops the fail-open).
let capsOverride = null; let capsOverride = null;
try { try {
const cm = (await getCustomModels()).find((m) => m.providerAlias === provider && m.id === model); const cm = (await getCustomModels()).find((m) => m.providerAlias === provider && m.id === model);
if (cm) capsOverride = { ...capabilitiesFromServiceKind(cm.type), vision: true }; if (cm) {
capsOverride = { ...capabilitiesFromServiceKind(cm.type) };
const asserted = cm.caps && typeof cm.caps === "object" ? cm.caps : null;
if (asserted) Object.assign(capsOverride, asserted);
if (!asserted || asserted.vision === undefined) capsOverride.vision = true;
}
} catch { /* fail-open to static caps */ } } catch { /* fail-open to static caps */ }
const result = await handleChatCore({ const result = await handleChatCore({
body: { ...body, model: `${provider}/${model}` }, body: { ...body, model: `${provider}/${model}` },

View File

@@ -0,0 +1,76 @@
import { afterEach, describe, expect, it, vi } from "vitest";
/**
* Next.js compiles `instrumentation.js` and every API route into SEPARATE server
* bundles, so one source file gets a distinct module instance per bundle. A
* reader installed into a plain module-local variable therefore lands in the
* startup copy while the chat route reads its own still-empty copy: the setter
* appears to succeed and the feature is silently dead.
*
* Distinct `?bundle=` suffixes give this file two instances of the same module in
* one process — it models "two bundles" without a real build.
*/
const boot = await import("../../open-sse/providers/capabilities.js?bundle=boot");
const route = await import("../../open-sse/providers/capabilities.js?bundle=route");
afterEach(() => {
boot.setUserCapsSource(null);
boot.setCatalogSource(null);
});
describe("capability sources across server bundles", () => {
it("keeps distinct module instances per bundle", () => {
// If these ever collapse into one instance the tests below prove nothing.
expect(route).not.toBe(boot);
});
it("honours a user-asserted caps source installed at startup", () => {
boot.setUserCapsSource(() => ({ vision: false }));
// qwen3-vl-plus is vision by name heuristic; the assertion must win in the
// bundle that actually serves the request.
expect(route.getCapabilitiesForModel("custom-node", "qwen3-vl-plus").vision).toBe(false);
boot.setUserCapsSource(() => ({ reasoning: true }));
expect(route.getCapabilitiesForModel("custom-node", "my-private-chatml-9").reasoning).toBe(true);
});
it("honours a catalog source installed at startup", () => {
boot.setCatalogSource({
getModalities: (model) => (model === "hand-rolled-7b" ? { vision: true } : null),
getLimits: (provider, model) => (model === "hand-rolled-7b" ? { contextWindow: 400000 } : null),
});
const caps = route.getCapabilitiesForModel("custom-node", "hand-rolled-7b");
expect(caps.vision).toBe(true);
expect(caps.contextWindow).toBe(400000);
});
it("detaches the catalog everywhere when one bundle detaches it", () => {
// modelCatalog/sync.js nulls the reader while computing a delta against the
// hand-written tables, then reinstalls it; both bundles must agree.
boot.setCatalogSource({ getModalities: () => ({ vision: true }), getLimits: () => null });
expect(route.getCapabilitiesForModel("custom-node", "hand-rolled-7b").vision).toBe(true);
boot.setCatalogSource(null);
expect(route.getCapabilitiesForModel("custom-node", "hand-rolled-7b").vision).toBe(false);
});
});
describe("user-caps snapshot across server bundles", () => {
const rows = [{ providerAlias: "node-abc", id: "chat-9", type: "llm", caps: { reasoning: true } }];
it("makes a rebuild started by the write route visible to the request route", async () => {
vi.doMock("@/lib/db/index.js", () => ({ getCustomModels: async () => rows }));
try {
const write = await import("../../src/lib/modelCaps/userCaps.js?bundle=write");
const request = await import("../../src/lib/modelCaps/userCaps.js?bundle=request");
write.invalidateUserCaps();
await vi.waitFor(() => {
expect(request.getUserCapsFor("node-abc", "chat-9")).toEqual({ reasoning: true });
});
} finally {
vi.doUnmock("@/lib/db/index.js");
vi.resetModules();
}
});
});

View File

@@ -0,0 +1,81 @@
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);
});
});

View File

@@ -0,0 +1,114 @@
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 });
});
});

View File

@@ -0,0 +1,205 @@
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"]);
});
});

View File

@@ -0,0 +1,92 @@
import { afterEach, describe, expect, it } from "vitest";
import {
DEFAULT_CAPABILITIES,
getCapabilitiesForModel,
setUserCapsSource,
} from "../../open-sse/providers/capabilities.js";
import { applyThinking } from "../../open-sse/translator/concerns/thinkingUnified.js";
import { looksLikeVisionModel } from "../../open-sse/providers/visionPatterns.js";
// Dashboard capability toggles are the user's own assertion about a model the
// heuristics cannot know. They are final: false must beat a name heuristic,
// true must beat the safe floor, and applying them must never leak a mutation
// back into DEFAULT_CAPABILITIES.
afterEach(() => setUserCapsSource(null));
function assertSource(caps) {
setUserCapsSource(() => caps);
}
describe("user-asserted model capabilities", () => {
it("turns a heuristic-detected vision model back off", () => {
const model = "qwen3-vl-plus";
expect(looksLikeVisionModel(model)).toBe(true);
expect(getCapabilitiesForModel("custom-node", model).vision).toBe(true);
assertSource({ vision: false });
expect(getCapabilitiesForModel("custom-node", model).vision).toBe(false);
});
it("turns reasoning on for a model the tables consider text-only", () => {
const model = "my-private-chatml-9";
expect(getCapabilitiesForModel("custom-node", model).reasoning).toBe(false);
assertSource({ reasoning: true });
expect(getCapabilitiesForModel("custom-node", model).reasoning).toBe(true);
});
it("ignores keys outside the capability set and non-objects", () => {
const before = getCapabilitiesForModel("custom-node", "gpt-5.1");
assertSource({ vision: true, nonsense: true });
const after = getCapabilitiesForModel("custom-node", "gpt-5.1");
expect(after.vision).toBe(true);
expect("nonsense" in after).toBe(false);
for (const junk of [null, undefined, "nope", 42]) {
assertSource(junk);
expect(getCapabilitiesForModel("custom-node", "gpt-5.1")).toEqual(before);
}
});
it("only applies asserted keys, leaving the rest resolved normally", () => {
assertSource({ vision: false });
const caps = getCapabilitiesForModel("custom-node", "my-private-chatml-9");
expect(caps.vision).toBe(false);
expect(caps.tools).toBe(true);
expect(caps.contextWindow).toBe(DEFAULT_CAPABILITIES.contextWindow);
});
it("never mutates DEFAULT_CAPABILITIES", () => {
const floor = { ...DEFAULT_CAPABILITIES };
assertSource({ vision: true, reasoning: true, tools: false, contextWindow: 1234 });
getCapabilitiesForModel("custom-node", "anything");
expect(DEFAULT_CAPABILITIES).toEqual(floor);
});
it("keeps heuristics when no source is installed", () => {
assertSource({ vision: false });
expect(getCapabilitiesForModel("custom-node", "qwen3-vl-plus").vision).toBe(false);
setUserCapsSource(null);
expect(getCapabilitiesForModel("custom-node", "qwen3-vl-plus").vision).toBe(true);
});
});
describe("thinking follows the assertion", () => {
it("strips thinking fields when reasoning is asserted off", () => {
assertSource({ reasoning: false });
const body = { reasoning_effort: "high", messages: [{ role: "user", content: "hi" }] };
applyThinking("openai", "my-private-chatml-9", body, "custom-node");
expect(body.reasoning_effort).toBeUndefined();
expect(body.reasoning).toBeUndefined();
expect(body.thinking).toBeUndefined();
});
it("emits thinking fields when reasoning is asserted on", () => {
assertSource({ reasoning: true, thinkingFormat: "openai" });
const body = { reasoning_effort: "high", messages: [{ role: "user", content: "hi" }] };
applyThinking("openai", "my-private-chatml-9", body, "custom-node");
expect(body.reasoning_effort).toBe("high");
});
});