diff --git a/CHANGELOG.md b/CHANGELOG.md index 39b3010c..24fa7652 100644 --- a/CHANGELOG.md +++ b/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) ## Features diff --git a/Dockerfile b/Dockerfile index dfcee598..afad1fd2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,6 +14,8 @@ RUN npm install --registry=https://registry.npmmirror.com COPY . ./ 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 FROM ${NODE_IMAGE} AS runner diff --git a/cli/package.json b/cli/package.json index db516ac4..200eaba0 100644 --- a/cli/package.json +++ b/cli/package.json @@ -1,6 +1,6 @@ { "name": "9router", - "version": "0.5.69", + "version": "0.5.70", "description": "9Router CLI - Start and manage 9Router server", "bin": { "9router": "./cli.js" diff --git a/open-sse/providers/capabilities.js b/open-sse/providers/capabilities.js index 999779f6..38309d39 100644 --- a/open-sse/providers/capabilities.js +++ b/open-sse/providers/capabilities.js @@ -414,16 +414,60 @@ const TRUST_UPSTREAM_VISION = new Set(["openrouter"]); */ const MODALITY_KEYS = ["vision", "pdf", "audioInput", "videoInput"]; -// Catalog lookups, installed by the server at startup. Left as no-ops in the -// browser bundle, where there is no file to read. -let catalogSource = null; +// ── Server-injected readers ────────────────────────────────────────── +// Next.js compiles instrumentation and each API route into SEPARATE server +// 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). * @param {{ getModalities: Function, getLimits: Function } | null} 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. @@ -431,7 +475,7 @@ export function setCatalogSource(source) { // flips when an outside source positively declares support. function refine(base, provider, model) { const result = { ...DEFAULT_CAPABILITIES, ...base }; - + const catalogSource = SOURCE_SLOTS.catalog; if (catalogSource) { const modalities = catalogSource.getModalities(model); 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". const baseModel = model.includes("/") ? model.split("/").pop() : model; - - // 1. Provider-specific override - if (provider) { - const providerCaps = PROVIDER_CAPABILITIES[provider]; - if (providerCaps?.[model]) return { ...DEFAULT_CAPABILITIES, ...providerCaps[model] }; - 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); + const resolve = () => { + // 1. Provider-specific override + if (provider) { + const providerCaps = PROVIDER_CAPABILITIES[provider]; + if (providerCaps?.[model]) return { ...DEFAULT_CAPABILITIES, ...providerCaps[model] }; + if (providerCaps?.[baseModel]) return { ...DEFAULT_CAPABILITIES, ...providerCaps[baseModel] }; } - } - // 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); + // 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) + if (provider && TRUST_UPSTREAM_VISION.has(provider)) { + return { ...refine(null, provider, model), vision: true }; + } + return refine(null, provider, model); + }; + + return applyUserCaps(resolve(), provider, model); } diff --git a/open-sse/providers/thinkingLevels.js b/open-sse/providers/thinkingLevels.js index 7cc1b28f..96f509e2 100644 --- a/open-sse/providers/thinkingLevels.js +++ b/open-sse/providers/thinkingLevels.js @@ -54,6 +54,12 @@ const PATTERN_THINKING = [ { 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. export function getThinkingLevels(provider, model) { if (provider === "kiro" && resolveKiroEffortPath(model) === null) return null; diff --git a/package.json b/package.json index 4fb77e9f..6907c9b7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "9router-app", - "version": "0.5.69", + "version": "0.5.70", "description": "9Router web dashboard", "private": true, "scripts": { diff --git a/public/fonts/inter-cyrillic-ext.woff2 b/public/fonts/inter-cyrillic-ext.woff2 new file mode 100644 index 00000000..2cd45edf Binary files /dev/null and b/public/fonts/inter-cyrillic-ext.woff2 differ diff --git a/public/fonts/inter-cyrillic.woff2 b/public/fonts/inter-cyrillic.woff2 new file mode 100644 index 00000000..bc0e0ab2 Binary files /dev/null and b/public/fonts/inter-cyrillic.woff2 differ diff --git a/public/fonts/inter-greek-ext.woff2 b/public/fonts/inter-greek-ext.woff2 new file mode 100644 index 00000000..b6dd1fac Binary files /dev/null and b/public/fonts/inter-greek-ext.woff2 differ diff --git a/public/fonts/inter-greek.woff2 b/public/fonts/inter-greek.woff2 new file mode 100644 index 00000000..9c71603a Binary files /dev/null and b/public/fonts/inter-greek.woff2 differ diff --git a/public/fonts/inter-latin-ext.woff2 b/public/fonts/inter-latin-ext.woff2 new file mode 100644 index 00000000..57da6f8d Binary files /dev/null and b/public/fonts/inter-latin-ext.woff2 differ diff --git a/public/fonts/inter-latin.woff2 b/public/fonts/inter-latin.woff2 new file mode 100644 index 00000000..91dc3e85 Binary files /dev/null and b/public/fonts/inter-latin.woff2 differ diff --git a/public/fonts/inter-vietnamese.woff2 b/public/fonts/inter-vietnamese.woff2 new file mode 100644 index 00000000..072229b8 Binary files /dev/null and b/public/fonts/inter-vietnamese.woff2 differ diff --git a/src/app/(dashboard)/dashboard/media-providers/[kind]/page.js b/src/app/(dashboard)/dashboard/media-providers/[kind]/page.js index e7d0cf28..1a99e87d 100644 --- a/src/app/(dashboard)/dashboard/media-providers/[kind]/page.js +++ b/src/app/(dashboard)/dashboard/media-providers/[kind]/page.js @@ -6,6 +6,7 @@ import { useEffect, useState } from "react"; import { Card, Badge, Button, Toggle, AddCustomEmbeddingModal } from "@/shared/components"; import ProviderIcon from "@/shared/components/ProviderIcon"; 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). // webSearch/webFetch handled by /web page. @@ -278,9 +279,19 @@ export default function MediaProviderKindPage() { setShowAddCustomEmbedding(false)} - onCreated={(node) => { - setCustomNodes((prev) => [...prev, node]); + onCreated={(result) => { + // 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); + if (node) { + notify.success( + `${node.name || "Provider"} created. Add an API key from its page to start using it.`, + "Provider created", + ); + } }} /> )} diff --git a/src/app/(dashboard)/dashboard/providers/[id]/AddApiKeyModal.js b/src/app/(dashboard)/dashboard/providers/[id]/AddApiKeyModal.js index e9a628db..c49491db 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/AddApiKeyModal.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/AddApiKeyModal.js @@ -97,7 +97,6 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa // Non-ollama providers require a name if (!formData.name) return; } - if (isCompatible && !formData.defaultModel.trim()) return; setSaving(true); try { @@ -298,6 +297,7 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa value={formData.defaultModel} onChange={(e) => setFormData({ ...formData, defaultModel: e.target.value })} 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 && ( @@ -313,11 +313,6 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa {error && (

{error}

)} - {isCompatible && ( -

- Enter the model ID exactly as your compatible endpoint expects it. This model will be saved as the connection default. -

- )} {isCloudflareAi && (

Cloudflare Workers AI

@@ -393,7 +388,7 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa

-
@@ -139,7 +179,8 @@ export default function AddCustomModelModal({ isOpen, providerAlias, providerDis AddCustomModelModal.propTypes = { isOpen: PropTypes.bool.isRequired, providerAlias: PropTypes.string.isRequired, - providerDisplayAlias: PropTypes.string.isRequired, + initialModelId: PropTypes.string, + initialCaps: PropTypes.object, onSave: PropTypes.func.isRequired, onClose: PropTypes.func.isRequired, }; diff --git a/src/app/(dashboard)/dashboard/providers/[id]/CompatibleModelsSection.js b/src/app/(dashboard)/dashboard/providers/[id]/CompatibleModelsSection.js index 8c2e9a28..680ea3ac 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/CompatibleModelsSection.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/CompatibleModelsSection.js @@ -1,263 +1,170 @@ "use client"; -import { useState } from "react"; import PropTypes from "prop-types"; import { Button } from "@/shared/components"; import { getProviderCustomModelRows } from "@/shared/utils/providerCustomModels"; -function CompatibleModelRow({ 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 ( -
- - {testStatus === "ok" ? "check_circle" : testStatus === "error" ? "cancel" : "smart_toy"} - -
-

{modelId}

-
- {fullModel} -
- - - {copied === `model-${modelId}` ? "Copied!" : "Copy"} - -
- {onTest && ( -
- - - {isTesting ? "Testing..." : "Test"} - -
- )} -
-
- -
- ); -} - -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); - } - }; +import { translate } from "@/i18n/runtime"; +import ModelRow from "./ModelRow"; +/** + * 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({ customModels, modelAliases, providerAlias: providerStorageAlias, 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); - } - }; + const activeModels = allModels.filter((model) => !disabledSet.has(model.id)); + const disabledModels = allModels.filter((model) => disabledSet.has(model.id)); const canFetch = connections.some((conn) => conn.isActive !== false); + const hasActiveConnection = canFetch; - const handleTestAllClick = async () => { - if (testAllRunning || allModels.length === 0) return; - setTestAllRunning(true); - 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); - }; + const failedIds = allModels + .filter((model) => model.source === "custom" && modelTestResults[model.id] === "error") + .map((model) => model.id); return (
-
-
- - 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" - /> -
- - - + {onFetchModels && ( + + )} + {failedIds.length > 0 && ( + + )} + {!canFetch && ( + + {"Add a connection to enable fetching models."} + + )}
- {(testAllResults || testAllRunning) && ( -
-
- - {testAllRunning ? "progress_activity" : testAllResults?.failed === 0 ? "check_circle" : "warning"} - - - {testAllRunning - ? `Testing... ${allModels.length} model${allModels.length > 1 ? "s" : ""} in parallel` - : `${testAllResults?.passed || 0} passed, ${testAllResults?.failed || 0} failed` - } - - {!testAllRunning && failedIds.length > 0 && ( - - )} -
-
- )} - - {!canFetch && ( + {allModels.length === 0 ? (

- Add a connection to enable fetching models. + {"No models yet — add one or fetch the provider's list."}

- )} + ) : ( + <> +
+ {activeModels.map(({ id, alias, name, source }) => { + const isCustom = source === "custom"; + return ( + {}} + 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 + } + /> + ); + })} +
- {allModels.length > 0 && ( -
- {allModels.map(({ id, alias, source }) => ( - source === "custom" ? onDeleteCustomModel(id) : onDeleteAlias(alias)} - onTest={connections.length > 0 ? () => handleTestModel(id) : undefined} - testStatus={modelTestResults[id]} - isTesting={testAllRunning || testingModelId === id} - /> - ))} -
+ {disabledModels.length > 0 && ( +
+

+ {`Disabled models (${disabledModels.length}):`} +

+
+ {disabledModels.map((model) => ( + + ))} +
+
+ )} + )}
); @@ -271,13 +178,23 @@ CompatibleModelsSection.propTypes = { copied: PropTypes.string, onCopy: PropTypes.func.isRequired, onDeleteAlias: PropTypes.func.isRequired, - onAddCustomModel: PropTypes.func.isRequired, + onOpenAddModel: PropTypes.func.isRequired, + onEditModel: PropTypes.func, onDeleteCustomModel: PropTypes.func.isRequired, + onFetchModels: PropTypes.func, + fetchingModels: PropTypes.bool, connections: PropTypes.arrayOf(PropTypes.shape({ id: PropTypes.string, isActive: PropTypes.bool, })).isRequired, - isAnthropic: PropTypes.bool, - onFetchModels: PropTypes.func, - fetchingModels: PropTypes.bool, + getCaps: PropTypes.func, + thinkingSuffix: PropTypes.func, + 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, }; diff --git a/src/app/(dashboard)/dashboard/providers/[id]/ModelRow.js b/src/app/(dashboard)/dashboard/providers/[id]/ModelRow.js index b011f58b..c51d20f2 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/ModelRow.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/ModelRow.js @@ -1,7 +1,7 @@ import PropTypes from "prop-types"; 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 borderColor = testStatus === "ok" ? "border-green-500/40" @@ -60,6 +60,19 @@ export default function ModelRow({ model, fullModel, alias, copied, onCopy, test {copied === `model-${model.id}` ? "Copied!" : "Copy"} + {onEdit && ( +
+ + + {"Edit capabilities"} + +
+ )} {isCustom ? ( - - {copied === `model-${modelId}` ? "Copied!" : "Copy"} - - - {onTest && ( -
- - - {isTesting ? "Testing..." : "Test"} - -
- )} - - - - {/* Delete button */} - - - ); -} - -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 ( -
-

- OpenRouter supports any model. Add models and create aliases for quick access. -

- - {/* Add new model */} -
-
- - 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" - /> -
- -
- - {/* Models list */} - {allModels.length > 0 && ( -
- {allModels.map(({ id, fullModel, alias, source }) => ( - source === "custom" ? onDeleteCustomModel(id) : onDeleteAlias(alias)} - /> - ))} -
- )} -
- ); -} - -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, -}; diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.js b/src/app/(dashboard)/dashboard/providers/[id]/page.js index c821a1c8..a8e0e33a 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.js @@ -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 { 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 { 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 { useModelCaps } from "@/shared/hooks/useModelCaps"; import { translate } from "@/i18n/runtime"; import { fetchSuggestedModels } from "@/shared/utils/providerModelsFetcher"; import { getProviderCustomModelRows } from "@/shared/utils/providerCustomModels"; import ModelRow from "./ModelRow"; -import PassthroughModelsSection from "./PassthroughModelsSection"; import CompatibleModelsSection from "./CompatibleModelsSection"; import ConnectionRow from "./ConnectionRow"; import AddApiKeyModal from "./AddApiKeyModal"; @@ -83,6 +82,9 @@ export default function ProviderDetailPage() { const [oneByOneSummary, setOneByOneSummary] = useState(null); const stopOneByOneRef = useRef(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 [testAllKeysModelId, setTestAllKeysModelId] = useState(""); const [testAllKeysRunning, setTestAllKeysRunning] = useState(false); @@ -90,6 +92,7 @@ export default function ProviderDetailPage() { const [testAllKeysError, setTestAllKeysError] = useState(""); const [testAllModelsRunning, setTestAllModelsRunning] = useState(false); const [testAllModelsSummary, setTestAllModelsSummary] = useState(null); + const [cleaningFailedModels, setCleaningFailedModels] = useState(false); const { copied, copy } = useCopyToClipboard(); const AG_RISK_STORAGE_KEY = "ag_risk_confirmed"; @@ -175,13 +178,34 @@ export default function ProviderDetailPage() { : providerId === "kimi" ? "Kimi API Key" : providerId === "qoder" ? "PAT" : "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. const resolveThinkingSuffix = (modelId) => { if (!thinkingMode || thinkingMode === "auto") return null; - const levels = getThinkingLevels(providerId, modelId); + const levels = thinkingLevelsFor(modelId); 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. // Include custom models too (e.g. manually added gpt-5.6-sol → max). const providerThinkingLevels = (() => { @@ -190,16 +214,12 @@ export default function ProviderDetailPage() { const addLevels = (modelId) => { if (!modelId || seen.has(modelId)) return; seen.add(modelId); - const lv = getThinkingLevels(providerId, modelId); + const lv = thinkingLevelsFor(modelId); if (lv) lv.forEach((l) => { if (l !== "none") set.add(l); }); }; for (const m of models) addLevels(m.id); for (const m of kiloFreeModels) addLevels(m.id); - for (const entry of customModels) { - if (entry.providerAlias !== providerStorageAlias) continue; - if ((entry.kind || entry.type || "llm") !== "llm") continue; - addLevels(entry.id); - } + for (const entry of customLlmEntries) addLevels(entry.id); return set.size ? ["auto", ...[...set]] : null; })(); 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 () => { if (oneByOneRunning) return; // Same population as Test Selected / routing itself: only active @@ -1158,17 +1236,38 @@ export default function ProviderDetailPage() { 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) // Shared by the per-key Test modal (testModels) and the Test All Keys modal. 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); - return all - .filter((m) => !disabledSet.has(m.id)) - .map((m) => ({ id: m.id, name: m.name || m.id })); + return allLlmModels.filter((m) => !disabledSet.has(m.id)); })(); const connectionsList = ( @@ -1352,6 +1451,28 @@ export default function ProviderDetailPage() { 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 = () => { if (isCompatible) { return ( @@ -1362,12 +1483,32 @@ export default function ProviderDetailPage() { customModels={customModels} copied={copied} onCopy={copy} - onSetAlias={handleSetAlias} 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)} + onFetchModels={handleFetchCompatibleModels} + fetchingModels={fetchingCompatibleModels} 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} caps={getCaps(`${providerId}/${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 */} + )} + {providerId === "codex" && ( + + )} + {providerId === "grok-cli" && ( + + )} + {hasDualAuthModes ? ( + <> - )} - {providerId === "codex" && ( - )} - {providerId === "grok-cli" && ( - - )} - {hasDualAuthModes ? ( - <> - - - - ) : ( - - )} - - )} + + ) : ( + + )} + )} @@ -2053,12 +2202,10 @@ export default function ProviderDetailPage() { )} - {!isCompatible && (() => { - const allIds = [ - ...models, - ...kiloFreeModels.filter((fm) => !models.some((m) => m.id === fm.id)), - ].filter((m) => { const k = getModelKind(m); return !k || k === "llm"; }).map((m) => m.id); - const activeIds = allIds.filter((id) => !disabledModelIds.includes(id)); + {(() => { + const activeIds = allLlmModels + .map((m) => m.id) + .filter((id) => !disabledModelIds.includes(id)); return (
{activeIds.length > 0 && ( @@ -2321,18 +2468,21 @@ export default function ProviderDetailPage() { isAnthropic={isAnthropicCompatible} /> )} - {!isCompatible && ( - { - await handleAddCustomModel(modelId, "llm", providerStorageAlias, caps); - setShowAddCustomModel(false); - }} - onClose={() => setShowAddCustomModel(false)} - /> - )} + { + await handleAddCustomModel(modelId, "llm", providerStorageAlias, caps); + setShowAddCustomModel(false); + setEditingModel(null); + }} + onClose={() => { + setShowAddCustomModel(false); + setEditingModel(null); + }} + /> {providerId === "codex" && ( ({ @@ -45,21 +46,13 @@ function AddCompatibleModal({ variant, isOpen, onClose, onCreated }) { const [formData, setFormData] = useState(initialFormData); 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(() => { - if (config.hasApiType) { - setFormData((prev) => ({ ...prev, baseUrl: config.defaultBaseUrl })); - } else if (isOpen) { - setValidationResult(null); - setCheckKey(""); - setCheckModelId(""); - } - }, [config.hasApiType ? formData.apiType : isOpen]); + if (!isOpen || !config.hasApiType) return; + setFormData((prev) => ({ ...prev, baseUrl: config.defaultBaseUrl })); + }, [isOpen, config.hasApiType, config.defaultBaseUrl]); const handleSubmit = async () => { 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(); if (res.ok) { - onCreated(data.node); + onCreated(data); setFormData(initialFormData()); - setCheckKey(""); - setValidationResult(null); } } catch (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 ( - <> - Valid - {method === "chat" && ( - (via inference test) - )} - - ); - } - return ( -
- Invalid - {error && {error}} -
- ); - }; - return (
@@ -141,7 +89,7 @@ function AddCompatibleModal({ variant, isOpen, onClose, onCreated }) { value={formData.name} onChange={(e) => setFormData({ ...formData, name: e.target.value })} placeholder={config.namePlaceholder} - hint="Required. A friendly label for this node." + hint="Required. A friendly label for this provider node." /> - setCheckKey(e.target.value)} - /> - setCheckModelId(e.target.value)} - placeholder={config.modelIdPlaceholder} - hint="If provider lacks /models endpoint, enter a model ID to validate via chat/completions instead." - /> -
- - {renderValidationResult()} -
+

+ {"You can add API keys and models from the provider page after creating it."} +

- {renderValidationResult()} -
+ {isEdit && ( + <> + setCheckKey(e.target.value)} + hint="Used only to Check the endpoint. Keys are stored on the provider page." + /> + setCheckModelId(e.target.value)} + placeholder="e.g. voyage-3, embed-english-v3.0, text-embedding-3-small" + hint="Required for validation. Will send a test embeddings request." + /> +
+ + {renderValidationResult()} +
+ + )} + {!isEdit && ( +

+ {"You can add API keys and models from the provider page after creating it."} +

+ )}