Fix codex cache session id
This commit is contained in:
15
CHANGELOG.md
15
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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -626,10 +626,19 @@ export default function APIPageClient({ machineId }) {
|
||||
</button>
|
||||
</>
|
||||
) : tunnelLoading ? (
|
||||
<div className="flex-1 flex items-center gap-2 px-3 py-1.5 rounded border border-border bg-input text-sm text-text-muted">
|
||||
<span className="material-symbols-outlined animate-spin text-sm">progress_activity</span>
|
||||
{tunnelProgress || "Creating tunnel..."}
|
||||
</div>
|
||||
<>
|
||||
<div className="flex-1 flex items-center gap-2 px-3 py-1.5 rounded border border-border bg-input text-sm text-text-muted">
|
||||
<span className="material-symbols-outlined animate-spin text-sm">progress_activity</span>
|
||||
{tunnelProgress || "Creating tunnel..."}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowDisableTunnelModal(true)}
|
||||
className="p-2 hover:bg-red-500/10 rounded text-red-500 transition-colors shrink-0"
|
||||
title="Disable Tunnel"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">power_settings_new</span>
|
||||
</button>
|
||||
</>
|
||||
) : tunnelStatus?.type === "error" ? (
|
||||
<>
|
||||
<div className="flex-1 flex items-center gap-2 px-3 py-1.5 rounded border border-red-300 dark:border-red-800 bg-red-500/5 text-sm text-red-600 dark:text-red-400">
|
||||
@@ -639,10 +648,19 @@ export default function APIPageClient({ machineId }) {
|
||||
<Button size="sm" icon="cloud_upload" onClick={() => setShowEnableTunnelModal(true)}>Enable</Button>
|
||||
</>
|
||||
) : tunnelChecking ? (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 text-sm text-text-muted">
|
||||
<span className="material-symbols-outlined animate-spin text-sm">progress_activity</span>
|
||||
Checking...
|
||||
</div>
|
||||
<>
|
||||
<div className="flex-1 flex items-center gap-2 px-3 py-1.5 rounded border border-border bg-input text-sm text-text-muted">
|
||||
<span className="material-symbols-outlined animate-spin text-sm">progress_activity</span>
|
||||
Checking...
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowDisableTunnelModal(true)}
|
||||
className="p-2 hover:bg-red-500/10 rounded text-red-500 transition-colors shrink-0"
|
||||
title="Disable Tunnel"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">power_settings_new</span>
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -683,10 +701,19 @@ export default function APIPageClient({ machineId }) {
|
||||
</button>
|
||||
</>
|
||||
) : (tsLoading || tsConnecting) ? (
|
||||
<div className="flex-1 flex items-center gap-2 px-3 py-1.5 rounded border border-border bg-input text-sm text-text-muted">
|
||||
<span className="material-symbols-outlined animate-spin text-sm">progress_activity</span>
|
||||
{tsProgress || "Connecting..."}
|
||||
</div>
|
||||
<>
|
||||
<div className="flex-1 flex items-center gap-2 px-3 py-1.5 rounded border border-border bg-input text-sm text-text-muted">
|
||||
<span className="material-symbols-outlined animate-spin text-sm">progress_activity</span>
|
||||
{tsProgress || "Connecting..."}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowDisableTsModal(true)}
|
||||
className="p-2 hover:bg-red-500/10 rounded text-red-500 transition-colors shrink-0"
|
||||
title="Disable Tailscale"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">power_settings_new</span>
|
||||
</button>
|
||||
</>
|
||||
) : tsStatus?.type === "error" ? (
|
||||
<>
|
||||
<div className="flex-1 flex items-center gap-2 px-3 py-1.5 rounded border border-red-300 dark:border-red-800 bg-red-500/5 text-sm text-red-600 dark:text-red-400">
|
||||
|
||||
164
src/app/(dashboard)/dashboard/providers/[id]/AddApiKeyModal.js
Normal file
164
src/app/(dashboard)/dashboard/providers/[id]/AddApiKeyModal.js
Normal file
@@ -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 (
|
||||
<Modal isOpen={isOpen} title={`Add ${providerName || provider} API Key`} onClose={onClose}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Input
|
||||
label="Name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder="Production Key"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
label="API Key"
|
||||
type="password"
|
||||
value={formData.apiKey}
|
||||
onChange={(e) => setFormData({ ...formData, apiKey: e.target.value })}
|
||||
className="flex-1"
|
||||
/>
|
||||
<div className="pt-6">
|
||||
<Button onClick={handleValidate} disabled={!formData.apiKey || validating || saving} variant="secondary">
|
||||
{validating ? "Checking..." : "Check"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{validationResult && (
|
||||
<Badge variant={validationResult === "success" ? "success" : "error"}>
|
||||
{validationResult === "success" ? "Valid" : "Invalid"}
|
||||
</Badge>
|
||||
)}
|
||||
{isCompatible && (
|
||||
<p className="text-xs text-text-muted">
|
||||
{isAnthropic
|
||||
? `Validation checks ${providerName || "Anthropic Compatible"} by verifying the API key.`
|
||||
: `Validation checks ${providerName || "OpenAI Compatible"} via /models on your base URL.`
|
||||
}
|
||||
</p>
|
||||
)}
|
||||
<Input
|
||||
label="Priority"
|
||||
type="number"
|
||||
value={formData.priority}
|
||||
onChange={(e) => setFormData({ ...formData, priority: Number.parseInt(e.target.value) || 1 })}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="Proxy Pool"
|
||||
value={formData.proxyPoolId}
|
||||
onChange={(e) => setFormData({ ...formData, proxyPoolId: e.target.value })}
|
||||
options={[
|
||||
{ value: NONE_PROXY_POOL_VALUE, label: "None" },
|
||||
...(proxyPools || []).map((pool) => ({ value: pool.id, label: pool.name })),
|
||||
]}
|
||||
placeholder="None"
|
||||
/>
|
||||
|
||||
{(proxyPools || []).length === 0 && (
|
||||
<p className="text-xs text-text-muted">
|
||||
No active proxy pools available. Create one in Proxy Pools page first.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-text-muted">
|
||||
Legacy manual proxy fields are still accepted by API for backward compatibility.
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleSubmit} fullWidth disabled={!formData.name || !formData.apiKey || saving}>
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
<Button onClick={onClose} variant="ghost" fullWidth>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
AddApiKeyModal.propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
provider: PropTypes.string,
|
||||
providerName: PropTypes.string,
|
||||
isCompatible: PropTypes.bool,
|
||||
isAnthropic: PropTypes.bool,
|
||||
proxyPools: PropTypes.arrayOf(PropTypes.shape({
|
||||
id: PropTypes.string,
|
||||
name: PropTypes.string,
|
||||
})),
|
||||
onSave: PropTypes.func.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
@@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { Button, Modal } from "@/shared/components";
|
||||
|
||||
export default 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 (
|
||||
<Modal isOpen={isOpen} onClose={onClose} title="Add Custom Model">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-1.5 block">Model ID</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={modelId}
|
||||
onChange={(e) => { 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
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
icon="science"
|
||||
loading={testStatus === "testing"}
|
||||
onClick={handleTest}
|
||||
disabled={!modelId.trim() || testStatus === "testing"}
|
||||
>
|
||||
{testStatus === "testing" ? "Testing..." : "Test"}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted mt-1">
|
||||
Sent to provider as: <code className="font-mono bg-sidebar px-1 rounded">{modelId.trim() || "model-id"}</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Test result */}
|
||||
{testStatus === "ok" && (
|
||||
<div className="flex items-center gap-2 text-sm text-green-600">
|
||||
<span className="material-symbols-outlined text-base">check_circle</span>
|
||||
Model is reachable
|
||||
</div>
|
||||
)}
|
||||
{testStatus === "error" && (
|
||||
<div className="flex items-start gap-2 text-sm text-red-500">
|
||||
<span className="material-symbols-outlined text-base shrink-0">cancel</span>
|
||||
<span>{testError || "Model not reachable"}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button onClick={onClose} variant="ghost" fullWidth size="sm">Cancel</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
fullWidth
|
||||
size="sm"
|
||||
disabled={!modelId.trim() || saving}
|
||||
>
|
||||
{saving ? "Adding..." : "Add Model"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
AddCustomModelModal.propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
providerAlias: PropTypes.string.isRequired,
|
||||
providerDisplayAlias: PropTypes.string.isRequired,
|
||||
onSave: PropTypes.func.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
};
|
||||
@@ -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 (
|
||||
<div className={`flex items-center gap-3 p-3 rounded-lg border ${borderColor} hover:bg-sidebar/50`}>
|
||||
<span
|
||||
className="material-symbols-outlined text-base text-text-muted"
|
||||
style={iconColor ? { color: iconColor } : undefined}
|
||||
>
|
||||
{testStatus === "ok" ? "check_circle" : testStatus === "error" ? "cancel" : "smart_toy"}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{modelId}</p>
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<code className="text-xs text-text-muted font-mono bg-sidebar px-1.5 py-0.5 rounded">{fullModel}</code>
|
||||
<div className="relative group/btn">
|
||||
<button
|
||||
onClick={() => onCopy(fullModel, `model-${modelId}`)}
|
||||
className="p-0.5 hover:bg-sidebar rounded text-text-muted hover:text-primary"
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm">
|
||||
{copied === `model-${modelId}` ? "check" : "content_copy"}
|
||||
</span>
|
||||
</button>
|
||||
<span className="pointer-events-none absolute top-5 left-1/2 -translate-x-1/2 text-[10px] text-text-muted whitespace-nowrap opacity-0 group-hover/btn:opacity-100 transition-opacity">
|
||||
{copied === `model-${modelId}` ? "Copied!" : "Copy"}
|
||||
</span>
|
||||
</div>
|
||||
{onTest && (
|
||||
<div className="relative group/btn">
|
||||
<button
|
||||
onClick={onTest}
|
||||
disabled={isTesting}
|
||||
className="p-0.5 hover:bg-sidebar rounded text-text-muted hover:text-primary transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm" style={isTesting ? { animation: "spin 1s linear infinite" } : undefined}>
|
||||
{isTesting ? "progress_activity" : "science"}
|
||||
</span>
|
||||
</button>
|
||||
<span className="pointer-events-none absolute top-5 left-1/2 -translate-x-1/2 text-[10px] text-text-muted whitespace-nowrap opacity-0 group-hover/btn:opacity-100 transition-opacity">
|
||||
{isTesting ? "Testing..." : "Test"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onDeleteAlias}
|
||||
className="p-1 hover:bg-red-50 rounded text-red-500"
|
||||
title="Remove model"
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm">delete</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CompatibleModelsSection({ providerStorageAlias, providerDisplayAlias, modelAliases, 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 (
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-text-muted">
|
||||
Add {isAnthropic ? "Anthropic" : "OpenAI"}-compatible models manually or import them from the /models endpoint.
|
||||
</p>
|
||||
|
||||
<div className="flex items-end gap-2 flex-wrap">
|
||||
<div className="flex-1 min-w-[240px]">
|
||||
<label htmlFor="new-compatible-model-input" className="text-xs text-text-muted mb-1 block">Model ID</label>
|
||||
<input
|
||||
id="new-compatible-model-input"
|
||||
type="text"
|
||||
value={newModel}
|
||||
onChange={(e) => setNewModel(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
|
||||
placeholder={isAnthropic ? "claude-3-opus-20240229" : "gpt-4o"}
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
<Button size="sm" icon="add" onClick={handleAdd} disabled={!newModel.trim() || adding}>
|
||||
{adding ? "Adding..." : "Add"}
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" icon="download" onClick={handleImport} disabled={!canImport || importing}>
|
||||
{importing ? "Importing..." : "Import from /models"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!canImport && (
|
||||
<p className="text-xs text-text-muted">
|
||||
Add a connection to enable importing models.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{allModels.length > 0 && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{allModels.map(({ modelId, fullModel, alias }) => (
|
||||
<CompatibleModelRow
|
||||
key={fullModel}
|
||||
modelId={modelId}
|
||||
fullModel={`${providerDisplayAlias}/${modelId}`}
|
||||
copied={copied}
|
||||
onCopy={onCopy}
|
||||
onDeleteAlias={() => onDeleteAlias(alias)}
|
||||
onTest={connections.length > 0 ? () => handleTestModel(modelId) : undefined}
|
||||
testStatus={modelTestResults[modelId]}
|
||||
isTesting={testingModelId === modelId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
261
src/app/(dashboard)/dashboard/providers/[id]/ConnectionRow.js
Normal file
261
src/app/(dashboard)/dashboard/providers/[id]/ConnectionRow.js
Normal file
@@ -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 (
|
||||
<div className={`group flex items-center justify-between p-2 rounded-lg hover:bg-black/[0.02] dark:hover:bg-white/[0.02] transition-colors ${connection.isActive === false ? "opacity-60" : ""}`}>
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
{/* Priority arrows */}
|
||||
<div className="flex flex-col">
|
||||
<button
|
||||
onClick={onMoveUp}
|
||||
disabled={isFirst}
|
||||
className={`p-0.5 rounded ${isFirst ? "text-text-muted/30 cursor-not-allowed" : "hover:bg-sidebar text-text-muted hover:text-primary"}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm">keyboard_arrow_up</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={onMoveDown}
|
||||
disabled={isLast}
|
||||
className={`p-0.5 rounded ${isLast ? "text-text-muted/30 cursor-not-allowed" : "hover:bg-sidebar text-text-muted hover:text-primary"}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm">keyboard_arrow_down</span>
|
||||
</button>
|
||||
</div>
|
||||
<span className="material-symbols-outlined text-base text-text-muted">
|
||||
{isOAuth ? "lock" : "key"}
|
||||
</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{displayName}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<Badge variant={getStatusVariant()} size="sm" dot>
|
||||
{connection.isActive === false ? "disabled" : (effectiveStatus || "Unknown")}
|
||||
</Badge>
|
||||
{hasAnyProxy && (
|
||||
<Badge variant={proxyBadgeVariant} size="sm">
|
||||
Proxy
|
||||
</Badge>
|
||||
)}
|
||||
{isCooldown && connection.isActive !== false && <CooldownTimer until={modelLockUntil} />}
|
||||
{connection.lastError && connection.isActive !== false && (
|
||||
<span className="text-xs text-red-500 truncate max-w-[300px]" title={connection.lastError}>
|
||||
{connection.lastError}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-text-muted">#{connection.priority}</span>
|
||||
{connection.globalPriority && (
|
||||
<span className="text-xs text-text-muted">Auto: {connection.globalPriority}</span>
|
||||
)}
|
||||
</div>
|
||||
{hasAnyProxy && (
|
||||
<div className="mt-1 flex items-center gap-2 flex-wrap">
|
||||
<span className="text-[11px] text-text-muted truncate max-w-[420px]" title={proxyDisplayText}>
|
||||
{proxyDisplayText}
|
||||
</span>
|
||||
{maskedProxyUrl && (
|
||||
<code className="text-[10px] font-mono bg-black/5 dark:bg-white/5 px-1 py-0.5 rounded text-text-muted">
|
||||
{maskedProxyUrl}
|
||||
</code>
|
||||
)}
|
||||
{noProxyText && (
|
||||
<span className="text-[11px] text-text-muted truncate max-w-[320px]" title={noProxyText}>
|
||||
no_proxy: {noProxyText}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex gap-1">
|
||||
{/* Proxy button with inline dropdown */}
|
||||
{(proxyPools || []).length > 0 && (
|
||||
<div className="relative" ref={proxyDropdownRef}>
|
||||
<button
|
||||
onClick={() => setShowProxyDropdown((v) => !v)}
|
||||
className={`flex flex-col items-center px-2 py-1 rounded hover:bg-black/5 dark:hover:bg-white/5 transition-colors ${hasAnyProxy ? "text-primary" : "text-text-muted hover:text-primary"}`}
|
||||
disabled={updatingProxy}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">
|
||||
{updatingProxy ? "progress_activity" : "lan"}
|
||||
</span>
|
||||
<span className="text-[10px] leading-tight">Proxy</span>
|
||||
</button>
|
||||
{showProxyDropdown && (
|
||||
<div className="absolute right-0 top-full mt-1 z-50 bg-bg border border-border rounded-lg shadow-lg py-1 min-w-[160px]">
|
||||
<button
|
||||
onClick={() => handleSelectProxy("__none__")}
|
||||
className={`w-full text-left px-3 py-1.5 text-sm hover:bg-black/5 dark:hover:bg-white/5 ${!boundProxyPoolId ? "text-primary font-medium" : "text-text-main"}`}
|
||||
>
|
||||
None
|
||||
</button>
|
||||
{(proxyPools || []).map((pool) => (
|
||||
<button
|
||||
key={pool.id}
|
||||
onClick={() => handleSelectProxy(pool.id)}
|
||||
className={`w-full text-left px-3 py-1.5 text-sm hover:bg-black/5 dark:hover:bg-white/5 ${boundProxyPoolId === pool.id ? "text-primary font-medium" : "text-text-main"}`}
|
||||
>
|
||||
{pool.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<button onClick={onEdit} className="flex flex-col items-center px-2 py-1 rounded hover:bg-black/5 dark:hover:bg-white/5 text-text-muted hover:text-primary">
|
||||
<span className="material-symbols-outlined text-[18px]">edit</span>
|
||||
<span className="text-[10px] leading-tight">Edit</span>
|
||||
</button>
|
||||
<button onClick={onDelete} className="flex flex-col items-center px-2 py-1 rounded hover:bg-red-500/10 text-red-500">
|
||||
<span className="material-symbols-outlined text-[18px]">delete</span>
|
||||
<span className="text-[10px] leading-tight">Delete</span>
|
||||
</button>
|
||||
</div>
|
||||
<Toggle
|
||||
size="sm"
|
||||
checked={connection.isActive ?? true}
|
||||
onChange={onToggleActive}
|
||||
title={(connection.isActive ?? true) ? "Disable connection" : "Enable connection"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
<span className="text-xs text-orange-500 font-mono">
|
||||
⏱ {remaining}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
CooldownTimer.propTypes = {
|
||||
until: PropTypes.string.isRequired,
|
||||
};
|
||||
@@ -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 (
|
||||
<Modal isOpen={isOpen} title={`Edit ${isAnthropic ? "Anthropic" : "OpenAI"} Compatible`} onClose={onClose}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Input
|
||||
label="Name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
placeholder={`${isAnthropic ? "Anthropic" : "OpenAI"} Compatible (Prod)`}
|
||||
hint="Required. A friendly label for this node."
|
||||
/>
|
||||
<Input
|
||||
label="Prefix"
|
||||
value={formData.prefix}
|
||||
onChange={(e) => setFormData({ ...formData, prefix: e.target.value })}
|
||||
placeholder={isAnthropic ? "ac-prod" : "oc-prod"}
|
||||
hint="Required. Used as the provider prefix for model IDs."
|
||||
/>
|
||||
{!isAnthropic && (
|
||||
<Select
|
||||
label="API Type"
|
||||
options={apiTypeOptions}
|
||||
value={formData.apiType}
|
||||
onChange={(e) => setFormData({ ...formData, apiType: e.target.value })}
|
||||
/>
|
||||
)}
|
||||
<Input
|
||||
label="Base URL"
|
||||
value={formData.baseUrl}
|
||||
onChange={(e) => 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.`}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
label="API Key (for Check)"
|
||||
type="password"
|
||||
value={checkKey}
|
||||
onChange={(e) => setCheckKey(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<div className="pt-6">
|
||||
<Button onClick={handleValidate} disabled={!checkKey || validating || !formData.baseUrl.trim()} variant="secondary">
|
||||
{validating ? "Checking..." : "Check"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Input
|
||||
label="Model ID (optional)"
|
||||
value={checkModelId}
|
||||
onChange={(e) => 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 && (
|
||||
<Badge variant={validationResult === "success" ? "success" : "error"}>
|
||||
{validationResult === "success" ? "Valid" : "Invalid"}
|
||||
</Badge>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleSubmit} fullWidth disabled={!formData.name.trim() || !formData.prefix.trim() || !formData.baseUrl.trim() || saving}>
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
<Button onClick={onClose} variant="ghost" fullWidth>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
86
src/app/(dashboard)/dashboard/providers/[id]/ModelRow.js
Normal file
86
src/app/(dashboard)/dashboard/providers/[id]/ModelRow.js
Normal file
@@ -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 (
|
||||
<div className={`group px-3 py-2 rounded-lg border ${borderColor} hover:bg-sidebar/50`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="material-symbols-outlined text-base"
|
||||
style={iconColor ? { color: iconColor } : undefined}
|
||||
>
|
||||
{testStatus === "ok" ? "check_circle" : testStatus === "error" ? "cancel" : "smart_toy"}
|
||||
</span>
|
||||
<div className="flex flex-col gap-1">
|
||||
<code className="text-xs text-text-muted font-mono bg-sidebar px-1.5 py-0.5 rounded">{fullModel}</code>
|
||||
{model.name && <span className="text-[9px] text-text-muted/70 italic pl-1">{model.name}</span>}
|
||||
</div>
|
||||
{onTest && (
|
||||
<div className="relative group/btn">
|
||||
<button
|
||||
onClick={onTest}
|
||||
disabled={isTesting}
|
||||
className={`p-0.5 hover:bg-sidebar rounded text-text-muted hover:text-primary transition-opacity ${isTesting ? "opacity-100" : "opacity-0 group-hover:opacity-100"}`}
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm" style={isTesting ? { animation: "spin 1s linear infinite" } : undefined}>
|
||||
{isTesting ? "progress_activity" : "science"}
|
||||
</span>
|
||||
</button>
|
||||
<span className="pointer-events-none absolute mt-1 top-5 left-1/2 -translate-x-1/2 text-[10px] text-text-muted whitespace-nowrap opacity-0 group-hover/btn:opacity-100 transition-opacity">
|
||||
{isTesting ? "Testing..." : "Test"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative group/btn">
|
||||
<button
|
||||
onClick={() => onCopy(fullModel, `model-${model.id}`)}
|
||||
className="p-0.5 hover:bg-sidebar rounded text-text-muted hover:text-primary"
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm">
|
||||
{copied === `model-${model.id}` ? "check" : "content_copy"}
|
||||
</span>
|
||||
</button>
|
||||
<span className="pointer-events-none absolute mt-1 top-5 left-1/2 -translate-x-1/2 text-[10px] text-text-muted whitespace-nowrap opacity-0 group-hover/btn:opacity-100 transition-opacity">
|
||||
{copied === `model-${model.id}` ? "Copied!" : "Copy"}
|
||||
</span>
|
||||
</div>
|
||||
{isCustom && (
|
||||
<button
|
||||
onClick={onDeleteAlias}
|
||||
className="p-0.5 hover:bg-red-500/10 rounded text-text-muted hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity ml-auto"
|
||||
title="Remove custom model"
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm">close</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
@@ -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 (
|
||||
<div className={`flex items-center gap-3 p-3 rounded-lg border ${borderColor} hover:bg-sidebar/50`}>
|
||||
<span
|
||||
className="material-symbols-outlined text-base text-text-muted"
|
||||
style={iconColor ? { color: iconColor } : undefined}
|
||||
>
|
||||
{testStatus === "ok" ? "check_circle" : testStatus === "error" ? "cancel" : "smart_toy"}
|
||||
</span>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{modelId}</p>
|
||||
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<code className="text-xs text-text-muted font-mono bg-sidebar px-1.5 py-0.5 rounded">{fullModel}</code>
|
||||
<div className="relative group/btn">
|
||||
<button
|
||||
onClick={() => onCopy(fullModel, `model-${modelId}`)}
|
||||
className="p-0.5 hover:bg-sidebar rounded text-text-muted hover:text-primary"
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm">
|
||||
{copied === `model-${modelId}` ? "check" : "content_copy"}
|
||||
</span>
|
||||
</button>
|
||||
<span className="pointer-events-none absolute top-5 left-1/2 -translate-x-1/2 text-[10px] text-text-muted whitespace-nowrap opacity-0 group-hover/btn:opacity-100 transition-opacity">
|
||||
{copied === `model-${modelId}` ? "Copied!" : "Copy"}
|
||||
</span>
|
||||
</div>
|
||||
{onTest && (
|
||||
<div className="relative group/btn">
|
||||
<button
|
||||
onClick={onTest}
|
||||
disabled={isTesting}
|
||||
className="p-0.5 hover:bg-sidebar rounded text-text-muted hover:text-primary transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm" style={isTesting ? { animation: "spin 1s linear infinite" } : undefined}>
|
||||
{isTesting ? "progress_activity" : "science"}
|
||||
</span>
|
||||
</button>
|
||||
<span className="pointer-events-none absolute top-5 left-1/2 -translate-x-1/2 text-[10px] text-text-muted whitespace-nowrap opacity-0 group-hover/btn:opacity-100 transition-opacity">
|
||||
{isTesting ? "Testing..." : "Test"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete button */}
|
||||
<button
|
||||
onClick={onDeleteAlias}
|
||||
className="p-1 hover:bg-red-50 rounded text-red-500"
|
||||
title="Remove model"
|
||||
>
|
||||
<span className="material-symbols-outlined text-sm">delete</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
PassthroughModelRow.propTypes = {
|
||||
modelId: PropTypes.string.isRequired,
|
||||
fullModel: PropTypes.string.isRequired,
|
||||
copied: PropTypes.string,
|
||||
onCopy: PropTypes.func.isRequired,
|
||||
onDeleteAlias: PropTypes.func.isRequired,
|
||||
onTest: PropTypes.func,
|
||||
testStatus: PropTypes.oneOf(["ok", "error"]),
|
||||
isTesting: PropTypes.bool,
|
||||
};
|
||||
|
||||
export default function PassthroughModelsSection({ providerAlias, modelAliases, 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 (
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-text-muted">
|
||||
OpenRouter supports any model. Add models and create aliases for quick access.
|
||||
</p>
|
||||
|
||||
{/* Add new model */}
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1">
|
||||
<label htmlFor="new-model-input" className="text-xs text-text-muted mb-1 block">Model ID (from OpenRouter)</label>
|
||||
<input
|
||||
id="new-model-input"
|
||||
type="text"
|
||||
value={newModel}
|
||||
onChange={(e) => setNewModel(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleAdd()}
|
||||
placeholder="anthropic/claude-3-opus"
|
||||
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background focus:outline-none focus:border-primary"
|
||||
/>
|
||||
</div>
|
||||
<Button size="sm" icon="add" onClick={handleAdd} disabled={!newModel.trim() || adding}>
|
||||
{adding ? "Adding..." : "Add"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Models list */}
|
||||
{allModels.length > 0 && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{allModels.map(({ modelId, fullModel, alias }) => (
|
||||
<PassthroughModelRow
|
||||
key={fullModel}
|
||||
modelId={modelId}
|
||||
fullModel={fullModel}
|
||||
copied={copied}
|
||||
onCopy={onCopy}
|
||||
onDeleteAlias={() => onDeleteAlias(alias)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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() {
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-4 text-sm text-text-main text-right font-mono">
|
||||
{detail.tokens?.prompt_tokens?.toLocaleString() || 0}
|
||||
{getInputTokens(detail.tokens).toLocaleString()}
|
||||
</td>
|
||||
<td className="p-4 text-sm text-text-main text-right font-mono">
|
||||
{detail.tokens?.completion_tokens?.toLocaleString() || 0}
|
||||
@@ -359,7 +365,7 @@ export default function RequestDetailsTab() {
|
||||
<div>
|
||||
<span className="text-text-muted">Input Tokens:</span>{" "}
|
||||
<span className="text-text-main font-mono">
|
||||
{selectedDetail.tokens?.prompt_tokens?.toLocaleString() || 0}
|
||||
{getInputTokens(selectedDetail.tokens).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user