From 3b1a608e8d92fc533b395c0706cf6d58fadd74f3 Mon Sep 17 00:00:00 2001 From: decolua Date: Mon, 13 Apr 2026 15:52:01 +0700 Subject: [PATCH] Fix codex cache session id --- CHANGELOG.md | 15 + open-sse/executors/codex.js | 34 +- .../dashboard/endpoint/EndpointPageClient.js | 51 +- .../providers/[id]/AddApiKeyModal.js | 164 +++ .../providers/[id]/AddCustomModelModal.js | 117 ++ .../providers/[id]/CompatibleModelsSection.js | 250 ++++ .../dashboard/providers/[id]/ConnectionRow.js | 261 ++++ .../dashboard/providers/[id]/CooldownTimer.js | 42 + .../providers/[id]/EditCompatibleNodeModal.js | 161 +++ .../dashboard/providers/[id]/ModelRow.js | 86 ++ .../[id]/PassthroughModelsSection.js | 183 +++ .../dashboard/providers/[id]/page.js | 1174 +---------------- .../usage/components/RequestDetailsTab.js | 10 +- src/sse/handlers/chat.js | 6 + 14 files changed, 1362 insertions(+), 1192 deletions(-) create mode 100644 src/app/(dashboard)/dashboard/providers/[id]/AddApiKeyModal.js create mode 100644 src/app/(dashboard)/dashboard/providers/[id]/AddCustomModelModal.js create mode 100644 src/app/(dashboard)/dashboard/providers/[id]/CompatibleModelsSection.js create mode 100644 src/app/(dashboard)/dashboard/providers/[id]/ConnectionRow.js create mode 100644 src/app/(dashboard)/dashboard/providers/[id]/CooldownTimer.js create mode 100644 src/app/(dashboard)/dashboard/providers/[id]/EditCompatibleNodeModal.js create mode 100644 src/app/(dashboard)/dashboard/providers/[id]/ModelRow.js create mode 100644 src/app/(dashboard)/dashboard/providers/[id]/PassthroughModelsSection.js diff --git a/CHANGELOG.md b/CHANGELOG.md index fc3bf38c..a907c313 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,18 @@ +# v0.3.86 (2026-04-13) + +## Features +- Add provider models and thinking configurations for enhanced chat handling +- Add Vercel relay support to proxy functionality +- Add Vercel deploy endpoint for proxy pools management + +## Improvements +- Enhance proxy functionality with new relay capabilities +- Streamline GitHub Actions Docker publish workflow +- Update Docker configuration and package management + +## Fixes +- Remove obsolete 9remote installation/management APIs + # v0.3.83 (2026-04-08) ## Fixes diff --git a/open-sse/executors/codex.js b/open-sse/executors/codex.js index bfcf2f8c..20e6c8b4 100644 --- a/open-sse/executors/codex.js +++ b/open-sse/executors/codex.js @@ -3,11 +3,16 @@ import { BaseExecutor } from "./base.js"; import { CODEX_DEFAULT_INSTRUCTIONS } from "../config/codexInstructions.js"; import { PROVIDERS } from "../config/providers.js"; import { normalizeResponsesInput } from "../translator/helpers/responsesApiHelper.js"; +import { getConsistentMachineId } from "../../src/shared/utils/machineId.js"; -// In-memory map: hash(first assistant content) → { sessionId, lastUsed } +// In-memory map: hash(machineId + first assistant content) → { sessionId, lastUsed } const SESSION_TTL_MS = 60 * 60 * 1000; // 1 hour const assistantSessionMap = new Map(); +// Cache machine ID at module level (resolved once) +let cachedMachineId = null; +getConsistentMachineId().then(id => { cachedMachineId = id; }); + function hashContent(text) { return createHash("sha256").update(text).digest("hex").slice(0, 16); } @@ -26,17 +31,22 @@ function extractItemText(item) { return ""; } -// Resolve session_id from first assistant message in conversation history -function resolveConversationSessionId(input) { - if (!Array.isArray(input) || input.length === 0) return generateSessionId(); +// Resolve session_id from first assistant message + machineId to avoid cross-user collision +function resolveConversationSessionId(input, machineId) { + const machineSessionId = machineId ? `sess_${hashContent(machineId)}` : generateSessionId(); + if (!Array.isArray(input) || input.length === 0) return machineSessionId; - const firstAssistant = input.find(item => item.role === "assistant"); - if (!firstAssistant) return generateSessionId(); // Turn 1: no assistant yet + // Find first assistant message that has actual text content + let text = ""; + for (const item of input) { + if (item.role === "assistant") { + text = extractItemText(item); + if (text) break; + } + } + if (!text) return machineSessionId; - const text = extractItemText(firstAssistant); - if (!text) return generateSessionId(); - - const hash = hashContent(text); + const hash = hashContent((machineId || "") + text); const entry = assistantSessionMap.get(hash); if (entry) { entry.lastUsed = Date.now(); @@ -81,8 +91,8 @@ export class CodexExecutor extends BaseExecutor { * Transform request before sending - inject default instructions if missing */ transformRequest(model, body, stream, credentials) { - // Resolve conversation-stable session_id from input history - this._currentSessionId = resolveConversationSessionId(body.input); + // Resolve conversation-stable session_id from input history + machineId + this._currentSessionId = resolveConversationSessionId(body.input, cachedMachineId); // Convert string input to array format (Codex API requires input as array) const normalized = normalizeResponsesInput(body.input); if (normalized) body.input = normalized; diff --git a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js index 5119f6c9..7535312a 100644 --- a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js +++ b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js @@ -626,10 +626,19 @@ export default function APIPageClient({ machineId }) { ) : tunnelLoading ? ( -
- progress_activity - {tunnelProgress || "Creating tunnel..."} -
+ <> +
+ progress_activity + {tunnelProgress || "Creating tunnel..."} +
+ + ) : tunnelStatus?.type === "error" ? ( <>
@@ -639,10 +648,19 @@ export default function APIPageClient({ machineId }) { ) : tunnelChecking ? ( -
- progress_activity - Checking... -
+ <> +
+ progress_activity + Checking... +
+ + ) : ( + ) : tsStatus?.type === "error" ? ( <>
diff --git a/src/app/(dashboard)/dashboard/providers/[id]/AddApiKeyModal.js b/src/app/(dashboard)/dashboard/providers/[id]/AddApiKeyModal.js new file mode 100644 index 00000000..ef461db0 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/AddApiKeyModal.js @@ -0,0 +1,164 @@ +"use client"; + +import { useState } from "react"; +import PropTypes from "prop-types"; +import { Button, Badge, Input, Modal, Select } from "@/shared/components"; + +export default function AddApiKeyModal({ isOpen, provider, providerName, isCompatible, isAnthropic, proxyPools, onSave, onClose }) { + const NONE_PROXY_POOL_VALUE = "__none__"; + + const [formData, setFormData] = useState({ + name: "", + apiKey: "", + priority: 1, + proxyPoolId: NONE_PROXY_POOL_VALUE, + }); + const [validating, setValidating] = useState(false); + const [validationResult, setValidationResult] = useState(null); + const [saving, setSaving] = useState(false); + + const handleValidate = async () => { + setValidating(true); + try { + const res = await fetch("/api/providers/validate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider, apiKey: formData.apiKey }), + }); + const data = await res.json(); + setValidationResult(data.valid ? "success" : "failed"); + } catch { + setValidationResult("failed"); + } finally { + setValidating(false); + } + }; + + const handleSubmit = async () => { + if (!provider || !formData.apiKey) return; + + setSaving(true); + try { + let isValid = false; + try { + setValidating(true); + setValidationResult(null); + const res = await fetch("/api/providers/validate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ provider, apiKey: formData.apiKey }), + }); + const data = await res.json(); + isValid = !!data.valid; + setValidationResult(isValid ? "success" : "failed"); + } catch { + setValidationResult("failed"); + } finally { + setValidating(false); + } + + await onSave({ + name: formData.name, + apiKey: formData.apiKey, + priority: formData.priority, + proxyPoolId: formData.proxyPoolId === NONE_PROXY_POOL_VALUE ? null : formData.proxyPoolId, + testStatus: isValid ? "active" : "unknown", + providerSpecificData: undefined + }); + } finally { + setSaving(false); + } + }; + + if (!provider) return null; + + return ( + +
+ setFormData({ ...formData, name: e.target.value })} + placeholder="Production Key" + /> +
+ setFormData({ ...formData, apiKey: e.target.value })} + className="flex-1" + /> +
+ +
+
+ {validationResult && ( + + {validationResult === "success" ? "Valid" : "Invalid"} + + )} + {isCompatible && ( +

+ {isAnthropic + ? `Validation checks ${providerName || "Anthropic Compatible"} by verifying the API key.` + : `Validation checks ${providerName || "OpenAI Compatible"} via /models on your base URL.` + } +

+ )} + setFormData({ ...formData, priority: Number.parseInt(e.target.value) || 1 })} + /> + + { setModelId(e.target.value); setTestStatus(null); setTestError(""); }} + onKeyDown={handleKeyDown} + placeholder="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" + autoFocus + /> + +
+

+ Sent to provider as: {modelId.trim() || "model-id"} +

+
+ + {/* Test result */} + {testStatus === "ok" && ( +
+ check_circle + Model is reachable +
+ )} + {testStatus === "error" && ( +
+ cancel + {testError || "Model not reachable"} +
+ )} + +
+ + +
+
+ + ); +} + +AddCustomModelModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + providerAlias: PropTypes.string.isRequired, + providerDisplayAlias: PropTypes.string.isRequired, + 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 new file mode 100644 index 00000000..33f1f05a --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/CompatibleModelsSection.js @@ -0,0 +1,250 @@ +"use client"; + +import { useState } from "react"; +import PropTypes from "prop-types"; +import { Button } from "@/shared/components"; +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, copied, onCopy, onSetAlias, onDeleteAlias, connections, isAnthropic }) { + const [newModel, setNewModel] = useState(""); + const [adding, setAdding] = useState(false); + const [importing, setImporting] = useState(false); + const [testingModelId, setTestingModelId] = useState(null); + const [modelTestResults, setModelTestResults] = useState({}); + + 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); + } + }; + + const providerAliases = Object.entries(modelAliases).filter( + ([, model]) => model.startsWith(`${providerStorageAlias}/`) + ); + + const allModels = providerAliases.map(([alias, fullModel]) => ({ + modelId: fullModel.replace(`${providerStorageAlias}/`, ""), + fullModel, + alias, + })); + + const generateDefaultAlias = (modelId) => { + const parts = modelId.split("/"); + return parts[parts.length - 1]; + }; + + const resolveAlias = (modelId) => { + const fullModel = `${providerStorageAlias}/${modelId}`; + // Skip if this exact model already has an alias + if (Object.values(modelAliases).includes(fullModel)) return null; + const baseAlias = generateDefaultAlias(modelId); + if (!modelAliases[baseAlias]) return baseAlias; + const prefixedAlias = `${providerDisplayAlias}-${baseAlias}`; + if (!modelAliases[prefixedAlias]) return prefixedAlias; + return null; + }; + + const handleAdd = async () => { + if (!newModel.trim() || adding) return; + const modelId = newModel.trim(); + const resolvedAlias = resolveAlias(modelId); + if (!resolvedAlias) { + alert("All suggested aliases already exist. Please choose a different model or remove conflicting aliases."); + return; + } + + setAdding(true); + try { + await onSetAlias(modelId, resolvedAlias, providerStorageAlias); + setNewModel(""); + } catch (error) { + console.log("Error adding model:", error); + } finally { + setAdding(false); + } + }; + + const handleImport = async () => { + if (importing) return; + const activeConnection = connections.find((conn) => conn.isActive !== false); + if (!activeConnection) return; + + setImporting(true); + try { + const res = await fetch(`/api/providers/${activeConnection.id}/models`); + const data = await res.json(); + if (!res.ok) { + alert(data.error || "Failed to import models"); + return; + } + const models = data.models || []; + if (models.length === 0) { + alert("No models returned from /models."); + return; + } + let importedCount = 0; + for (const model of models) { + const modelId = model.id || model.name || model.model; + if (!modelId) continue; + const resolvedAlias = resolveAlias(modelId); + if (!resolvedAlias) continue; + await onSetAlias(modelId, resolvedAlias, providerStorageAlias); + importedCount += 1; + } + if (importedCount === 0) { + alert("No new models were added."); + } + } catch (error) { + console.log("Error importing models:", error); + } finally { + setImporting(false); + } + }; + + const canImport = connections.some((conn) => conn.isActive !== false); + + return ( +
+

+ Add {isAnthropic ? "Anthropic" : "OpenAI"}-compatible models manually or import them from the /models endpoint. +

+ +
+
+ + 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" + /> +
+ + +
+ + {!canImport && ( +

+ Add a connection to enable importing models. +

+ )} + + {allModels.length > 0 && ( +
+ {allModels.map(({ modelId, fullModel, alias }) => ( + onDeleteAlias(alias)} + onTest={connections.length > 0 ? () => handleTestModel(modelId) : undefined} + testStatus={modelTestResults[modelId]} + isTesting={testingModelId === modelId} + /> + ))} +
+ )} +
+ ); +} + +CompatibleModelsSection.propTypes = { + providerStorageAlias: PropTypes.string.isRequired, + providerDisplayAlias: PropTypes.string.isRequired, + modelAliases: PropTypes.object.isRequired, + copied: PropTypes.string, + onCopy: PropTypes.func.isRequired, + onSetAlias: PropTypes.func.isRequired, + onDeleteAlias: PropTypes.func.isRequired, + connections: PropTypes.arrayOf(PropTypes.shape({ + id: PropTypes.string, + isActive: PropTypes.bool, + })).isRequired, + isAnthropic: PropTypes.bool, +}; diff --git a/src/app/(dashboard)/dashboard/providers/[id]/ConnectionRow.js b/src/app/(dashboard)/dashboard/providers/[id]/ConnectionRow.js new file mode 100644 index 00000000..257270d1 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/ConnectionRow.js @@ -0,0 +1,261 @@ +"use client"; + +import { useState, useEffect, useRef } from "react"; +import PropTypes from "prop-types"; +import { Badge, Toggle } from "@/shared/components"; +import CooldownTimer from "./CooldownTimer"; + +export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst, isLast, onMoveUp, onMoveDown, onToggleActive, onUpdateProxy, onEdit, onDelete }) { + const [showProxyDropdown, setShowProxyDropdown] = useState(false); + const [updatingProxy, setUpdatingProxy] = useState(false); + const proxyDropdownRef = useRef(null); + + const proxyPoolMap = new Map((proxyPools || []).map((pool) => [pool.id, pool])); + const boundProxyPoolId = connection.providerSpecificData?.proxyPoolId || null; + const boundProxyPool = boundProxyPoolId ? proxyPoolMap.get(boundProxyPoolId) : null; + const hasLegacyProxy = connection.providerSpecificData?.connectionProxyEnabled === true && !!connection.providerSpecificData?.connectionProxyUrl; + const hasAnyProxy = !!boundProxyPoolId || hasLegacyProxy; + const proxyDisplayText = boundProxyPool + ? `Pool: ${boundProxyPool.name}` + : boundProxyPoolId + ? `Pool: ${boundProxyPoolId} (inactive/missing)` + : hasLegacyProxy + ? `Legacy: ${connection.providerSpecificData?.connectionProxyUrl}` + : ""; + + let maskedProxyUrl = ""; + if (boundProxyPool?.proxyUrl || connection.providerSpecificData?.connectionProxyUrl) { + const rawProxyUrl = boundProxyPool?.proxyUrl || connection.providerSpecificData?.connectionProxyUrl; + try { + const parsed = new URL(rawProxyUrl); + maskedProxyUrl = `${parsed.protocol}//${parsed.hostname}${parsed.port ? `:${parsed.port}` : ""}`; + } catch { + maskedProxyUrl = rawProxyUrl; + } + } + + const noProxyText = boundProxyPool?.noProxy || connection.providerSpecificData?.connectionNoProxy || ""; + + let proxyBadgeVariant = "default"; + if (boundProxyPool?.isActive === true) { + proxyBadgeVariant = "success"; + } else if (boundProxyPoolId || hasLegacyProxy) { + proxyBadgeVariant = "error"; + } + + // Close dropdown when clicking outside + useEffect(() => { + if (!showProxyDropdown) return; + const handler = (e) => { + if (proxyDropdownRef.current && !proxyDropdownRef.current.contains(e.target)) { + setShowProxyDropdown(false); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [showProxyDropdown]); + + const handleSelectProxy = async (poolId) => { + setUpdatingProxy(true); + try { + await onUpdateProxy(poolId === "__none__" ? null : poolId); + } finally { + setUpdatingProxy(false); + setShowProxyDropdown(false); + } + }; + + const displayName = isOAuth + ? connection.name || connection.email || connection.displayName || "OAuth Account" + : connection.name; + + // Use useState + useEffect for impure Date.now() to avoid calling during render + const [isCooldown, setIsCooldown] = useState(false); + + // Get earliest model lock timestamp (useEffect handles the Date.now() comparison) + const modelLockUntil = Object.entries(connection) + .filter(([k]) => k.startsWith("modelLock_")) + .map(([, v]) => v) + .filter(v => !!v) + .sort()[0] || null; + + useEffect(() => { + const checkCooldown = () => { + const until = Object.entries(connection) + .filter(([k]) => k.startsWith("modelLock_")) + .map(([, v]) => v) + .filter(v => v && new Date(v).getTime() > Date.now()) + .sort()[0] || null; + setIsCooldown(!!until); + }; + + checkCooldown(); + const interval = modelLockUntil ? setInterval(checkCooldown, 1000) : null; + return () => { + if (interval) clearInterval(interval); + }; + }, [modelLockUntil]); + + // Determine effective status (override unavailable if cooldown expired) + const effectiveStatus = (connection.testStatus === "unavailable" && !isCooldown) + ? "active" // Cooldown expired u2192 treat as active + : connection.testStatus; + + const getStatusVariant = () => { + if (connection.isActive === false) return "default"; + if (effectiveStatus === "active" || effectiveStatus === "success") return "success"; + if (effectiveStatus === "error" || effectiveStatus === "expired" || effectiveStatus === "unavailable") return "error"; + return "default"; + }; + + return ( +
+
+ {/* Priority arrows */} +
+ + +
+ + {isOAuth ? "lock" : "key"} + +
+

{displayName}

+
+ + {connection.isActive === false ? "disabled" : (effectiveStatus || "Unknown")} + + {hasAnyProxy && ( + + Proxy + + )} + {isCooldown && connection.isActive !== false && } + {connection.lastError && connection.isActive !== false && ( + + {connection.lastError} + + )} + #{connection.priority} + {connection.globalPriority && ( + Auto: {connection.globalPriority} + )} +
+ {hasAnyProxy && ( +
+ + {proxyDisplayText} + + {maskedProxyUrl && ( + + {maskedProxyUrl} + + )} + {noProxyText && ( + + no_proxy: {noProxyText} + + )} +
+ )} +
+
+
+
+ {/* Proxy button with inline dropdown */} + {(proxyPools || []).length > 0 && ( +
+ + {showProxyDropdown && ( +
+ + {(proxyPools || []).map((pool) => ( + + ))} +
+ )} +
+ )} + + +
+ +
+
+ ); +} + +ConnectionRow.propTypes = { + connection: PropTypes.shape({ + id: PropTypes.string, + name: PropTypes.string, + email: PropTypes.string, + displayName: PropTypes.string, + modelLockUntil: PropTypes.string, + testStatus: PropTypes.string, + isActive: PropTypes.bool, + lastError: PropTypes.string, + priority: PropTypes.number, + globalPriority: PropTypes.number, + }).isRequired, + proxyPools: PropTypes.arrayOf(PropTypes.shape({ + id: PropTypes.string, + name: PropTypes.string, + proxyUrl: PropTypes.string, + noProxy: PropTypes.string, + isActive: PropTypes.bool, + })), + isOAuth: PropTypes.bool.isRequired, + isFirst: PropTypes.bool.isRequired, + isLast: PropTypes.bool.isRequired, + onMoveUp: PropTypes.func.isRequired, + onMoveDown: PropTypes.func.isRequired, + onToggleActive: PropTypes.func.isRequired, + onUpdateProxy: PropTypes.func, + onEdit: PropTypes.func.isRequired, + onDelete: PropTypes.func.isRequired, +}; + diff --git a/src/app/(dashboard)/dashboard/providers/[id]/CooldownTimer.js b/src/app/(dashboard)/dashboard/providers/[id]/CooldownTimer.js new file mode 100644 index 00000000..50e99dad --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/CooldownTimer.js @@ -0,0 +1,42 @@ +import { useState, useEffect } from "react"; +import PropTypes from "prop-types"; + +export default function CooldownTimer({ until }) { + const [remaining, setRemaining] = useState(""); + + useEffect(() => { + const updateRemaining = () => { + const diff = new Date(until).getTime() - Date.now(); + if (diff <= 0) { + setRemaining(""); + return; + } + const secs = Math.floor(diff / 1000); + if (secs < 60) { + setRemaining(`${secs}s`); + } else if (secs < 3600) { + setRemaining(`${Math.floor(secs / 60)}m ${secs % 60}s`); + } else { + const hrs = Math.floor(secs / 3600); + const mins = Math.floor((secs % 3600) / 60); + setRemaining(`${hrs}h ${mins}m`); + } + }; + + updateRemaining(); + const interval = setInterval(updateRemaining, 1000); + return () => clearInterval(interval); + }, [until]); + + if (!remaining) return null; + + return ( + + ⏱ {remaining} + + ); +} + +CooldownTimer.propTypes = { + until: PropTypes.string.isRequired, +}; diff --git a/src/app/(dashboard)/dashboard/providers/[id]/EditCompatibleNodeModal.js b/src/app/(dashboard)/dashboard/providers/[id]/EditCompatibleNodeModal.js new file mode 100644 index 00000000..d6d33de1 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/EditCompatibleNodeModal.js @@ -0,0 +1,161 @@ +"use client"; + +import { useState, useEffect } from "react"; +import PropTypes from "prop-types"; +import { Button, Badge, Input, Modal, Select } from "@/shared/components"; + +export default function EditCompatibleNodeModal({ isOpen, node, onSave, onClose, isAnthropic }) { + const [formData, setFormData] = useState({ + name: "", + prefix: "", + apiType: "chat", + baseUrl: "https://api.openai.com/v1", + }); + const [saving, setSaving] = useState(false); + const [checkKey, setCheckKey] = useState(""); + const [checkModelId, setCheckModelId] = useState(""); + const [validating, setValidating] = useState(false); + const [validationResult, setValidationResult] = useState(null); + + useEffect(() => { + if (node) { + setFormData({ + name: node.name || "", + prefix: node.prefix || "", + apiType: node.apiType || "chat", + baseUrl: node.baseUrl || (isAnthropic ? "https://api.anthropic.com/v1" : "https://api.openai.com/v1"), + }); + } + }, [node, isAnthropic]); + + const apiTypeOptions = [ + { value: "chat", label: "Chat Completions" }, + { value: "responses", label: "Responses API" }, + ]; + + const handleSubmit = async () => { + if (!formData.name.trim() || !formData.prefix.trim() || !formData.baseUrl.trim()) return; + setSaving(true); + try { + const payload = { + name: formData.name, + prefix: formData.prefix, + baseUrl: formData.baseUrl, + }; + if (!isAnthropic) { + payload.apiType = formData.apiType; + } + await onSave(payload); + } finally { + setSaving(false); + } + }; + + 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: isAnthropic ? "anthropic-compatible" : "openai-compatible", + modelId: checkModelId.trim() || undefined + }), + }); + const data = await res.json(); + setValidationResult(data.valid ? "success" : "failed"); + } catch { + setValidationResult("failed"); + } finally { + setValidating(false); + } + }; + + if (!node) return null; + + return ( + +
+ setFormData({ ...formData, name: e.target.value })} + placeholder={`${isAnthropic ? "Anthropic" : "OpenAI"} Compatible (Prod)`} + hint="Required. A friendly label for this node." + /> + setFormData({ ...formData, prefix: e.target.value })} + placeholder={isAnthropic ? "ac-prod" : "oc-prod"} + hint="Required. Used as the provider prefix for model IDs." + /> + {!isAnthropic && ( + setFormData({ ...formData, baseUrl: e.target.value })} + placeholder={isAnthropic ? "https://api.anthropic.com/v1" : "https://api.openai.com/v1"} + hint={`Use the base URL (ending in /v1) for your ${isAnthropic ? "Anthropic" : "OpenAI"}-compatible API.`} + /> +
+ setCheckKey(e.target.value)} + className="flex-1" + /> +
+ +
+
+ setCheckModelId(e.target.value)} + placeholder="e.g. my-model-id" + hint="If provider lacks /models endpoint, enter a model ID to validate via chat/completions instead." + /> + {validationResult && ( + + {validationResult === "success" ? "Valid" : "Invalid"} + + )} +
+ + +
+
+
+ ); +} + +EditCompatibleNodeModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + node: PropTypes.shape({ + id: PropTypes.string, + name: PropTypes.string, + prefix: PropTypes.string, + apiType: PropTypes.string, + baseUrl: PropTypes.string, + }), + onSave: PropTypes.func.isRequired, + onClose: PropTypes.func.isRequired, + isAnthropic: PropTypes.bool, +}; diff --git a/src/app/(dashboard)/dashboard/providers/[id]/ModelRow.js b/src/app/(dashboard)/dashboard/providers/[id]/ModelRow.js new file mode 100644 index 00000000..44d78c52 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/ModelRow.js @@ -0,0 +1,86 @@ +import PropTypes from "prop-types"; + +export default function ModelRow({ model, fullModel, alias, copied, onCopy, testStatus, isCustom, isFree, onDeleteAlias, onTest, 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"} + +
+ {fullModel} + {model.name && {model.name}} +
+ {onTest && ( +
+ + + {isTesting ? "Testing..." : "Test"} + +
+ )} +
+ + + {copied === `model-${model.id}` ? "Copied!" : "Copy"} + +
+ {isCustom && ( + + )} +
+
+ ); +} + +ModelRow.propTypes = { + model: PropTypes.shape({ + id: PropTypes.string.isRequired, + }).isRequired, + fullModel: PropTypes.string.isRequired, + alias: PropTypes.string, + copied: PropTypes.string, + onCopy: PropTypes.func.isRequired, + testStatus: PropTypes.oneOf(["ok", "error"]), + isCustom: PropTypes.bool, + isFree: PropTypes.bool, + onDeleteAlias: PropTypes.func, + onTest: PropTypes.func, + isTesting: PropTypes.bool, +}; diff --git a/src/app/(dashboard)/dashboard/providers/[id]/PassthroughModelsSection.js b/src/app/(dashboard)/dashboard/providers/[id]/PassthroughModelsSection.js new file mode 100644 index 00000000..a58d34f8 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/PassthroughModelsSection.js @@ -0,0 +1,183 @@ +"use client"; + +import { useState } from "react"; +import PropTypes from "prop-types"; +import { Button } from "@/shared/components"; + +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 ( +
+ + {testStatus === "ok" ? "check_circle" : testStatus === "error" ? "cancel" : "smart_toy"} + + +
+

{modelId}

+ +
+ {fullModel} +
+ + + {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, copied, onCopy, onSetAlias, onDeleteAlias }) { + const [newModel, setNewModel] = useState(""); + const [adding, setAdding] = useState(false); + + // Filter aliases for this provider - models are persisted via alias + const providerAliases = Object.entries(modelAliases).filter( + ([, model]) => model.startsWith(`${providerAlias}/`) + ); + + const allModels = providerAliases.map(([alias, fullModel]) => ({ + modelId: fullModel.replace(`${providerAlias}/`, ""), + fullModel, + alias, + })); + + // Generate default alias from modelId (last part after /) + const generateDefaultAlias = (modelId) => { + const parts = modelId.split("/"); + return parts[parts.length - 1]; + }; + + const handleAdd = async () => { + if (!newModel.trim() || adding) return; + const modelId = newModel.trim(); + const defaultAlias = generateDefaultAlias(modelId); + + // Check if alias already exists + if (modelAliases[defaultAlias]) { + alert(`Alias "${defaultAlias}" already exists. Please use a different model or edit existing alias.`); + return; + } + + setAdding(true); + try { + await onSetAlias(modelId, defaultAlias); + 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(({ modelId, fullModel, alias }) => ( + onDeleteAlias(alias)} + /> + ))} +
+ )} +
+ ); +} + +PassthroughModelsSection.propTypes = { + providerAlias: PropTypes.string.isRequired, + modelAliases: PropTypes.object.isRequired, + copied: PropTypes.string, + onCopy: PropTypes.func.isRequired, + onSetAlias: PropTypes.func.isRequired, + onDeleteAlias: PropTypes.func.isRequired, +}; diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.js b/src/app/(dashboard)/dashboard/providers/[id]/page.js index b9077e48..19b970e4 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.js @@ -1,7 +1,6 @@ "use client"; -import { useState, useEffect, useCallback, useRef } from "react"; -import PropTypes from "prop-types"; +import { useState, useEffect, useCallback } from "react"; import { useParams, useRouter } from "next/navigation"; import Link from "next/link"; import Image from "next/image"; @@ -10,6 +9,13 @@ import { OAUTH_PROVIDERS, APIKEY_PROVIDERS, FREE_PROVIDERS, FREE_TIER_PROVIDERS, import { getModelsByProviderId } from "@/shared/constants/models"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; import { fetchSuggestedModels } from "@/shared/utils/providerModelsFetcher"; +import ModelRow from "./ModelRow"; +import PassthroughModelsSection from "./PassthroughModelsSection"; +import CompatibleModelsSection from "./CompatibleModelsSection"; +import ConnectionRow from "./ConnectionRow"; +import AddApiKeyModal from "./AddApiKeyModal"; +import EditCompatibleNodeModal from "./EditCompatibleNodeModal"; +import AddCustomModelModal from "./AddCustomModelModal"; export default function ProviderDetailPage() { const params = useParams(); @@ -1044,1167 +1050,3 @@ export default function ProviderDetailPage() { ); } - -function ModelRow({ model, fullModel, alias, copied, onCopy, testStatus, isCustom, isFree, onDeleteAlias, onTest, 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"} - -
- {fullModel} - {model.name && {model.name}} -
- {onTest && ( -
- - - {isTesting ? "Testing..." : "Test"} - -
- )} -
- - - {copied === `model-${model.id}` ? "Copied!" : "Copy"} - -
- {isCustom && ( - - )} -
-
- ); -} - -ModelRow.propTypes = { - model: PropTypes.shape({ - id: PropTypes.string.isRequired, - }).isRequired, - fullModel: PropTypes.string.isRequired, - alias: PropTypes.string, - copied: PropTypes.string, - onCopy: PropTypes.func.isRequired, - testStatus: PropTypes.oneOf(["ok", "error"]), - isCustom: PropTypes.bool, - isFree: PropTypes.bool, - onDeleteAlias: PropTypes.func, - onTest: PropTypes.func, - isTesting: PropTypes.bool, -}; - -function PassthroughModelsSection({ providerAlias, modelAliases, copied, onCopy, onSetAlias, onDeleteAlias }) { - const [newModel, setNewModel] = useState(""); - const [adding, setAdding] = useState(false); - - // Filter aliases for this provider - models are persisted via alias - const providerAliases = Object.entries(modelAliases).filter( - ([, model]) => model.startsWith(`${providerAlias}/`) - ); - - const allModels = providerAliases.map(([alias, fullModel]) => ({ - modelId: fullModel.replace(`${providerAlias}/`, ""), - fullModel, - alias, - })); - - // Generate default alias from modelId (last part after /) - const generateDefaultAlias = (modelId) => { - const parts = modelId.split("/"); - return parts[parts.length - 1]; - }; - - const handleAdd = async () => { - if (!newModel.trim() || adding) return; - const modelId = newModel.trim(); - const defaultAlias = generateDefaultAlias(modelId); - - // Check if alias already exists - if (modelAliases[defaultAlias]) { - alert(`Alias "${defaultAlias}" already exists. Please use a different model or edit existing alias.`); - return; - } - - setAdding(true); - try { - await onSetAlias(modelId, defaultAlias); - 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(({ modelId, fullModel, alias }) => ( - onDeleteAlias(alias)} - /> - ))} -
- )} -
- ); -} - -PassthroughModelsSection.propTypes = { - providerAlias: PropTypes.string.isRequired, - modelAliases: PropTypes.object.isRequired, - copied: PropTypes.string, - onCopy: PropTypes.func.isRequired, - onSetAlias: PropTypes.func.isRequired, - onDeleteAlias: PropTypes.func.isRequired, -}; - -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 ( -
- - {testStatus === "ok" ? "check_circle" : testStatus === "error" ? "cancel" : "smart_toy"} - - -
-

{modelId}

- -
- {fullModel} -
- - - {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, -}; - -function CompatibleModelsSection({ providerStorageAlias, providerDisplayAlias, modelAliases, copied, onCopy, onSetAlias, onDeleteAlias, connections, isAnthropic }) { - const [newModel, setNewModel] = useState(""); - const [adding, setAdding] = useState(false); - const [importing, setImporting] = useState(false); - const [testingModelId, setTestingModelId] = useState(null); - const [modelTestResults, setModelTestResults] = useState({}); - - 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); - } - }; - - const providerAliases = Object.entries(modelAliases).filter( - ([, model]) => model.startsWith(`${providerStorageAlias}/`) - ); - - const allModels = providerAliases.map(([alias, fullModel]) => ({ - modelId: fullModel.replace(`${providerStorageAlias}/`, ""), - fullModel, - alias, - })); - - const generateDefaultAlias = (modelId) => { - const parts = modelId.split("/"); - return parts[parts.length - 1]; - }; - - const resolveAlias = (modelId) => { - const fullModel = `${providerStorageAlias}/${modelId}`; - // Skip if this exact model already has an alias - if (Object.values(modelAliases).includes(fullModel)) return null; - const baseAlias = generateDefaultAlias(modelId); - if (!modelAliases[baseAlias]) return baseAlias; - const prefixedAlias = `${providerDisplayAlias}-${baseAlias}`; - if (!modelAliases[prefixedAlias]) return prefixedAlias; - return null; - }; - - const handleAdd = async () => { - if (!newModel.trim() || adding) return; - const modelId = newModel.trim(); - const resolvedAlias = resolveAlias(modelId); - if (!resolvedAlias) { - alert("All suggested aliases already exist. Please choose a different model or remove conflicting aliases."); - return; - } - - setAdding(true); - try { - await onSetAlias(modelId, resolvedAlias, providerStorageAlias); - setNewModel(""); - } catch (error) { - console.log("Error adding model:", error); - } finally { - setAdding(false); - } - }; - - const handleImport = async () => { - if (importing) return; - const activeConnection = connections.find((conn) => conn.isActive !== false); - if (!activeConnection) return; - - setImporting(true); - try { - const res = await fetch(`/api/providers/${activeConnection.id}/models`); - const data = await res.json(); - if (!res.ok) { - alert(data.error || "Failed to import models"); - return; - } - const models = data.models || []; - if (models.length === 0) { - alert("No models returned from /models."); - return; - } - let importedCount = 0; - for (const model of models) { - const modelId = model.id || model.name || model.model; - if (!modelId) continue; - const resolvedAlias = resolveAlias(modelId); - if (!resolvedAlias) continue; - await onSetAlias(modelId, resolvedAlias, providerStorageAlias); - importedCount += 1; - } - if (importedCount === 0) { - alert("No new models were added."); - } - } catch (error) { - console.log("Error importing models:", error); - } finally { - setImporting(false); - } - }; - - const canImport = connections.some((conn) => conn.isActive !== false); - - return ( -
-

- Add {isAnthropic ? "Anthropic" : "OpenAI"}-compatible models manually or import them from the /models endpoint. -

- -
-
- - 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" - /> -
- - -
- - {!canImport && ( -

- Add a connection to enable importing models. -

- )} - - {allModels.length > 0 && ( -
- {allModels.map(({ modelId, fullModel, alias }) => ( - onDeleteAlias(alias)} - onTest={connections.length > 0 ? () => handleTestModel(modelId) : undefined} - testStatus={modelTestResults[modelId]} - isTesting={testingModelId === modelId} - /> - ))} -
- )} -
- ); -} - -CompatibleModelsSection.propTypes = { - providerStorageAlias: PropTypes.string.isRequired, - providerDisplayAlias: PropTypes.string.isRequired, - modelAliases: PropTypes.object.isRequired, - copied: PropTypes.string, - onCopy: PropTypes.func.isRequired, - onSetAlias: PropTypes.func.isRequired, - onDeleteAlias: PropTypes.func.isRequired, - connections: PropTypes.arrayOf(PropTypes.shape({ - id: PropTypes.string, - isActive: PropTypes.bool, - })).isRequired, - isAnthropic: PropTypes.bool, -}; - -function CooldownTimer({ until }) { - const [remaining, setRemaining] = useState(""); - - useEffect(() => { - const updateRemaining = () => { - const diff = new Date(until).getTime() - Date.now(); - if (diff <= 0) { - setRemaining(""); - return; - } - const secs = Math.floor(diff / 1000); - if (secs < 60) { - setRemaining(`${secs}s`); - } else if (secs < 3600) { - setRemaining(`${Math.floor(secs / 60)}m ${secs % 60}s`); - } else { - const hrs = Math.floor(secs / 3600); - const mins = Math.floor((secs % 3600) / 60); - setRemaining(`${hrs}h ${mins}m`); - } - }; - - updateRemaining(); - const interval = setInterval(updateRemaining, 1000); - return () => clearInterval(interval); - }, [until]); - - if (!remaining) return null; - - return ( - - ⏱ {remaining} - - ); -} - -CooldownTimer.propTypes = { - until: PropTypes.string.isRequired, -}; - -function ConnectionRow({ connection, proxyPools, isOAuth, isFirst, isLast, onMoveUp, onMoveDown, onToggleActive, onUpdateProxy, onEdit, onDelete }) { - const [showProxyDropdown, setShowProxyDropdown] = useState(false); - const [updatingProxy, setUpdatingProxy] = useState(false); - const proxyDropdownRef = useRef(null); - - const proxyPoolMap = new Map((proxyPools || []).map((pool) => [pool.id, pool])); - const boundProxyPoolId = connection.providerSpecificData?.proxyPoolId || null; - const boundProxyPool = boundProxyPoolId ? proxyPoolMap.get(boundProxyPoolId) : null; - const hasLegacyProxy = connection.providerSpecificData?.connectionProxyEnabled === true && !!connection.providerSpecificData?.connectionProxyUrl; - const hasAnyProxy = !!boundProxyPoolId || hasLegacyProxy; - const proxyDisplayText = boundProxyPool - ? `Pool: ${boundProxyPool.name}` - : boundProxyPoolId - ? `Pool: ${boundProxyPoolId} (inactive/missing)` - : hasLegacyProxy - ? `Legacy: ${connection.providerSpecificData?.connectionProxyUrl}` - : ""; - - let maskedProxyUrl = ""; - if (boundProxyPool?.proxyUrl || connection.providerSpecificData?.connectionProxyUrl) { - const rawProxyUrl = boundProxyPool?.proxyUrl || connection.providerSpecificData?.connectionProxyUrl; - try { - const parsed = new URL(rawProxyUrl); - maskedProxyUrl = `${parsed.protocol}//${parsed.hostname}${parsed.port ? `:${parsed.port}` : ""}`; - } catch { - maskedProxyUrl = rawProxyUrl; - } - } - - const noProxyText = boundProxyPool?.noProxy || connection.providerSpecificData?.connectionNoProxy || ""; - - let proxyBadgeVariant = "default"; - if (boundProxyPool?.isActive === true) { - proxyBadgeVariant = "success"; - } else if (boundProxyPoolId || hasLegacyProxy) { - proxyBadgeVariant = "error"; - } - - // Close dropdown when clicking outside - useEffect(() => { - if (!showProxyDropdown) return; - const handler = (e) => { - if (proxyDropdownRef.current && !proxyDropdownRef.current.contains(e.target)) { - setShowProxyDropdown(false); - } - }; - document.addEventListener("mousedown", handler); - return () => document.removeEventListener("mousedown", handler); - }, [showProxyDropdown]); - - const handleSelectProxy = async (poolId) => { - setUpdatingProxy(true); - try { - await onUpdateProxy(poolId === "__none__" ? null : poolId); - } finally { - setUpdatingProxy(false); - setShowProxyDropdown(false); - } - }; - - const displayName = isOAuth - ? connection.name || connection.email || connection.displayName || "OAuth Account" - : connection.name; - - // Use useState + useEffect for impure Date.now() to avoid calling during render - const [isCooldown, setIsCooldown] = useState(false); - - // Get earliest model lock timestamp (useEffect handles the Date.now() comparison) - const modelLockUntil = Object.entries(connection) - .filter(([k]) => k.startsWith("modelLock_")) - .map(([, v]) => v) - .filter(v => !!v) - .sort()[0] || null; - - useEffect(() => { - const checkCooldown = () => { - const until = Object.entries(connection) - .filter(([k]) => k.startsWith("modelLock_")) - .map(([, v]) => v) - .filter(v => v && new Date(v).getTime() > Date.now()) - .sort()[0] || null; - setIsCooldown(!!until); - }; - - checkCooldown(); - const interval = modelLockUntil ? setInterval(checkCooldown, 1000) : null; - return () => { - if (interval) clearInterval(interval); - }; - }, [modelLockUntil]); - - // Determine effective status (override unavailable if cooldown expired) - const effectiveStatus = (connection.testStatus === "unavailable" && !isCooldown) - ? "active" // Cooldown expired → treat as active - : connection.testStatus; - - const getStatusVariant = () => { - if (connection.isActive === false) return "default"; - if (effectiveStatus === "active" || effectiveStatus === "success") return "success"; - if (effectiveStatus === "error" || effectiveStatus === "expired" || effectiveStatus === "unavailable") return "error"; - return "default"; - }; - - return ( -
-
- {/* Priority arrows */} -
- - -
- - {isOAuth ? "lock" : "key"} - -
-

{displayName}

-
- - {connection.isActive === false ? "disabled" : (effectiveStatus || "Unknown")} - - {hasAnyProxy && ( - - Proxy - - )} - {isCooldown && connection.isActive !== false && } - {connection.lastError && connection.isActive !== false && ( - - {connection.lastError} - - )} - #{connection.priority} - {connection.globalPriority && ( - Auto: {connection.globalPriority} - )} -
- {hasAnyProxy && ( -
- - {proxyDisplayText} - - {maskedProxyUrl && ( - - {maskedProxyUrl} - - )} - {noProxyText && ( - - no_proxy: {noProxyText} - - )} -
- )} -
-
-
-
- {/* Proxy button with inline dropdown */} - {(proxyPools || []).length > 0 && ( -
- - {showProxyDropdown && ( -
- - {(proxyPools || []).map((pool) => ( - - ))} -
- )} -
- )} - - -
- -
-
- ); -} - -ConnectionRow.propTypes = { - connection: PropTypes.shape({ - id: PropTypes.string, - name: PropTypes.string, - email: PropTypes.string, - displayName: PropTypes.string, - modelLockUntil: PropTypes.string, - testStatus: PropTypes.string, - isActive: PropTypes.bool, - lastError: PropTypes.string, - priority: PropTypes.number, - globalPriority: PropTypes.number, - }).isRequired, - proxyPools: PropTypes.arrayOf(PropTypes.shape({ - id: PropTypes.string, - name: PropTypes.string, - proxyUrl: PropTypes.string, - noProxy: PropTypes.string, - isActive: PropTypes.bool, - })), - isOAuth: PropTypes.bool.isRequired, - isFirst: PropTypes.bool.isRequired, - isLast: PropTypes.bool.isRequired, - onMoveUp: PropTypes.func.isRequired, - onMoveDown: PropTypes.func.isRequired, - onToggleActive: PropTypes.func.isRequired, - onUpdateProxy: PropTypes.func, - onEdit: PropTypes.func.isRequired, - onDelete: PropTypes.func.isRequired, -}; - -function AddApiKeyModal({ isOpen, provider, providerName, isCompatible, isAnthropic, proxyPools, onSave, onClose }) { - const NONE_PROXY_POOL_VALUE = "__none__"; - - const [formData, setFormData] = useState({ - name: "", - apiKey: "", - priority: 1, - proxyPoolId: NONE_PROXY_POOL_VALUE, - }); - const [validating, setValidating] = useState(false); - const [validationResult, setValidationResult] = useState(null); - const [saving, setSaving] = useState(false); - - const handleValidate = async () => { - setValidating(true); - try { - const res = await fetch("/api/providers/validate", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ provider, apiKey: formData.apiKey }), - }); - const data = await res.json(); - setValidationResult(data.valid ? "success" : "failed"); - } catch { - setValidationResult("failed"); - } finally { - setValidating(false); - } - }; - - const handleSubmit = async () => { - if (!provider || !formData.apiKey) return; - - setSaving(true); - try { - let isValid = false; - try { - setValidating(true); - setValidationResult(null); - const res = await fetch("/api/providers/validate", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ provider, apiKey: formData.apiKey }), - }); - const data = await res.json(); - isValid = !!data.valid; - setValidationResult(isValid ? "success" : "failed"); - } catch { - setValidationResult("failed"); - } finally { - setValidating(false); - } - - await onSave({ - name: formData.name, - apiKey: formData.apiKey, - priority: formData.priority, - proxyPoolId: formData.proxyPoolId === NONE_PROXY_POOL_VALUE ? null : formData.proxyPoolId, - testStatus: isValid ? "active" : "unknown", - providerSpecificData: undefined - }); - } finally { - setSaving(false); - } - }; - - if (!provider) return null; - - return ( - -
- setFormData({ ...formData, name: e.target.value })} - placeholder="Production Key" - /> -
- setFormData({ ...formData, apiKey: e.target.value })} - className="flex-1" - /> -
- -
-
- {validationResult && ( - - {validationResult === "success" ? "Valid" : "Invalid"} - - )} - {isCompatible && ( -

- {isAnthropic - ? `Validation checks ${providerName || "Anthropic Compatible"} by verifying the API key.` - : `Validation checks ${providerName || "OpenAI Compatible"} via /models on your base URL.` - } -

- )} - setFormData({ ...formData, priority: Number.parseInt(e.target.value) || 1 })} - /> - - setFormData({ ...formData, name: e.target.value })} - placeholder={`${isAnthropic ? "Anthropic" : "OpenAI"} Compatible (Prod)`} - hint="Required. A friendly label for this node." - /> - setFormData({ ...formData, prefix: e.target.value })} - placeholder={isAnthropic ? "ac-prod" : "oc-prod"} - hint="Required. Used as the provider prefix for model IDs." - /> - {!isAnthropic && ( - setFormData({ ...formData, baseUrl: e.target.value })} - placeholder={isAnthropic ? "https://api.anthropic.com/v1" : "https://api.openai.com/v1"} - hint={`Use the base URL (ending in /v1) for your ${isAnthropic ? "Anthropic" : "OpenAI"}-compatible API.`} - /> -
- setCheckKey(e.target.value)} - className="flex-1" - /> -
- -
-
- setCheckModelId(e.target.value)} - placeholder="e.g. my-model-id" - hint="If provider lacks /models endpoint, enter a model ID to validate via chat/completions instead." - /> - {validationResult && ( - - {validationResult === "success" ? "Valid" : "Invalid"} - - )} -
- - -
-
-
- ); -} - -EditCompatibleNodeModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - node: PropTypes.shape({ - id: PropTypes.string, - name: PropTypes.string, - prefix: PropTypes.string, - apiType: PropTypes.string, - baseUrl: PropTypes.string, - }), - onSave: PropTypes.func.isRequired, - onClose: PropTypes.func.isRequired, - isAnthropic: PropTypes.bool, -}; - -function AddCustomModelModal({ isOpen, providerAlias, providerDisplayAlias, onSave, onClose }) { - const [modelId, setModelId] = useState(""); - const [testStatus, setTestStatus] = useState(null); // null | "testing" | "ok" | "error" - const [testError, setTestError] = useState(""); - const [saving, setSaving] = useState(false); - - // Reset state when modal opens - useEffect(() => { - if (isOpen) { setModelId(""); setTestStatus(null); setTestError(""); } - }, [isOpen]); - - const handleTest = async () => { - if (!modelId.trim()) return; - setTestStatus("testing"); - setTestError(""); - try { - const res = await fetch("/api/models/test", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ model: `${providerAlias}/${modelId.trim()}` }), - }); - const data = await res.json(); - setTestStatus(data.ok ? "ok" : "error"); - setTestError(data.error || ""); - } catch (err) { - setTestStatus("error"); - setTestError(err.message); - } - }; - - const handleSave = async () => { - if (!modelId.trim() || saving) return; - setSaving(true); - try { - await onSave(modelId.trim()); - } finally { - setSaving(false); - } - }; - - const handleKeyDown = (e) => { - if (e.key === "Enter") handleTest(); - }; - - return ( - -
-
- -
- { setModelId(e.target.value); setTestStatus(null); setTestError(""); }} - onKeyDown={handleKeyDown} - placeholder="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" - autoFocus - /> - -
-

- Sent to provider as: {modelId.trim() || "model-id"} -

-
- - {/* Test result */} - {testStatus === "ok" && ( -
- check_circle - Model is reachable -
- )} - {testStatus === "error" && ( -
- cancel - {testError || "Model not reachable"} -
- )} - -
- - -
-
-
- ); -} - -AddCustomModelModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - providerAlias: PropTypes.string.isRequired, - providerDisplayAlias: PropTypes.string.isRequired, - onSave: PropTypes.func.isRequired, - onClose: PropTypes.func.isRequired, -}; - diff --git a/src/app/(dashboard)/dashboard/usage/components/RequestDetailsTab.js b/src/app/(dashboard)/dashboard/usage/components/RequestDetailsTab.js index 4f527361..4d21d522 100644 --- a/src/app/(dashboard)/dashboard/usage/components/RequestDetailsTab.js +++ b/src/app/(dashboard)/dashboard/usage/components/RequestDetailsTab.js @@ -82,6 +82,12 @@ function CollapsibleSection({ title, children, defaultOpen = false, icon = null ); } +function getInputTokens(tokens) { + const prompt = tokens?.prompt_tokens || tokens?.input_tokens || 0; + const cache = tokens?.cached_tokens || tokens?.cache_read_input_tokens || 0; + return prompt < cache ? cache : prompt; +} + export default function RequestDetailsTab() { const [details, setDetails] = useState([]); const [pagination, setPagination] = useState({ @@ -276,7 +282,7 @@ export default function RequestDetailsTab() { - {detail.tokens?.prompt_tokens?.toLocaleString() || 0} + {getInputTokens(detail.tokens).toLocaleString()} {detail.tokens?.completion_tokens?.toLocaleString() || 0} @@ -359,7 +365,7 @@ export default function RequestDetailsTab() {
Input Tokens:{" "} - {selectedDetail.tokens?.prompt_tokens?.toLocaleString() || 0} + {getInputTokens(selectedDetail.tokens).toLocaleString()}
diff --git a/src/sse/handlers/chat.js b/src/sse/handlers/chat.js index b2e64a1e..aa6cff7a 100644 --- a/src/sse/handlers/chat.js +++ b/src/sse/handlers/chat.js @@ -13,6 +13,7 @@ import { getModelInfo, getComboModels } from "../services/model.js"; import { handleChatCore } from "open-sse/handlers/chatCore.js"; import { errorResponse, unavailableResponse } from "open-sse/utils/error.js"; import { handleComboChat } from "open-sse/services/combo.js"; +import { handleBypassRequest } from "open-sse/utils/bypassHandler.js"; import { HTTP_STATUS } from "open-sse/config/runtimeConfig.js"; import { detectFormatByEndpoint } from "open-sse/translator/formats.js"; import * as log from "../utils/logger.js"; @@ -83,6 +84,11 @@ export async function handleChat(request, clientRawRequest = null) { return errorResponse(HTTP_STATUS.BAD_REQUEST, "Missing model"); } + // Bypass naming/warmup requests before combo rotation to avoid wasting rotation slots + const userAgent = request?.headers?.get("user-agent") || ""; + const bypassResponse = handleBypassRequest(body, modelStr, userAgent, !!settings.ccFilterNaming); + if (bypassResponse) return bypassResponse.response || bypassResponse; + // Check if model is a combo (has multiple models with fallback) const comboModels = await getComboModels(modelStr); if (comboModels) {