diff --git a/src/app/(dashboard)/dashboard/providers/[id]/ConnectionRow.js b/src/app/(dashboard)/dashboard/providers/[id]/ConnectionRow.js index dcc9172f..6230a6ea 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/ConnectionRow.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/ConnectionRow.js @@ -7,16 +7,40 @@ import { Badge, Toggle, Tooltip, Modal, Button } from "@/shared/components"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; import CooldownTimer from "./CooldownTimer"; -export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst, isLast, onMoveUp, onMoveDown, onToggleActive, onUpdateProxy, onEdit, onDelete, oneByOneStatus = null, autoPing = null }) { +export default function ConnectionRow({ + connection, + proxyPools, + isOAuth, + isFirst, + isLast, + onMoveUp, + onMoveDown, + onToggleActive, + onUpdateProxy, + onEdit, + onDelete, + oneByOneStatus = null, + autoPing = null, + testModels = [], + providerAlias = null, +}) { const [showProxyDropdown, setShowProxyDropdown] = useState(false); const [updatingProxy, setUpdatingProxy] = useState(false); const [showKeyModal, setShowKeyModal] = useState(false); const [revealedKey, setRevealedKey] = useState(""); const [loadingKey, setLoadingKey] = useState(false); const [keyError, setKeyError] = useState(null); + const [showTestModelModal, setShowTestModelModal] = useState(false); + const [selectedTestModelId, setSelectedTestModelId] = useState(""); + const [manualTestModelId, setManualTestModelId] = useState(""); + const [testingModel, setTestingModel] = useState(false); + const [testModelResult, setTestModelResult] = useState(null); const { copied, copy } = useCopyToClipboard(); const proxyDropdownRef = useRef(null); + const canTestModel = !!providerAlias && (connection.isActive !== false); + const hasCatalogModels = Array.isArray(testModels) && testModels.length > 0; + const proxyPoolMap = new Map((proxyPools || []).map((pool) => [pool.id, pool])); const boundProxyPoolId = connection.providerSpecificData?.proxyPoolId || null; const boundProxyPool = boundProxyPoolId ? proxyPoolMap.get(boundProxyPoolId) : null; @@ -75,6 +99,58 @@ export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst } }; + const openTestModelModal = () => { + setTestModelResult(null); + setManualTestModelId(""); + setSelectedTestModelId(hasCatalogModels ? (testModels[0]?.id || "") : ""); + setShowTestModelModal(true); + }; + + const resolveTestModelId = () => { + if (hasCatalogModels) return selectedTestModelId?.trim() || ""; + return manualTestModelId?.trim() || ""; + }; + + const handleTestModel = async () => { + if (!canTestModel || testingModel) return; + const modelId = resolveTestModelId(); + if (!modelId) { + setTestModelResult({ ok: false, error: "Select or enter a model id" }); + return; + } + setTestingModel(true); + setTestModelResult(null); + try { + const res = await fetch("/api/models/test", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: `${providerAlias}/${modelId}`, + connectionId: connection.id, + }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + setTestModelResult({ + ok: false, + error: data.error || `HTTP ${res.status}`, + latencyMs: data.latencyMs, + }); + return; + } + setTestModelResult({ + ok: !!data.ok, + error: data.error || null, + latencyMs: data.latencyMs, + status: data.status, + }); + } catch (error) { + setTestModelResult({ ok: false, error: error.message || "Network error" }); + } finally { + setTestingModel(false); + } + }; + const rowAuthType = connection.authType || (isOAuth ? "oauth" : "apikey"); const isOAuthConnection = rowAuthType === "oauth"; const isCookieConnection = rowAuthType === "cookie"; @@ -293,6 +369,16 @@ export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst Key )} + {canTestModel && ( + + )} + + {/* Test Model Modal — pins this connection via x-connection-id */} + { + if (testingModel) return; + setShowTestModelModal(false); + setTestModelResult(null); + }} + > +
+

+ Call one model using only this account: + {displayName} +

+ + {hasCatalogModels ? ( +
+ + +
+ ) : ( +
+ + { + setManualTestModelId(e.target.value); + setTestModelResult(null); + }} + placeholder="e.g. gpt-4o-mini" + className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm font-mono focus:border-primary focus:outline-none" + disabled={testingModel} + /> +
+ )} + + {testModelResult && ( +
+ + {testModelResult.ok ? "check_circle" : "error"} + +
+

+ {testModelResult.ok ? "Model reachable" : "Test failed"} + {typeof testModelResult.latencyMs === "number" ? ` · ${testModelResult.latencyMs}ms` : ""} +

+ {testModelResult.error && ( +

{testModelResult.error}

+ )} +
+
+ )} + +
+ + +
+
+
); } @@ -397,4 +584,9 @@ ConnectionRow.propTypes = { onToggle: PropTypes.func, provider: PropTypes.string, }), + testModels: PropTypes.arrayOf(PropTypes.shape({ + id: PropTypes.string.isRequired, + name: PropTypes.string, + })), + providerAlias: PropTypes.string, }; diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.js b/src/app/(dashboard)/dashboard/providers/[id]/page.js index 6f0c9032..8088071b 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.js @@ -76,6 +76,10 @@ export default function ProviderDetailPage() { const [oneByOneResults, setOneByOneResults] = useState({}); const [oneByOneSummary, setOneByOneSummary] = useState(null); const stopOneByOneRef = useRef(false); + // Multi-select model test (real /api/models/test call, pinned per connection) + const [showBulkModelTestModal, setShowBulkModelTestModal] = useState(false); + const [bulkTestModelId, setBulkTestModelId] = useState(""); + const [manualBulkTestModelId, setManualBulkTestModelId] = useState(""); const [importingQoderModels, setImportingQoderModels] = useState(false); const [fetchingCompatibleModels, setFetchingCompatibleModels] = useState(false); const { copied, copy } = useCopyToClipboard(); @@ -158,6 +162,35 @@ export default function ProviderDetailPage() { ? (providerNode?.prefix || providerId) : providerAlias; + // Models available for "Test Model" action on each connection (pinned to that account). + const connectionTestModels = (() => { + const disabledSet = new Set(disabledModelIds); + const llmBuiltIns = models + .filter((m) => { + const k = getModelKind(m); + return (!k || k === "llm") && !disabledSet.has(m.id); + }) + .map((m) => ({ id: m.id, name: m.name || m.id })); + const customRows = getProviderCustomModelRows({ + customModels, + modelAliases, + providerAlias: providerStorageAlias, + builtInModels: models, + type: "llm", + }).map((m) => ({ id: m.id, name: m.name || m.id })); + const kiloRows = kiloFreeModels + .filter((m) => !models.some((b) => b.id === m.id) && !disabledSet.has(m.id)) + .map((m) => ({ id: m.id, name: m.name || m.id })); + const seen = new Set(); + const out = []; + for (const m of [...customRows, ...llmBuiltIns, ...kiloRows]) { + if (!m?.id || seen.has(m.id)) continue; + seen.add(m.id); + out.push(m); + } + return out; + })(); + const fetchDisabledModels = useCallback(async () => { try { const res = await fetch(`/api/models/disabled?providerAlias=${encodeURIComponent(providerStorageAlias)}`, { cache: "no-store" }); @@ -582,11 +615,20 @@ export default function ProviderDetailPage() { } }; - const handleRunOneByOneTest = async () => { + const handleRunOneByOneTest = async (connectionIds = null) => { if (oneByOneRunning || connections.length === 0) return; + // Optional multi-select scope: only test checked connections; default = all. + const idFilter = Array.isArray(connectionIds) && connectionIds.length > 0 + ? new Set(connectionIds) + : null; + const targets = idFilter + ? connections.filter((connection) => idFilter.has(connection.id)) + : connections; + if (targets.length === 0) return; + const queuedState = Object.fromEntries( - connections.map((connection) => [connection.id, { state: "queued", error: null }]), + targets.map((connection) => [connection.id, { state: "queued", error: null }]), ); stopOneByOneRef.current = false; @@ -594,16 +636,16 @@ export default function ProviderDetailPage() { setOneByOneStopping(false); setOneByOneCurrentConnectionId(null); setOneByOneResults(queuedState); - setOneByOneSummary({ total: connections.length, completed: 0, passed: 0, failed: 0, stopped: false }); + setOneByOneSummary({ total: targets.length, completed: 0, passed: 0, failed: 0, stopped: false }); let passed = 0; let failed = 0; try { - for (let index = 0; index < connections.length; index += 1) { + for (let index = 0; index < targets.length; index += 1) { if (stopOneByOneRef.current) { setOneByOneSummary({ - total: connections.length, + total: targets.length, completed: index, passed, failed, @@ -612,7 +654,7 @@ export default function ProviderDetailPage() { break; } - const connection = connections[index]; + const connection = targets[index]; setOneByOneCurrentConnectionId(connection.id); setOneByOneResults((prev) => ({ ...prev, @@ -649,14 +691,132 @@ export default function ProviderDetailPage() { } setOneByOneSummary({ - total: connections.length, + total: targets.length, completed: index + 1, passed, failed, stopped: false, }); - if (index < connections.length - 1) { + if (index < targets.length - 1) { + await sleep(ONE_BY_ONE_DELAY_MS); + } + } + } finally { + setOneByOneCurrentConnectionId(null); + setOneByOneRunning(false); + setOneByOneStopping(false); + stopOneByOneRef.current = false; + } + }; + + const openBulkModelTestModal = () => { + if (selectedConnectionIds.length === 0 || oneByOneRunning) return; + const defaultModelId = connectionTestModels[0]?.id || ""; + setBulkTestModelId(defaultModelId); + setManualBulkTestModelId(""); + setShowBulkModelTestModal(true); + }; + + const resolveBulkTestModelId = () => { + if (connectionTestModels.length > 0) return bulkTestModelId?.trim() || ""; + return manualBulkTestModelId?.trim() || ""; + }; + + // Real model call for selected accounts only (pins each connection via connectionId). + const handleRunSelectedModelTest = async () => { + if (oneByOneRunning || selectedConnectionIds.length === 0) return; + + const modelId = resolveBulkTestModelId(); + if (!modelId) return; + + const targets = connections.filter((connection) => selectedConnectionIds.includes(connection.id)); + if (targets.length === 0) return; + + setShowBulkModelTestModal(false); + + const fullModel = `${providerStorageAlias}/${modelId}`; + const queuedState = Object.fromEntries( + targets.map((connection) => [connection.id, { state: "queued", error: null }]), + ); + + stopOneByOneRef.current = false; + setOneByOneRunning(true); + setOneByOneStopping(false); + setOneByOneCurrentConnectionId(null); + setOneByOneResults(queuedState); + setOneByOneSummary({ total: targets.length, completed: 0, passed: 0, failed: 0, stopped: false }); + + let passed = 0; + let failed = 0; + + try { + for (let index = 0; index < targets.length; index += 1) { + if (stopOneByOneRef.current) { + setOneByOneSummary({ + total: targets.length, + completed: index, + passed, + failed, + stopped: true, + }); + break; + } + + const connection = targets[index]; + setOneByOneCurrentConnectionId(connection.id); + setOneByOneResults((prev) => ({ + ...prev, + [connection.id]: { state: "testing", error: null }, + })); + + try { + const res = await fetch("/api/models/test", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + model: fullModel, + connectionId: connection.id, + }), + }); + const data = await res.json().catch(() => ({})); + const ok = res.ok && !!data.ok; + + if (ok) { + passed += 1; + } else { + failed += 1; + } + + setOneByOneResults((prev) => ({ + ...prev, + [connection.id]: { + state: ok ? "success" : "failed", + error: ok + ? null + : (data.error || (res.ok ? "Model not reachable" : `HTTP ${res.status}`)), + }, + })); + } catch (error) { + failed += 1; + setOneByOneResults((prev) => ({ + ...prev, + [connection.id]: { + state: "failed", + error: error.message || "Test failed", + }, + })); + } + + setOneByOneSummary({ + total: targets.length, + completed: index + 1, + passed, + failed, + stopped: false, + }); + + if (index < targets.length - 1) { await sleep(ONE_BY_ONE_DELAY_MS); } } @@ -963,6 +1123,8 @@ export default function ProviderDetailPage() { }} onDelete={() => handleDelete(conn.id)} oneByOneStatus={oneByOneResults[conn.id] || null} + testModels={connectionTestModels} + providerAlias={providerStorageAlias} /> @@ -1444,14 +1606,28 @@ export default function ProviderDetailPage() { {connections.length > 0 && ( <> {selectedConnectionIds.length > 0 && ( - + <> + + + )} + + + + + {/* AG Risk Confirmation Modal */} 0) { const restResults = await Promise.all( rest.map(async (model) => { - const result = await pingModelByKind(`${alias}/${model.id}`, model.kind || model.type || "llm", baseUrl); + const result = await pingModelByKind(`${alias}/${model.id}`, model.kind || model.type || "llm", baseUrl, pinOpts); return { modelId: model.id, name: model.name || model.id, ...result }; }) ); diff --git a/src/sse/handlers/chat.js b/src/sse/handlers/chat.js index 571930e0..92b34c00 100644 --- a/src/sse/handlers/chat.js +++ b/src/sse/handlers/chat.js @@ -198,14 +198,16 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re // Extract userAgent from request const userAgent = request?.headers?.get("user-agent") || ""; + // Optional pin to a specific connection (dashboard test / client override) + const preferredConnectionId = request?.headers?.get("x-connection-id") || null; - // Try with available accounts (fallback on errors) + // Try with available accounts (fallback on errors unless pinned) const excludeConnectionIds = new Set(); let lastError = null; let lastStatus = null; while (true) { - const credentials = await getProviderCredentials(provider, excludeConnectionIds, model); + const credentials = await getProviderCredentials(provider, excludeConnectionIds, model, { preferredConnectionId }); // All accounts unavailable if (!credentials || credentials.allRateLimited) { @@ -280,6 +282,11 @@ async function handleSingleModelChat(body, modelStr, clientRawRequest = null, re const { shouldFallback } = await markAccountUnavailable(credentials.connectionId, result.status, result.error, provider, model, result.resetsAtMs); if (shouldFallback) { + // When a connection is explicitly pinned, never rotate to another account. + if (preferredConnectionId) { + log.warn("AUTH", `Pinned account ${credentials.connectionName} unavailable (${result.status}), no fallback`); + return result.response; + } log.warn("AUTH", `Account ${credentials.connectionName} unavailable (${result.status}), trying fallback`); excludeConnectionIds.add(credentials.connectionId); lastError = result.error; diff --git a/src/sse/handlers/embeddings.js b/src/sse/handlers/embeddings.js index cde0d41e..f6a945b5 100644 --- a/src/sse/handlers/embeddings.js +++ b/src/sse/handlers/embeddings.js @@ -79,13 +79,16 @@ export async function handleEmbeddings(request) { log.info("ROUTING", `Provider: ${provider}, Model: ${model}`); } + // Optional pin to a specific connection (dashboard test / client override) + const preferredConnectionId = request?.headers?.get("x-connection-id") || null; + // Credential + fallback loop (mirrors handleChat) const excludeConnectionIds = new Set(); let lastError = null; let lastStatus = null; while (true) { - const credentials = await getProviderCredentials(provider, excludeConnectionIds, model); + const credentials = await getProviderCredentials(provider, excludeConnectionIds, model, { preferredConnectionId }); // All accounts unavailable if (!credentials || credentials.allRateLimited) { @@ -129,6 +132,10 @@ export async function handleEmbeddings(request) { const { shouldFallback } = await markAccountUnavailable(credentials.connectionId, result.status, result.error, provider, model); if (shouldFallback) { + if (preferredConnectionId) { + log.warn("AUTH", `Pinned account ${credentials.connectionName} unavailable (${result.status}), no fallback`); + return result.response; + } log.warn("AUTH", `Account ${credentials.connectionName} unavailable (${result.status}), trying fallback`); excludeConnectionIds.add(credentials.connectionId); lastError = result.error; diff --git a/src/sse/services/auth.js b/src/sse/services/auth.js index 241082d4..9abfe35a 100644 --- a/src/sse/services/auth.js +++ b/src/sse/services/auth.js @@ -103,15 +103,21 @@ export async function getProviderCredentials(provider, excludeConnectionIds = nu const strategy = providerOverride.fallbackStrategy || settings.fallbackStrategy || "fill-first"; let connection; - // Pin to preferred connection if specified and available + // Strict pin: only use the requested connection (no strategy fallback). + // Allows model-locked accounts so explicit tests can still hit that account. if (preferredConnectionId) { - connection = availableConnections.find((c) => c.id === preferredConnectionId); - if (connection) { - log.info("AUTH", `${provider} | pinned to ${connection.id?.slice(0, 8)} (${connection.name || connection.email || "unnamed"})`); + connection = connections.find((c) => c.id === preferredConnectionId); + if (!connection || excludeSet.has(connection.id)) { + log.warn( + "AUTH", + `${provider} | preferred connection ${preferredConnectionId.slice(0, 8)} not found/active or excluded`, + ); + return null; } - } - if (connection) { - // skip strategy + log.info( + "AUTH", + `${provider} | pinned to ${connection.id?.slice(0, 8)} (${connection.name || connection.email || "unnamed"})`, + ); } else if (strategy === "round-robin") { const stickyLimit = providerOverride.stickyRoundRobinLimit || settings.stickyRoundRobinLimit || 3;