# 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 since0532f00d- **Dashboard**: thinking-level picker and model-row suffix reflect user-asserted reasoning on compatible nodes - **Providers**: `Default Model` is optional when adding an API key to a compatible node — the node's own model list (and the picker in the test modals) already determine what gets probed, and the built-in fallback still covers connection checks - **Providers**: restore the `useCopyToClipboard` import dropped from the provider detail page, which crashed the route with `ReferenceError` for every provider - **DB**: restore `getModelAliases` / `setModelAlias` / `deleteModelAlias` re-exports dropped from the `localDb` shim by86112cee, which broke `GET /api/models` and `GET /v1/models` at import time - **Providers**: remove dead `PassthroughModelsSection` (never passed props, superseded by the shared model rows) - **Media Providers**: creating a custom embedding node reports that a key still has to be added, instead of claiming a key was saved; the edit dialog keeps its API Key + Check affordance since a stored key already exists there - **Build**: self-host Inter instead of fetching it through `next/font/google` at build time — a Docker / mirrored builder with no route to `fonts.googleapis.com` failed the entire image build on `Failed to fetch 'Inter' from Google Fonts`. The seven `@font-face` rules and their `unicode-range`s copy what `next/font` emitted (a `latin`-only file would have dropped Vietnamese diacritics) and the latin subset is preloaded as before, so rendered metrics are unchanged
This commit is contained in:
20
CHANGELOG.md
20
CHANGELOG.md
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,28 +501,31 @@ 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];
|
||||||
if (providerCaps?.[model]) return { ...DEFAULT_CAPABILITIES, ...providerCaps[model] };
|
if (providerCaps?.[model]) return { ...DEFAULT_CAPABILITIES, ...providerCaps[model] };
|
||||||
if (providerCaps?.[baseModel]) return { ...DEFAULT_CAPABILITIES, ...providerCaps[baseModel] };
|
if (providerCaps?.[baseModel]) return { ...DEFAULT_CAPABILITIES, ...providerCaps[baseModel] };
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Canonical exact
|
|
||||||
if (MODEL_CAPABILITIES[baseModel]) return { ...DEFAULT_CAPABILITIES, ...MODEL_CAPABILITIES[baseModel] };
|
|
||||||
if (MODEL_CAPABILITIES[model]) return { ...DEFAULT_CAPABILITIES, ...MODEL_CAPABILITIES[model] };
|
|
||||||
|
|
||||||
// 3. Pattern match (first match wins), refined by catalog + name heuristic
|
|
||||||
for (const { pattern, caps } of PATTERN_CAPABILITIES) {
|
|
||||||
if (matchPattern(pattern, baseModel) || matchPattern(pattern, model)) {
|
|
||||||
return refine(caps, provider, model);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Floor (upstream-validated gateways keep vision on for unknown models)
|
// 2. Canonical exact
|
||||||
if (provider && TRUST_UPSTREAM_VISION.has(provider)) {
|
if (MODEL_CAPABILITIES[baseModel]) return { ...DEFAULT_CAPABILITIES, ...MODEL_CAPABILITIES[baseModel] };
|
||||||
return { ...refine(null, provider, model), vision: true };
|
if (MODEL_CAPABILITIES[model]) return { ...DEFAULT_CAPABILITIES, ...MODEL_CAPABILITIES[model] };
|
||||||
}
|
|
||||||
return refine(null, provider, model);
|
// 3. Pattern match (first match wins), refined by catalog + name heuristic
|
||||||
|
for (const { pattern, caps } of PATTERN_CAPABILITIES) {
|
||||||
|
if (matchPattern(pattern, baseModel) || matchPattern(pattern, model)) {
|
||||||
|
return refine(caps, provider, model);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Floor (upstream-validated gateways keep vision on for unknown models)
|
||||||
|
if (provider && TRUST_UPSTREAM_VISION.has(provider)) {
|
||||||
|
return { ...refine(null, provider, model), vision: true };
|
||||||
|
}
|
||||||
|
return refine(null, provider, model);
|
||||||
|
};
|
||||||
|
|
||||||
|
return applyUserCaps(resolve(), provider, model);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
BIN
public/fonts/inter-cyrillic-ext.woff2
Normal file
BIN
public/fonts/inter-cyrillic-ext.woff2
Normal file
Binary file not shown.
BIN
public/fonts/inter-cyrillic.woff2
Normal file
BIN
public/fonts/inter-cyrillic.woff2
Normal file
Binary file not shown.
BIN
public/fonts/inter-greek-ext.woff2
Normal file
BIN
public/fonts/inter-greek-ext.woff2
Normal file
Binary file not shown.
BIN
public/fonts/inter-greek.woff2
Normal file
BIN
public/fonts/inter-greek.woff2
Normal file
Binary file not shown.
BIN
public/fonts/inter-latin-ext.woff2
Normal file
BIN
public/fonts/inter-latin-ext.woff2
Normal file
Binary file not shown.
BIN
public/fonts/inter-latin.woff2
Normal file
BIN
public/fonts/inter-latin.woff2
Normal file
Binary file not shown.
BIN
public/fonts/inter-vietnamese.woff2
Normal file
BIN
public/fonts/inter-vietnamese.woff2
Normal file
Binary file not shown.
@@ -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",
|
||||||
|
);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 size="sm" variant="secondary" icon="download" onClick={onFetchModels} disabled={!canFetch || fetchingModels}>
|
|
||||||
{fetchingModels ? "Fetching..." : "Fetch Models"}
|
|
||||||
</Button>
|
|
||||||
<Button size="sm" variant="secondary" icon="science" onClick={handleTestAllClick} disabled={allModels.length === 0 || testAllRunning}>
|
|
||||||
{testAllRunning ? "Testing..." : "Test All"}
|
|
||||||
</Button>
|
</Button>
|
||||||
|
{onFetchModels && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
icon={fetchingModels ? "progress_activity" : "download"}
|
||||||
|
onClick={onFetchModels}
|
||||||
|
disabled={!canFetch || fetchingModels}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={fetchingModels ? "animate-spin inline-flex" : "inline-flex"}
|
||||||
|
style={fetchingModels ? { animation: "spin 1s linear infinite" } : undefined}
|
||||||
|
>
|
||||||
|
{fetchingModels ? translate("Fetching...") : translate("Fetch Models")}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{failedIds.length > 0 && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="danger"
|
||||||
|
icon={cleaningFailed ? "progress_activity" : "delete_sweep"}
|
||||||
|
onClick={() => onCleanFailed?.(failedIds)}
|
||||||
|
disabled={cleaningFailed}
|
||||||
|
>
|
||||||
|
{cleaningFailed ? "Cleaning..." : `Clean ${failedIds.length} failed`}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{!canFetch && (
|
||||||
|
<span className="text-xs text-text-muted">
|
||||||
|
{"Add a connection to enable fetching models."}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{(testAllResults || testAllRunning) && (
|
{allModels.length === 0 ? (
|
||||||
<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]">
|
|
||||||
{cleaning ? "progress_activity" : "delete_sweep"}
|
|
||||||
</span>
|
|
||||||
{cleaning ? "Cleaning..." : `Clean ${failedIds.length} failed`}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!canFetch && (
|
|
||||||
<p className="text-xs text-text-muted">
|
<p className="text-xs text-text-muted">
|
||||||
Add a connection to enable fetching models.
|
{"No models yet — add one or fetch the provider's list."}
|
||||||
</p>
|
</p>
|
||||||
)}
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{activeModels.map(({ id, alias, name, source }) => {
|
||||||
|
const isCustom = source === "custom";
|
||||||
|
return (
|
||||||
|
<ModelRow
|
||||||
|
key={`${source}-${providerStorageAlias}/${id}`}
|
||||||
|
model={{ id, name }}
|
||||||
|
fullModel={`${providerDisplayAlias}/${id}`}
|
||||||
|
alias={alias}
|
||||||
|
copied={copied}
|
||||||
|
onCopy={onCopy}
|
||||||
|
onSetAlias={() => {}}
|
||||||
|
onDeleteAlias={() =>
|
||||||
|
isCustom ? onDeleteCustomModel(id) : onDeleteAlias(alias)
|
||||||
|
}
|
||||||
|
testStatus={modelTestResults[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>
|
||||||
|
|
||||||
{allModels.length > 0 && (
|
{disabledModels.length > 0 && (
|
||||||
<div className="flex flex-col gap-3">
|
<div className="w-full">
|
||||||
{allModels.map(({ id, alias, source }) => (
|
<p className="text-xs text-text-muted mb-2">
|
||||||
<CompatibleModelRow
|
{`Disabled models (${disabledModels.length}):`}
|
||||||
key={`${source}-${providerStorageAlias}/${id}`}
|
</p>
|
||||||
modelId={id}
|
<div className="flex flex-wrap gap-2">
|
||||||
fullModel={`${providerDisplayAlias}/${id}`}
|
{disabledModels.map((model) => (
|
||||||
copied={copied}
|
<button
|
||||||
onCopy={onCopy}
|
key={`${model.source}-${model.id}`}
|
||||||
onDeleteAlias={() => source === "custom" ? onDeleteCustomModel(id) : onDeleteAlias(alias)}
|
onClick={() => onEnableModel?.(model.id)}
|
||||||
onTest={connections.length > 0 ? () => handleTestModel(id) : undefined}
|
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"
|
||||||
testStatus={modelTestResults[id]}
|
title="Restore model"
|
||||||
isTesting={testAllRunning || testingModelId === id}
|
>
|
||||||
/>
|
<span className="material-symbols-outlined text-[13px]">add</span>
|
||||||
))}
|
{model.id}
|
||||||
</div>
|
</button>
|
||||||
|
))}
|
||||||
|
</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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
|
||||||
};
|
|
||||||
@@ -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,76 +2109,74 @@ 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
|
||||||
|
size="sm"
|
||||||
|
icon="cookie"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setShowIFlowCookieModal(true)}
|
||||||
|
title="Add connection using browser cookie"
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
Cookie
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{providerId === "codex" && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
icon="playlist_add"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setShowBulkImportCodex(true)}
|
||||||
|
title={translate("Bulk import codex accounts from JSON")}
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
{translate("Bulk Add")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{providerId === "grok-cli" && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
icon="playlist_add"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => setShowBulkImportGrokCli(true)}
|
||||||
|
title={translate("Bulk import Grok CLI accounts from JSON")}
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
{translate("Bulk Add")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{hasDualAuthModes ? (
|
||||||
|
<>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
icon="cookie"
|
icon="lock"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => setShowIFlowCookieModal(true)}
|
onClick={triggerOAuthConnection}
|
||||||
title="Add connection using browser cookie"
|
|
||||||
className="w-full sm:w-auto"
|
className="w-full sm:w-auto"
|
||||||
>
|
>
|
||||||
Cookie
|
{oauthConnectionLabel}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
|
||||||
{providerId === "codex" && (
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
icon="playlist_add"
|
icon="key"
|
||||||
variant="secondary"
|
onClick={triggerApiKeyConnection}
|
||||||
onClick={() => setShowBulkImportCodex(true)}
|
|
||||||
title={translate("Bulk import codex accounts from JSON")}
|
|
||||||
className="w-full sm:w-auto"
|
className="w-full sm:w-auto"
|
||||||
>
|
>
|
||||||
{translate("Bulk Add")}
|
{apiKeyConnectionLabel}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
</>
|
||||||
{providerId === "grok-cli" && (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
icon="playlist_add"
|
icon="add"
|
||||||
variant="secondary"
|
onClick={triggerAddConnection}
|
||||||
onClick={() => setShowBulkImportGrokCli(true)}
|
className="w-full sm:w-auto"
|
||||||
title={translate("Bulk import Grok CLI accounts from JSON")}
|
>
|
||||||
className="w-full sm:w-auto"
|
{isCompatible ? "Add API Key" : "Add"}
|
||||||
>
|
</Button>
|
||||||
{translate("Bulk Add")}
|
)}
|
||||||
</Button>
|
</div>
|
||||||
)}
|
|
||||||
{hasDualAuthModes ? (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
icon="lock"
|
|
||||||
variant="secondary"
|
|
||||||
onClick={triggerOAuthConnection}
|
|
||||||
className="w-full sm:w-auto"
|
|
||||||
>
|
|
||||||
{oauthConnectionLabel}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
icon="key"
|
|
||||||
onClick={triggerApiKeyConnection}
|
|
||||||
className="w-full sm:w-auto"
|
|
||||||
>
|
|
||||||
{apiKeyConnectionLabel}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
icon="add"
|
|
||||||
onClick={triggerAddConnection}
|
|
||||||
className="w-full sm:w-auto"
|
|
||||||
>
|
|
||||||
Add
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</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}
|
initialModelId={editingModel?.id || ""}
|
||||||
providerDisplayAlias={providerDisplayAlias}
|
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)}
|
}}
|
||||||
/>
|
onClose={() => {
|
||||||
)}
|
setShowAddCustomModel(false);
|
||||||
|
setEditingModel(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
{providerId === "codex" && (
|
{providerId === "codex" && (
|
||||||
<BulkImportCodexModal
|
<BulkImportCodexModal
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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.
|
||||||
|
function planNode({ type, name, prefix, apiType, baseUrl }) {
|
||||||
|
if (type === "openai-compatible") {
|
||||||
|
if (!apiType || !["chat", "responses"].includes(apiType)) {
|
||||||
|
return { error: "Invalid OpenAI compatible API type" };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
node: {
|
||||||
|
id: `${OPENAI_COMPATIBLE_PREFIX}${apiType}-${generateId()}`,
|
||||||
|
type: "openai-compatible",
|
||||||
|
prefix,
|
||||||
|
apiType,
|
||||||
|
baseUrl: (baseUrl || OPENAI_COMPATIBLE_DEFAULTS.baseUrl).trim(),
|
||||||
|
name,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "custom-embedding") {
|
||||||
|
// Strip trailing slash and /embeddings if user pasted full endpoint
|
||||||
|
let sanitizedBaseUrl = (baseUrl || CUSTOM_EMBEDDING_DEFAULTS.baseUrl).trim().replace(/\/$/, "");
|
||||||
|
if (sanitizedBaseUrl.endsWith("/embeddings")) {
|
||||||
|
sanitizedBaseUrl = sanitizedBaseUrl.slice(0, -"/embeddings".length);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
node: {
|
||||||
|
id: `${CUSTOM_EMBEDDING_PREFIX}${generateId()}`,
|
||||||
|
type: "custom-embedding",
|
||||||
|
prefix,
|
||||||
|
baseUrl: sanitizedBaseUrl,
|
||||||
|
name,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "anthropic-compatible") {
|
||||||
|
// Sanitize Base URL: remove trailing slash, and remove trailing /messages if user added it
|
||||||
|
// This prevents double-appending /messages at runtime
|
||||||
|
let sanitizedBaseUrl = (baseUrl || ANTHROPIC_COMPATIBLE_DEFAULTS.baseUrl).trim().replace(/\/$/, "");
|
||||||
|
if (sanitizedBaseUrl.endsWith("/messages")) {
|
||||||
|
sanitizedBaseUrl = sanitizedBaseUrl.slice(0, -9); // remove /messages
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
node: {
|
||||||
|
id: `${ANTHROPIC_COMPATIBLE_PREFIX}${generateId()}`,
|
||||||
|
type: "anthropic-compatible",
|
||||||
|
prefix,
|
||||||
|
baseUrl: sanitizedBaseUrl,
|
||||||
|
name,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
export async function POST(request) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json();
|
const body = await request.json();
|
||||||
const { name, prefix, apiType, baseUrl, type } = body;
|
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?.trim()) {
|
if (!name) {
|
||||||
return NextResponse.json({ error: "Name is required" }, { status: 400 });
|
return NextResponse.json({ error: "Name is required" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
if (!prefix) {
|
||||||
if (!prefix?.trim()) {
|
|
||||||
return NextResponse.json({ error: "Prefix is required" }, { status: 400 });
|
return NextResponse.json({ error: "Prefix is required" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine type
|
// Determine type
|
||||||
const nodeType = type || "openai-compatible";
|
const nodeType = type || "openai-compatible";
|
||||||
|
|
||||||
if (nodeType === "openai-compatible") {
|
// Nodes carry no proxy of their own; reject stray proxy fields rather than
|
||||||
if (!apiType || !["chat", "responses"].includes(apiType)) {
|
// silently dropping them, so a client that means to set one gets told.
|
||||||
return NextResponse.json({ error: "Invalid OpenAI compatible API type" }, { status: 400 });
|
const proxyConfig = normalizeProxyConfig(body);
|
||||||
}
|
if (proxyConfig.error) {
|
||||||
|
return NextResponse.json({ error: proxyConfig.error }, { status: 400 });
|
||||||
const node = await createProviderNode({
|
|
||||||
id: `${OPENAI_COMPATIBLE_PREFIX}${apiType}-${generateId()}`,
|
|
||||||
type: "openai-compatible",
|
|
||||||
prefix: prefix.trim(),
|
|
||||||
apiType,
|
|
||||||
baseUrl: (baseUrl || OPENAI_COMPATIBLE_DEFAULTS.baseUrl).trim(),
|
|
||||||
name: name.trim(),
|
|
||||||
});
|
|
||||||
return NextResponse.json({ node }, { status: 201 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nodeType === "custom-embedding") {
|
const plan = planNode({ type: nodeType, name, prefix, apiType, baseUrl });
|
||||||
// Strip trailing slash and /embeddings if user pasted full endpoint
|
if (plan.error) {
|
||||||
let sanitizedBaseUrl = (baseUrl || CUSTOM_EMBEDDING_DEFAULTS.baseUrl).trim().replace(/\/$/, "");
|
return NextResponse.json({ error: plan.error }, { status: 400 });
|
||||||
if (sanitizedBaseUrl.endsWith("/embeddings")) {
|
|
||||||
sanitizedBaseUrl = sanitizedBaseUrl.slice(0, -"/embeddings".length);
|
|
||||||
}
|
|
||||||
|
|
||||||
const node = await createProviderNode({
|
|
||||||
id: `${CUSTOM_EMBEDDING_PREFIX}${generateId()}`,
|
|
||||||
type: "custom-embedding",
|
|
||||||
prefix: prefix.trim(),
|
|
||||||
baseUrl: sanitizedBaseUrl,
|
|
||||||
name: name.trim(),
|
|
||||||
});
|
|
||||||
return NextResponse.json({ node }, { status: 201 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (nodeType === "anthropic-compatible") {
|
const node = await createProviderNode(plan.node);
|
||||||
// Sanitize Base URL: remove trailing slash, and remove trailing /messages if user added it
|
return NextResponse.json({ node }, { status: 201 });
|
||||||
// This prevents double-appending /messages at runtime
|
|
||||||
let sanitizedBaseUrl = (baseUrl || ANTHROPIC_COMPATIBLE_DEFAULTS.baseUrl).trim().replace(/\/$/, "");
|
|
||||||
if (sanitizedBaseUrl.endsWith("/messages")) {
|
|
||||||
sanitizedBaseUrl = sanitizedBaseUrl.slice(0, -9); // remove /messages
|
|
||||||
}
|
|
||||||
|
|
||||||
const node = await createProviderNode({
|
|
||||||
id: `${ANTHROPIC_COMPATIBLE_PREFIX}${generateId()}`,
|
|
||||||
type: "anthropic-compatible",
|
|
||||||
prefix: prefix.trim(),
|
|
||||||
baseUrl: sanitizedBaseUrl,
|
|
||||||
name: name.trim(),
|
|
||||||
});
|
|
||||||
return NextResponse.json({ node }, { status: 201 });
|
|
||||||
}
|
|
||||||
|
|
||||||
return NextResponse.json({ error: "Invalid provider node type" }, { status: 400 });
|
|
||||||
} 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 });
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|| capabilitiesFromServiceKind(customKind || liveKind)
|
...(liveCapabilitiesById.get(modelId)
|
||||||
|| (kind === LLM_KIND ? getCapabilitiesForModel(providerId, modelId) : null);
|
|| capabilitiesFromServiceKind(customKind || liveKind)
|
||||||
if (caps) model.capabilities = caps;
|
|| (kind === LLM_KIND ? getCapabilitiesForModel(providerId, modelId) : null)),
|
||||||
|
// 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
13
src/app/fonts-inter.css
Normal 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%}
|
||||||
|
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
111
src/lib/modelCaps/userCaps.js
Normal file
111
src/lib/modelCaps/userCaps.js
Normal 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
56
src/lib/nodeConnection.js
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,29 +136,39 @@ 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..."
|
||||||
/>
|
/>
|
||||||
<Input
|
{isEdit && (
|
||||||
label="API Key (for Check)"
|
<>
|
||||||
type="password"
|
<Input
|
||||||
value={checkKey}
|
label="API Key"
|
||||||
onChange={(e) => setCheckKey(e.target.value)}
|
type="password"
|
||||||
/>
|
value={checkKey}
|
||||||
<Input
|
onChange={(e) => setCheckKey(e.target.value)}
|
||||||
label="Model ID (for Check)"
|
hint="Used only to Check the endpoint. Keys are stored on the provider page."
|
||||||
value={checkModelId}
|
/>
|
||||||
onChange={(e) => setCheckModelId(e.target.value)}
|
<Input
|
||||||
placeholder="e.g. voyage-3, embed-english-v3.0, text-embedding-3-small"
|
label="Model ID (for Check)"
|
||||||
hint="Required for validation. Will send a test embeddings request."
|
value={checkModelId}
|
||||||
/>
|
onChange={(e) => setCheckModelId(e.target.value)}
|
||||||
<div className="flex items-center gap-3">
|
placeholder="e.g. voyage-3, embed-english-v3.0, text-embedding-3-small"
|
||||||
<Button
|
hint="Required for validation. Will send a test embeddings request."
|
||||||
onClick={handleValidate}
|
/>
|
||||||
disabled={!checkKey || !checkModelId.trim() || validating || !formData.baseUrl.trim()}
|
<div className="flex items-center gap-3">
|
||||||
variant="secondary"
|
<Button
|
||||||
>
|
onClick={handleValidate}
|
||||||
{validating ? "Checking..." : "Check"}
|
disabled={!checkKey || !checkModelId.trim() || validating || !formData.baseUrl.trim()}
|
||||||
</Button>
|
variant="secondary"
|
||||||
{renderValidationResult()}
|
>
|
||||||
</div>
|
{validating ? "Checking..." : "Check"}
|
||||||
|
</Button>
|
||||||
|
{renderValidationResult()}
|
||||||
|
</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}
|
||||||
|
|||||||
@@ -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}` },
|
||||||
|
|||||||
76
tests/unit/capability-source-instance.test.js
Normal file
76
tests/unit/capability-source-instance.test.js
Normal 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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
81
tests/unit/custom-model-caps-listing.test.js
Normal file
81
tests/unit/custom-model-caps-listing.test.js
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
114
tests/unit/custom-model-caps-storage.test.js
Normal file
114
tests/unit/custom-model-caps-storage.test.js
Normal 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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
205
tests/unit/node-first-connection.test.js
Normal file
205
tests/unit/node-first-connection.test.js
Normal 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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
92
tests/unit/user-capabilities.test.js
Normal file
92
tests/unit/user-capabilities.test.js
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user