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:
2026-07-13 16:52:22 +07:00
parent 7f436e2792
commit b1d368d960
28 changed files with 939 additions and 149 deletions

View File

@@ -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}

View File

@@ -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"] },

View File

@@ -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">

View File

@@ -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,
};

View File

@@ -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>
);
}

View File

@@ -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>

View 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 });
}
}

View 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 });
}
}

View File

@@ -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) {

View File

@@ -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)
);
}

View File

@@ -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,
};
});

View File

@@ -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" });

View File

@@ -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

View File

@@ -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]);

View File

@@ -13,6 +13,8 @@ const DEFAULT_SETTINGS = {
tailscaleUrl: "",
stickyRoundRobinLimit: 3,
providerStrategies: {},
providerTimeouts: {},
defaultTimeoutMs: null,
comboStrategy: "fallback",
comboStickyRoundRobinLimit: 1,
comboStrategies: {},

View File

@@ -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,

View File

@@ -32,6 +32,7 @@ export {
setMitmAliasAll,
getApiKeys,
createApiKey,
importApiKey,
deleteApiKey,
validateApiKey,
isCloudEnabled,

View File

@@ -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">