feat: xAI image generate/edit, API key import, and per-provider timeouts
- Add dedicated xAI image adapter with generate + edit (multi-image) via /v1/images/generations and /v1/images/edits, plus aspect_ratio/resolution UI - Support importing existing API keys and exposing connection api-key routes - Add global/per-provider connect timeout overrides from settings - Keep unrelated provider UX improvements on this branch; no Grok quota tracking
This commit is contained in:
@@ -21,6 +21,11 @@ export default function APIPageClient({ machineId }) {
|
||||
const [keys, setKeys] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [showImportModal, setShowImportModal] = useState(false);
|
||||
const [importKeyValue, setImportKeyValue] = useState("");
|
||||
const [importKeyName, setImportKeyName] = useState("");
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [importError, setImportError] = useState(null);
|
||||
const [newKeyName, setNewKeyName] = useState("");
|
||||
const [createdKey, setCreatedKey] = useState(null);
|
||||
const [confirmState, setConfirmState] = useState(null);
|
||||
@@ -955,9 +960,14 @@ export default function APIPageClient({ machineId }) {
|
||||
<span className="material-symbols-outlined text-primary">vpn_key</span>
|
||||
API Keys
|
||||
</h2>
|
||||
<Button icon="add" onClick={() => setShowAddModal(true)}>
|
||||
Create Key
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button icon="add" onClick={() => setShowAddModal(true)}>
|
||||
Create Key
|
||||
</Button>
|
||||
<Button icon="file_open" variant="secondary" onClick={() => setShowImportModal(true)}>
|
||||
Import Key
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pb-4 mb-4 border-b border-border">
|
||||
@@ -1095,6 +1105,100 @@ export default function APIPageClient({ machineId }) {
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Import Key Modal */}
|
||||
<Modal
|
||||
isOpen={showImportModal}
|
||||
title="Import Existing API Key"
|
||||
onClose={() => {
|
||||
setShowImportModal(false);
|
||||
setImportKeyValue("");
|
||||
setImportKeyName("");
|
||||
setImportError(null);
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="bg-surface-2 border border-border-subtle rounded-lg p-3">
|
||||
<p className="text-sm text-text-muted">
|
||||
Paste an existing API key to add it to this instance.
|
||||
Useful for transferring keys from another 9Router instance or adding externally generated keys.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{importError && (
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg border border-red-300 dark:border-red-800 bg-red-500/10 text-sm text-red-600 dark:text-red-400">
|
||||
<span className="material-symbols-outlined text-[16px]">error</span>
|
||||
{importError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Input
|
||||
label="API Key"
|
||||
value={importKeyValue}
|
||||
onChange={(e) => {
|
||||
setImportKeyValue(e.target.value);
|
||||
setImportError(null);
|
||||
}}
|
||||
placeholder="Paste your API key here"
|
||||
className="font-mono"
|
||||
/>
|
||||
<Input
|
||||
label="Key Name (optional)"
|
||||
value={importKeyName}
|
||||
onChange={(e) => setImportKeyName(e.target.value)}
|
||||
placeholder="Imported Key"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={async () => {
|
||||
if (!importKeyValue.trim()) return;
|
||||
setImporting(true);
|
||||
setImportError(null);
|
||||
try {
|
||||
const res = await fetch("/api/keys/import", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
key: importKeyValue.trim(),
|
||||
name: importKeyName.trim() || null,
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
await fetchData();
|
||||
setShowImportModal(false);
|
||||
setImportKeyValue("");
|
||||
setImportKeyName("");
|
||||
} else {
|
||||
setImportError(data.error || "Failed to import key");
|
||||
}
|
||||
} catch (error) {
|
||||
setImportError("Network error. Please try again.");
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
}}
|
||||
fullWidth
|
||||
disabled={!importKeyValue.trim() || importing}
|
||||
>
|
||||
{importing ? "Importing..." : "Import"}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setShowImportModal(false);
|
||||
setImportKeyValue("");
|
||||
setImportKeyName("");
|
||||
setImportError(null);
|
||||
}}
|
||||
variant="ghost"
|
||||
fullWidth
|
||||
disabled={importing}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Created Key Modal */}
|
||||
<Modal
|
||||
isOpen={!!createdKey}
|
||||
|
||||
@@ -43,6 +43,8 @@ export const KIND_EXAMPLE_CONFIG = {
|
||||
extraFields: [
|
||||
{ key: "n", label: "n", type: "number", default: 1, min: 1, max: 4 },
|
||||
{ key: "size", label: "Size", type: "select", default: "auto", options: ["auto", "1024x1024", "1024x1536", "1536x1024", "1024x1792", "1792x1024"] },
|
||||
{ key: "aspect_ratio", label: "Aspect", type: "select", default: "", options: ["", "auto", "1:1", "16:9", "9:16", "4:3", "3:2", "2:3", "9:19.5", "20:9"] },
|
||||
{ key: "resolution", label: "Resolution", type: "select", default: "", options: ["", "1k", "2k"] },
|
||||
{ key: "quality", label: "Quality", type: "select", default: "auto", options: ["auto", "low", "medium", "high", "standard", "hd"] },
|
||||
{ key: "background", label: "Background", type: "select", default: "auto", options: ["auto", "transparent", "opaque"] },
|
||||
{ key: "style", label: "Style", type: "select", default: "", options: ["", "vivid", "natural"] },
|
||||
|
||||
@@ -257,6 +257,25 @@ export default function ProfilePage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleGlobalTimeoutChange = async (e) => {
|
||||
const raw = e.target.value.replace(/[^0-9]/g, "");
|
||||
const numTimeout = parseInt(raw, 10);
|
||||
const patchValue = (raw !== "" && Number.isFinite(numTimeout) && numTimeout > 0) ? numTimeout : null;
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/settings", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ defaultTimeoutMs: patchValue }),
|
||||
});
|
||||
if (res.ok) {
|
||||
setSettings(prev => ({ ...prev, defaultTimeoutMs: patchValue }));
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to update default timeout:", err);
|
||||
}
|
||||
};
|
||||
|
||||
const updateStickyLimit = async (limit) => {
|
||||
const numLimit = parseInt(limit);
|
||||
if (isNaN(numLimit) || numLimit < 1) return;
|
||||
@@ -1006,6 +1025,43 @@ export default function ProfilePage() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Default Timeout — global default for all providers */}
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-2 rounded-lg bg-amber-500/10 text-amber-500 shrink-0">
|
||||
<span className="material-symbols-outlined text-[20px]">timer</span>
|
||||
</div>
|
||||
<h3 className="text-base sm:text-lg font-semibold">Default Connect Timeout</h3>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-start sm:items-center justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-sm sm:text-base">All Providers</p>
|
||||
<p className="text-xs sm:text-sm text-text-muted">
|
||||
Timeout for upstream connect (applies globally unless overridden per provider). Set to 0 or leave empty for system default (60s).
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
placeholder="60000"
|
||||
value={settings.defaultTimeoutMs != null ? String(settings.defaultTimeoutMs) : ""}
|
||||
onChange={handleGlobalTimeoutChange}
|
||||
disabled={loading}
|
||||
className="w-20 text-center"
|
||||
/>
|
||||
<span className="text-xs text-text-muted shrink-0">ms</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted italic">
|
||||
{settings.defaultTimeoutMs
|
||||
? `All providers will wait up to ${settings.defaultTimeoutMs}ms for a connection.`
|
||||
: "Using system default (60s) — configure per-provider timeout on each provider's detail page for fine-grained control."}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Network */}
|
||||
<Card>
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import PropTypes from "prop-types";
|
||||
import { Button } from "@/shared/components";
|
||||
import { getProviderCustomModelRows } from "@/shared/utils/providerCustomModels";
|
||||
@@ -71,12 +71,22 @@ function CompatibleModelRow({ modelId, fullModel, copied, onCopy, onDeleteAlias,
|
||||
);
|
||||
}
|
||||
|
||||
export default function CompatibleModelsSection({ providerStorageAlias, providerDisplayAlias, modelAliases, customModels, copied, onCopy, onDeleteAlias, onAddCustomModel, onDeleteCustomModel, connections, isAnthropic }) {
|
||||
const TEST_ALL_DELAY_MS = 500;
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
export default function CompatibleModelsSection({ providerStorageAlias, providerDisplayAlias, modelAliases, customModels, copied, onCopy, onDeleteAlias, onAddCustomModel, onDeleteCustomModel, connections, isAnthropic, onFetchModels, fetchingModels }) {
|
||||
const [newModel, setNewModel] = useState("");
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [testingModelId, setTestingModelId] = useState(null);
|
||||
const [modelTestResults, setModelTestResults] = useState({});
|
||||
const [testAllRunning, setTestAllRunning] = useState(false);
|
||||
const [testAllResults, setTestAllResults] = useState(null);
|
||||
const [failedIds, setFailedIds] = useState([]);
|
||||
const [cleaning, setCleaning] = useState(false);
|
||||
const stopRef = useRef(false);
|
||||
|
||||
const handleTestModel = async (modelId) => {
|
||||
if (testingModelId) return;
|
||||
@@ -122,50 +132,56 @@ export default function CompatibleModelsSection({ providerStorageAlias, provider
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = async () => {
|
||||
if (importing) return;
|
||||
const activeConnection = connections.find((conn) => conn.isActive !== false);
|
||||
if (!activeConnection) return;
|
||||
const canFetch = connections.some((conn) => conn.isActive !== false);
|
||||
|
||||
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 handleTestAllClick = async () => {
|
||||
if (testAllRunning || allModels.length === 0) return;
|
||||
stopRef.current = false;
|
||||
setTestAllRunning(true);
|
||||
setTestAllResults(null);
|
||||
setFailedIds([]);
|
||||
setModelTestResults({});
|
||||
|
||||
const currentResults = { passed: 0, failed: 0, failedIds: [] };
|
||||
for (const model of allModels) {
|
||||
if (stopRef.current) break;
|
||||
|
||||
setTestingModelId(model.id);
|
||||
await sleep(100); // let React flush the spinning state
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/models/test", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model: `${providerStorageAlias}/${model.id}` }),
|
||||
});
|
||||
const data = await res.json();
|
||||
const ok = data.ok;
|
||||
setModelTestResults((prev) => ({ ...prev, [model.id]: ok ? "ok" : "error" }));
|
||||
if (ok) currentResults.passed++;
|
||||
else { currentResults.failed++; currentResults.failedIds.push(model.id); }
|
||||
} catch {
|
||||
setModelTestResults((prev) => ({ ...prev, [model.id]: "error" }));
|
||||
currentResults.failed++;
|
||||
currentResults.failedIds.push(model.id);
|
||||
}
|
||||
const models = data.models || [];
|
||||
if (models.length === 0) {
|
||||
alert("No models returned from /models.");
|
||||
return;
|
||||
|
||||
setTestingModelId(null);
|
||||
|
||||
// Update live summary
|
||||
setTestAllResults({ passed: currentResults.passed, failed: currentResults.failed });
|
||||
setFailedIds([...currentResults.failedIds]);
|
||||
|
||||
if (!stopRef.current && model !== allModels[allModels.length - 1]) {
|
||||
await sleep(TEST_ALL_DELAY_MS);
|
||||
}
|
||||
let importedCount = 0;
|
||||
for (const model of models) {
|
||||
const modelId = model.id || model.name || model.model;
|
||||
if (!modelId) continue;
|
||||
if (allModels.some((entry) => entry.id === modelId)) continue;
|
||||
await onAddCustomModel(modelId);
|
||||
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);
|
||||
setTestAllRunning(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>
|
||||
@@ -182,14 +198,71 @@ export default function CompatibleModelsSection({ providerStorageAlias, provider
|
||||
<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 size="sm" variant="secondary" icon="download" onClick={onFetchModels} disabled={!canFetch || fetchingModels}>
|
||||
{fetchingModels ? "Fetching..." : "Fetch Models"}
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" icon="science" onClick={handleTestAllClick} disabled={allModels.length === 0 || testAllRunning}>
|
||||
{testAllRunning ? "Testing..." : "Test All"}
|
||||
</Button>
|
||||
{testAllRunning && (
|
||||
<Button size="sm" variant="ghost" icon="stop" onClick={() => { stopRef.current = true; }}>
|
||||
Stop
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!canImport && (
|
||||
{(testAllResults || testAllRunning) && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm border ${
|
||||
testAllRunning
|
||||
? "bg-blue-500/10 text-blue-600 dark:text-blue-400 border-blue-500/30"
|
||||
: testAllResults?.failed === 0
|
||||
? "bg-green-500/10 text-green-600 dark:text-green-400 border-green-500/30"
|
||||
: "bg-yellow-500/10 text-yellow-600 dark:text-yellow-400 border-yellow-500/30"
|
||||
}`}>
|
||||
<span className={`material-symbols-outlined text-[16px] ${testAllRunning ? "animate-spin" : ""}`}>
|
||||
{testAllRunning ? "progress_activity" : testAllResults?.failed === 0 ? "check_circle" : "warning"}
|
||||
</span>
|
||||
<span>
|
||||
{testAllRunning
|
||||
? `Testing... ${(testAllResults?.passed || 0) + (testAllResults?.failed || 0)}/${allModels.length}`
|
||||
: `${testAllResults?.passed || 0} passed, ${testAllResults?.failed || 0} failed`
|
||||
}
|
||||
</span>
|
||||
{testingModelId && testAllRunning && (
|
||||
<span className="text-xs text-text-muted ml-1">
|
||||
(current: {testingModelId})
|
||||
</span>
|
||||
)}
|
||||
{!testAllRunning && failedIds.length > 0 && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
if (cleaning) return;
|
||||
setCleaning(true);
|
||||
for (const id of failedIds) {
|
||||
await onDeleteCustomModel(id);
|
||||
}
|
||||
setCleaning(false);
|
||||
setTestAllResults(null);
|
||||
setFailedIds([]);
|
||||
setModelTestResults({});
|
||||
}}
|
||||
disabled={cleaning}
|
||||
className="ml-auto flex items-center gap-1 px-2 py-1 rounded-md text-xs font-medium bg-red-500/20 text-red-600 dark:text-red-400 hover:bg-red-500/30 transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">
|
||||
{cleaning ? "progress_activity" : "delete_sweep"}
|
||||
</span>
|
||||
{cleaning ? "Cleaning..." : `Clean ${failedIds.length} failed`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!canFetch && (
|
||||
<p className="text-xs text-text-muted">
|
||||
Add a connection to enable importing models.
|
||||
Add a connection to enable fetching models.
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -229,4 +302,6 @@ CompatibleModelsSection.propTypes = {
|
||||
isActive: PropTypes.bool,
|
||||
})).isRequired,
|
||||
isAnthropic: PropTypes.bool,
|
||||
onFetchModels: PropTypes.func,
|
||||
fetchingModels: PropTypes.bool,
|
||||
};
|
||||
|
||||
@@ -3,12 +3,18 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { getStatusVariant as getConnectionStatusVariant } from "@/shared/utils/connectionStatus";
|
||||
import PropTypes from "prop-types";
|
||||
import { Badge, Toggle, Tooltip } from "@/shared/components";
|
||||
import { Badge, Toggle, Tooltip, Modal, Button } from "@/shared/components";
|
||||
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
|
||||
import CooldownTimer from "./CooldownTimer";
|
||||
|
||||
export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst, isLast, onMoveUp, onMoveDown, onToggleActive, onUpdateProxy, onEdit, onDelete, oneByOneStatus = null, autoPing = null }) {
|
||||
const [showProxyDropdown, setShowProxyDropdown] = useState(false);
|
||||
const [updatingProxy, setUpdatingProxy] = useState(false);
|
||||
const [showKeyModal, setShowKeyModal] = useState(false);
|
||||
const [revealedKey, setRevealedKey] = useState("");
|
||||
const [loadingKey, setLoadingKey] = useState(false);
|
||||
const [keyError, setKeyError] = useState(null);
|
||||
const { copied, copy } = useCopyToClipboard();
|
||||
const proxyDropdownRef = useRef(null);
|
||||
|
||||
const proxyPoolMap = new Map((proxyPools || []).map((pool) => [pool.id, pool]));
|
||||
@@ -257,6 +263,36 @@ export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{connection.authType === "apikey" && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
setLoadingKey(true);
|
||||
setKeyError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/providers/${connection.id}/api-key`);
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setRevealedKey(data.apiKey || "");
|
||||
setShowKeyModal(true);
|
||||
} else {
|
||||
setKeyError(data.error || "Failed to fetch key");
|
||||
setShowKeyModal(true);
|
||||
}
|
||||
} catch {
|
||||
setKeyError("Network error");
|
||||
setShowKeyModal(true);
|
||||
} finally {
|
||||
setLoadingKey(false);
|
||||
}
|
||||
}}
|
||||
className="flex flex-col items-center rounded px-2 py-1 text-text-muted hover:bg-black/5 hover:text-primary dark:hover:bg-white/5"
|
||||
>
|
||||
<span className={`material-symbols-outlined text-[18px] ${loadingKey ? "animate-spin" : ""}`}>
|
||||
{loadingKey ? "progress_activity" : "key"}
|
||||
</span>
|
||||
<span className="text-[10px] leading-tight">Key</span>
|
||||
</button>
|
||||
)}
|
||||
<button onClick={onEdit} className="flex flex-col items-center rounded px-2 py-1 text-text-muted hover:bg-black/5 hover:text-primary dark:hover:bg-white/5">
|
||||
<span className="material-symbols-outlined text-[18px]">edit</span>
|
||||
<span className="text-[10px] leading-tight">Edit</span>
|
||||
@@ -273,6 +309,52 @@ export default function ConnectionRow({ connection, proxyPools, isOAuth, isFirst
|
||||
title={(connection.isActive ?? true) ? "Disable connection" : "Enable connection"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Show Key Modal */}
|
||||
<Modal
|
||||
isOpen={showKeyModal}
|
||||
title="API Key"
|
||||
onClose={() => {
|
||||
setShowKeyModal(false);
|
||||
setRevealedKey("");
|
||||
setKeyError(null);
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{keyError ? (
|
||||
<div className="flex items-center gap-2 px-3 py-2 rounded-lg border border-red-300 dark:border-red-800 bg-red-500/10 text-sm text-red-600 dark:text-red-400">
|
||||
<span className="material-symbols-outlined text-[16px]">error</span>
|
||||
{keyError}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-xs text-yellow-600 dark:text-yellow-400 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-3">
|
||||
<span className="material-symbols-outlined text-[14px] align-middle mr-1">warning</span>
|
||||
This key provides full access to your endpoint. Keep it secure.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={revealedKey}
|
||||
className="flex-1 px-3 py-2 text-sm font-mono bg-surface-2 rounded-lg border border-border focus:outline-none"
|
||||
onClick={(e) => e.target.select()}
|
||||
/>
|
||||
<button
|
||||
onClick={() => copy(revealedKey, connection.id)}
|
||||
className="flex items-center gap-1 px-3 py-2 rounded-lg border border-border text-sm text-text-muted hover:text-primary hover:border-primary/40 transition-colors"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">{copied === connection.id ? "check" : "content_copy"}</span>
|
||||
{copied === connection.id ? "Copied!" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<Button onClick={() => { setShowKeyModal(false); setRevealedKey(""); setKeyError(null); }} fullWidth>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ export default function ProviderDetailPage() {
|
||||
const [providerStrategy, setProviderStrategy] = useState(null);
|
||||
const [providerStickyLimit, setProviderStickyLimit] = useState("");
|
||||
const [thinkingMode, setThinkingMode] = useState("auto");
|
||||
const [providerTimeout, setProviderTimeout] = useState("");
|
||||
const [autoPing, setAutoPing] = useState({ enabled: false, connections: {} });
|
||||
const [suggestedModels, setSuggestedModels] = useState([]);
|
||||
const [kiloFreeModels, setKiloFreeModels] = useState([]);
|
||||
@@ -76,6 +77,7 @@ export default function ProviderDetailPage() {
|
||||
const [oneByOneSummary, setOneByOneSummary] = useState(null);
|
||||
const stopOneByOneRef = useRef(false);
|
||||
const [importingQoderModels, setImportingQoderModels] = useState(false);
|
||||
const [fetchingCompatibleModels, setFetchingCompatibleModels] = useState(false);
|
||||
const { copied, copy } = useCopyToClipboard();
|
||||
|
||||
const AG_RISK_STORAGE_KEY = "ag_risk_confirmed";
|
||||
@@ -278,6 +280,9 @@ export default function ProviderDetailPage() {
|
||||
// Load per-provider thinking config
|
||||
const thinkingCfg = (settingsData.providerThinking || {})[providerId] || {};
|
||||
setThinkingMode(thinkingCfg.mode || "auto");
|
||||
// Load per-provider connect timeout
|
||||
const timeoutCfg = (settingsData.providerTimeouts || {})[providerId] || {};
|
||||
setProviderTimeout(timeoutCfg.timeoutMs != null ? String(timeoutCfg.timeoutMs) : "");
|
||||
const autoPingSettingsKey = AUTO_PING_SETTINGS_KEYS[providerId];
|
||||
const apCfg = autoPingSettingsKey ? settingsData[autoPingSettingsKey] || {} : {};
|
||||
setAutoPing({ enabled: apCfg.enabled === true, connections: apCfg.connections || {} });
|
||||
@@ -393,6 +398,38 @@ export default function ProviderDetailPage() {
|
||||
saveThinkingConfig(mode);
|
||||
};
|
||||
|
||||
const saveProviderTimeout = async (ms) => {
|
||||
try {
|
||||
const settingsRes = await fetch("/api/settings", { cache: "no-store" });
|
||||
const settingsData = settingsRes.ok ? await settingsRes.json() : {};
|
||||
const current = settingsData.providerTimeouts || {};
|
||||
const updated = { ...current };
|
||||
if (!ms || ms === "") {
|
||||
delete updated[providerId];
|
||||
} else {
|
||||
const timeoutMs = parseInt(ms, 10);
|
||||
if (Number.isFinite(timeoutMs) && timeoutMs > 0) {
|
||||
updated[providerId] = { timeoutMs };
|
||||
} else {
|
||||
delete updated[providerId];
|
||||
}
|
||||
}
|
||||
await fetch("/api/settings", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ providerTimeouts: updated }),
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error saving provider timeout:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTimeoutChange = (value) => {
|
||||
const cleaned = value.replace(/[^0-9]/g, "");
|
||||
setProviderTimeout(cleaned);
|
||||
saveProviderTimeout(cleaned);
|
||||
};
|
||||
|
||||
const saveAutoPing = async (next) => {
|
||||
const autoPingSettingsKey = AUTO_PING_SETTINGS_KEYS[providerId];
|
||||
if (!autoPingSettingsKey) return;
|
||||
@@ -1018,6 +1055,49 @@ export default function ProviderDetailPage() {
|
||||
onDeleteAlias={handleDeleteAlias}
|
||||
onAddCustomModel={(modelId) => handleAddCustomModel(modelId, "llm", providerStorageAlias)}
|
||||
onDeleteCustomModel={(modelId) => handleDeleteCustomModel(modelId, "llm", providerStorageAlias)}
|
||||
onFetchModels={async () => {
|
||||
if (fetchingCompatibleModels || connections.length === 0) return;
|
||||
setFetchingCompatibleModels(true);
|
||||
const activeConnection = connections.find((conn) => conn.isActive !== false) || connections[0];
|
||||
if (!activeConnection) {
|
||||
setFetchingCompatibleModels(false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`/api/providers/${activeConnection.id}/models`);
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
alert(data.error || "Failed to fetch models");
|
||||
return;
|
||||
}
|
||||
const models = data.models || [];
|
||||
if (models.length === 0) {
|
||||
alert("No models returned from /models endpoint.");
|
||||
return;
|
||||
}
|
||||
let importedCount = 0;
|
||||
for (const model of models) {
|
||||
const modelId = model.id || model.name || model.model;
|
||||
if (!modelId) continue;
|
||||
const cleanId = modelId.replace(/^qoder\//, "");
|
||||
const alreadyExists = customModels.some(
|
||||
(entry) => entry.providerAlias === providerStorageAlias && entry.id === cleanId
|
||||
) || Object.values(modelAliases).includes(`${providerStorageAlias}/${cleanId}`);
|
||||
if (alreadyExists) continue;
|
||||
await handleAddCustomModel(cleanId, "llm", providerStorageAlias);
|
||||
importedCount += 1;
|
||||
}
|
||||
if (importedCount === 0) {
|
||||
alert("All models already exist, no new models added.");
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("Error fetching models:", error);
|
||||
alert("Error fetching models: " + error.message);
|
||||
} finally {
|
||||
setFetchingCompatibleModels(false);
|
||||
}
|
||||
}}
|
||||
fetchingModels={fetchingCompatibleModels}
|
||||
connections={connections}
|
||||
isAnthropic={isAnthropicCompatible}
|
||||
/>
|
||||
@@ -1410,6 +1490,21 @@ export default function ProviderDetailPage() {
|
||||
</select>
|
||||
</div>
|
||||
)} */}
|
||||
{/* Connect Timeout */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs text-text-muted font-medium">Connect Timeout</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={providerTimeout}
|
||||
onChange={(e) => handleTimeoutChange(e.target.value)}
|
||||
placeholder="default"
|
||||
className="w-20 px-2 py-1 text-xs border border-border rounded-md bg-background focus:outline-none focus:border-primary"
|
||||
/>
|
||||
<span className="text-xs text-text-muted">ms</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Round Robin toggle */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs text-text-muted font-medium">Round Robin</span>
|
||||
|
||||
31
src/app/api/keys/import/route.js
Normal file
31
src/app/api/keys/import/route.js
Normal file
@@ -0,0 +1,31 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { importApiKey, getApiKeys } from "@/lib/localDb";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// POST /api/keys/import - Import existing API key
|
||||
export async function POST(request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { name, key } = body;
|
||||
|
||||
if (!key?.trim()) {
|
||||
return NextResponse.json({ error: "API key value is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const apiKey = await importApiKey(name, key.trim());
|
||||
|
||||
return NextResponse.json({
|
||||
key: apiKey.key,
|
||||
name: apiKey.name,
|
||||
id: apiKey.id,
|
||||
}, { status: 201 });
|
||||
} catch (error) {
|
||||
const message = error.message;
|
||||
if (message?.includes("already exists")) {
|
||||
return NextResponse.json({ error: message }, { status: 409 });
|
||||
}
|
||||
console.log("Error importing key:", error);
|
||||
return NextResponse.json({ error: message || "Failed to import key" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
26
src/app/api/providers/[id]/api-key/route.js
Normal file
26
src/app/api/providers/[id]/api-key/route.js
Normal file
@@ -0,0 +1,26 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getProviderConnectionById } from "@/models";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
// GET /api/providers/[id]/api-key - Get API key for a connection
|
||||
// Only returns key for apikey authType connections
|
||||
export async function GET(request, { params }) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const connection = await getProviderConnectionById(id);
|
||||
|
||||
if (!connection) {
|
||||
return NextResponse.json({ error: "Connection not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (connection.authType !== "apikey" && connection.authType !== "api_key") {
|
||||
return NextResponse.json({ error: "This connection does not use API key authentication" }, { status: 400 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ apiKey: connection.apiKey || "" });
|
||||
} catch (error) {
|
||||
console.log("Error fetching API key:", error);
|
||||
return NextResponse.json({ error: "Failed to fetch API key" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -140,6 +140,12 @@ export async function PUT(request, { params }) {
|
||||
...(providerSpecificData || {}),
|
||||
};
|
||||
|
||||
// null sentinel = explicit delete for sensitive/optional PSD keys
|
||||
for (const key of Object.keys(updateData.providerSpecificData)) {
|
||||
if (updateData.providerSpecificData[key] === null) {
|
||||
delete updateData.providerSpecificData[key];
|
||||
}
|
||||
}
|
||||
if (proxyConfig.hasAnyProxyField) {
|
||||
updateData.providerSpecificData.connectionProxyEnabled = proxyConfig.connectionProxyEnabled;
|
||||
updateData.providerSpecificData.connectionProxyUrl = proxyConfig.connectionProxyUrl;
|
||||
@@ -163,6 +169,10 @@ export async function PUT(request, { params }) {
|
||||
delete result.accessToken;
|
||||
delete result.refreshToken;
|
||||
delete result.idToken;
|
||||
if (result.providerSpecificData) {
|
||||
const psd = { ...result.providerSpecificData };
|
||||
result.providerSpecificData = psd;
|
||||
}
|
||||
|
||||
return NextResponse.json({ connection: result });
|
||||
} catch (error) {
|
||||
|
||||
@@ -44,8 +44,12 @@ function sanitize(c) {
|
||||
}
|
||||
|
||||
function isUsageEligible(connection) {
|
||||
return USAGE_SUPPORTED_PROVIDERS.includes(connection.provider) && (
|
||||
connection.authType === "oauth" || USAGE_APIKEY_PROVIDERS.includes(connection.provider)
|
||||
if (!USAGE_SUPPORTED_PROVIDERS.includes(connection.provider)) return false;
|
||||
// OAuth + apikey/cookie providers that expose a usage API (cookie used by grok-web).
|
||||
return (
|
||||
connection.authType === "oauth" ||
|
||||
connection.authType === "cookie" ||
|
||||
USAGE_APIKEY_PROVIDERS.includes(connection.provider)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@ export async function GET() {
|
||||
const name = isCompatible
|
||||
? (c.name || nodeNameMap[c.provider] || c.providerSpecificData?.nodeName || c.provider)
|
||||
: c.name;
|
||||
const psd = c.providerSpecificData ? { ...c.providerSpecificData } : undefined;
|
||||
return {
|
||||
...c,
|
||||
name,
|
||||
@@ -73,6 +74,7 @@ export async function GET() {
|
||||
accessToken: undefined,
|
||||
refreshToken: undefined,
|
||||
idToken: undefined,
|
||||
providerSpecificData: psd,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -131,14 +131,15 @@ export async function GET(request, { params }) {
|
||||
return Response.json({ error: "Connection not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Allow OAuth connections, plus whitelisted apikey providers (glm/minimax/kiro/...)
|
||||
// Allow OAuth connections, plus whitelisted apikey/cookie providers (glm/minimax/kiro/grok-web/...)
|
||||
// Kiro's headless api-key flow persists authType "api_key" (underscore) while
|
||||
// generic apikey providers persist "apikey" — accept both spellings here.
|
||||
// generic apikey providers persist "apikey". Web cookie providers (grok-web) use "cookie".
|
||||
const isOAuth = connection.authType === "oauth";
|
||||
const isApikeyAuth =
|
||||
connection.authType === "apikey" || connection.authType === "api_key";
|
||||
const isCookieAuth = connection.authType === "cookie";
|
||||
const isApikeyEligible =
|
||||
isApikeyAuth && USAGE_APIKEY_PROVIDERS.includes(connection.provider);
|
||||
(isApikeyAuth || isCookieAuth) && USAGE_APIKEY_PROVIDERS.includes(connection.provider);
|
||||
|
||||
if (!isOAuth && !isApikeyEligible) {
|
||||
return Response.json({ message: "Usage not available for this connection" });
|
||||
|
||||
@@ -29,7 +29,7 @@ export {
|
||||
|
||||
// API keys
|
||||
export {
|
||||
getApiKeys, getApiKeyById, createApiKey, updateApiKey, deleteApiKey, validateApiKey,
|
||||
getApiKeys, getApiKeyById, createApiKey, updateApiKey, importApiKey, deleteApiKey, validateApiKey,
|
||||
} from "./repos/apiKeysRepo.js";
|
||||
|
||||
// Combos
|
||||
|
||||
@@ -61,6 +61,31 @@ export async function updateApiKey(id, data) {
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function importApiKey(name, keyValue) {
|
||||
if (!keyValue?.trim()) throw new Error("Key value is required");
|
||||
const db = await getAdapter();
|
||||
|
||||
// Check for duplicates
|
||||
const existing = db.get(`SELECT id FROM apiKeys WHERE key = ?`, [keyValue.trim()]);
|
||||
if (existing) {
|
||||
throw new Error("This API key already exists in the system");
|
||||
}
|
||||
|
||||
const apiKey = {
|
||||
id: uuidv4(),
|
||||
name: name?.trim() || "Imported Key",
|
||||
key: keyValue.trim(),
|
||||
machineId: null,
|
||||
isActive: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
db.run(
|
||||
`INSERT INTO apiKeys(id, key, name, machineId, isActive, createdAt) VALUES(?, ?, ?, ?, ?, ?)`,
|
||||
[apiKey.id, apiKey.key, apiKey.name, apiKey.machineId, 1, apiKey.createdAt]
|
||||
);
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
export async function deleteApiKey(id) {
|
||||
const db = await getAdapter();
|
||||
const res = db.run(`DELETE FROM apiKeys WHERE id = ?`, [id]);
|
||||
|
||||
@@ -13,6 +13,8 @@ const DEFAULT_SETTINGS = {
|
||||
tailscaleUrl: "",
|
||||
stickyRoundRobinLimit: 3,
|
||||
providerStrategies: {},
|
||||
providerTimeouts: {},
|
||||
defaultTimeoutMs: null,
|
||||
comboStrategy: "fallback",
|
||||
comboStickyRoundRobinLimit: 1,
|
||||
comboStrategies: {},
|
||||
|
||||
@@ -10,7 +10,7 @@ export {
|
||||
createProviderNode, updateProviderNode, deleteProviderNode,
|
||||
getProxyPools, getProxyPoolById,
|
||||
createProxyPool, updateProxyPool, deleteProxyPool,
|
||||
getApiKeys, getApiKeyById, createApiKey, updateApiKey, deleteApiKey, validateApiKey,
|
||||
getApiKeys, getApiKeyById, createApiKey, updateApiKey, importApiKey, deleteApiKey, validateApiKey,
|
||||
getCombos, getComboById, getComboByName,
|
||||
createCombo, updateCombo, deleteCombo,
|
||||
getModelAliases, setModelAlias, deleteModelAlias,
|
||||
|
||||
@@ -32,6 +32,7 @@ export {
|
||||
setMitmAliasAll,
|
||||
getApiKeys,
|
||||
createApiKey,
|
||||
importApiKey,
|
||||
deleteApiKey,
|
||||
validateApiKey,
|
||||
isCloudEnabled,
|
||||
|
||||
@@ -171,7 +171,7 @@ export default function EditConnectionModal({ isOpen, connection, proxyPools, on
|
||||
if (providerRegions && region) {
|
||||
updates.providerSpecificData = buildRegionSpecificData();
|
||||
}
|
||||
|
||||
|
||||
await onSave(updates);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
@@ -202,6 +202,8 @@ export default function EditConnectionModal({ isOpen, connection, proxyPools, on
|
||||
onChange={(e) => setFormData({ ...formData, priority: Number.parseInt(e.target.value, 10) || 1 })}
|
||||
/>
|
||||
|
||||
|
||||
|
||||
{!isOAuth && (
|
||||
<>
|
||||
<div className="flex gap-2">
|
||||
|
||||
Reference in New Issue
Block a user