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:
@@ -2,6 +2,7 @@ import { HTTP_STATUS, RETRY_CONFIG, DEFAULT_RETRY_CONFIG, resolveRetryEntry, FET
|
||||
import { shouldRefreshCredentials } from "../services/oauthCredentialManager.js";
|
||||
import { proxyAwareFetch } from "../utils/proxyFetch.js";
|
||||
import { dbg } from "../utils/debugLog.js";
|
||||
import { resolveProviderTimeoutMs } from "../services/providerTimeout.js";
|
||||
import { ANTHROPIC_API_VERSION, OPENAI_COMPAT_BASE, ANTHROPIC_COMPAT_BASE } from "../providers/shared.js";
|
||||
|
||||
/**
|
||||
@@ -132,7 +133,7 @@ export class BaseExecutor {
|
||||
|
||||
// Abort if upstream doesn't return response headers within connection timeout
|
||||
const connectCtrl = new AbortController();
|
||||
const timeoutMs = this.config?.timeoutMs || FETCH_CONNECT_TIMEOUT_MS;
|
||||
const timeoutMs = await resolveProviderTimeoutMs(this.provider, this.config?.timeoutMs, FETCH_CONNECT_TIMEOUT_MS);
|
||||
const connectTimer = setTimeout(() => connectCtrl.abort(new Error("fetch connect timeout")), timeoutMs);
|
||||
const mergedSignal = signal ? AbortSignal.any([signal, connectCtrl.signal]) : connectCtrl.signal;
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ import { PROVIDERS } from "../config/providers.js";
|
||||
import { proxyAwareFetch } from "../utils/proxyFetch.js";
|
||||
import { SSE_DONE } from "../utils/sseConstants.js";
|
||||
import { FETCH_CONNECT_TIMEOUT_MS } from "../config/runtimeConfig.js";
|
||||
import { resolveProviderTimeoutMs } from "../services/providerTimeout.js";
|
||||
import {
|
||||
QODER_CHAT_URL_ENCODED,
|
||||
QODER_MODEL_MAP,
|
||||
@@ -410,7 +411,7 @@ export class QoderExecutor extends BaseExecutor {
|
||||
};
|
||||
|
||||
// Abort if upstream doesn't return response headers within connect timeout.
|
||||
const timeoutMs = this.config?.timeoutMs || FETCH_CONNECT_TIMEOUT_MS;
|
||||
const timeoutMs = await resolveProviderTimeoutMs(this.provider, this.config?.timeoutMs, FETCH_CONNECT_TIMEOUT_MS);
|
||||
const connectCtrl = new AbortController();
|
||||
const connectTimer = setTimeout(() => connectCtrl.abort(new Error("fetch connect timeout")), timeoutMs);
|
||||
const mergedSignal = signal ? AbortSignal.any([signal, connectCtrl.signal]) : connectCtrl.signal;
|
||||
|
||||
@@ -96,7 +96,7 @@ export async function handleImageGenerationCore({
|
||||
let requestBody;
|
||||
|
||||
try {
|
||||
url = adapter.buildUrl(model, credentials);
|
||||
url = adapter.buildUrl(model, credentials, body);
|
||||
requestBody = await adapter.buildBody(model, body);
|
||||
headers = adapter.buildHeaders(credentials, requestBody, model, body);
|
||||
} catch (error) {
|
||||
@@ -140,7 +140,7 @@ export async function handleImageGenerationCore({
|
||||
try {
|
||||
const retryBody = await adapter.buildBody(model, body);
|
||||
const retryHeaders = adapter.buildHeaders(credentials, retryBody, model, body);
|
||||
const retryUrl = adapter.buildUrl(model, credentials);
|
||||
const retryUrl = adapter.buildUrl(model, credentials, body);
|
||||
providerResponse = await fetch(retryUrl, {
|
||||
method: "POST",
|
||||
headers: retryHeaders,
|
||||
|
||||
@@ -12,6 +12,7 @@ import blackForestLabs from "./blackForestLabs.js";
|
||||
import runwayml from "./runwayml.js";
|
||||
import cloudflareAi from "./cloudflareAi.js";
|
||||
import antigravity from "./antigravity.js";
|
||||
import xai from "./xai.js";
|
||||
|
||||
const ADAPTERS = {
|
||||
openai: createOpenAIAdapter("openai"),
|
||||
@@ -19,7 +20,7 @@ const ADAPTERS = {
|
||||
openrouter: createOpenAIAdapter("openrouter"),
|
||||
recraft: createOpenAIAdapter("recraft"),
|
||||
"vercel-ai-gateway": createOpenAIAdapter("vercel-ai-gateway"),
|
||||
xai: createOpenAIAdapter("xai"),
|
||||
xai,
|
||||
gemini,
|
||||
codex,
|
||||
sdwebui,
|
||||
|
||||
137
open-sse/handlers/imageProviders/xai.js
Normal file
137
open-sse/handlers/imageProviders/xai.js
Normal file
@@ -0,0 +1,137 @@
|
||||
// xAI Grok Imagine — text-to-image + single/multi image editing
|
||||
// Docs:
|
||||
// https://docs.x.ai/developers/model-capabilities/images/generation
|
||||
// https://docs.x.ai/developers/model-capabilities/images/editing
|
||||
// https://docs.x.ai/developers/model-capabilities/images/multi-image-editing
|
||||
import { sizeToAspectRatio } from "./_base.js";
|
||||
import { PROVIDER_MEDIA } from "../../providers/index.js";
|
||||
|
||||
const IMG_CFG = PROVIDER_MEDIA["xai"]?.imageConfig || {};
|
||||
const GENERATIONS_URL = IMG_CFG.baseUrl || "https://api.x.ai/v1/images/generations";
|
||||
const EDITS_URL = IMG_CFG.editsUrl || "https://api.x.ai/v1/images/edits";
|
||||
|
||||
const ASPECT_RATIOS = new Set([
|
||||
"auto",
|
||||
"1:1",
|
||||
"16:9",
|
||||
"9:16",
|
||||
"4:3",
|
||||
"3:2",
|
||||
"2:3",
|
||||
"9:19.5",
|
||||
"20:9",
|
||||
]);
|
||||
|
||||
function hasEditInput(body) {
|
||||
if (!body || typeof body !== "object") return false;
|
||||
if (body.image) return true;
|
||||
return Array.isArray(body.images) && body.images.some(Boolean);
|
||||
}
|
||||
|
||||
/** Normalize client image input → xAI image ref object */
|
||||
function toXaiImageRef(input) {
|
||||
if (!input) return null;
|
||||
|
||||
if (typeof input === "object") {
|
||||
// Already xAI-shaped or partial
|
||||
if (input.file_id) {
|
||||
return {
|
||||
type: input.type || "image_url",
|
||||
file_id: input.file_id,
|
||||
...(input.url ? { url: input.url } : {}),
|
||||
};
|
||||
}
|
||||
if (input.url) {
|
||||
return { type: input.type || "image_url", url: input.url };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof input !== "string") return null;
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
// Public URL or data URI
|
||||
if (/^https?:\/\//i.test(trimmed) || /^data:image\//i.test(trimmed)) {
|
||||
return { type: "image_url", url: trimmed };
|
||||
}
|
||||
|
||||
// Raw base64 → data URI
|
||||
return { type: "image_url", url: `data:image/png;base64,${trimmed}` };
|
||||
}
|
||||
|
||||
function collectImageRefs(body) {
|
||||
const refs = [];
|
||||
if (Array.isArray(body.images)) {
|
||||
for (const item of body.images) {
|
||||
const ref = toXaiImageRef(item);
|
||||
if (ref) refs.push(ref);
|
||||
}
|
||||
}
|
||||
if (body.image) {
|
||||
const ref = toXaiImageRef(body.image);
|
||||
if (ref) refs.push(ref);
|
||||
}
|
||||
// xAI multi-edit supports up to 3 source images
|
||||
return refs.slice(0, 3);
|
||||
}
|
||||
|
||||
function resolveAspectRatio(body) {
|
||||
if (typeof body.aspect_ratio === "string" && body.aspect_ratio.trim()) {
|
||||
const ratio = body.aspect_ratio.trim();
|
||||
if (ASPECT_RATIOS.has(ratio)) return ratio;
|
||||
// Pass through unknown ratio strings (upstream will validate)
|
||||
return ratio;
|
||||
}
|
||||
// OpenAI-style size → aspect ratio (skip auto)
|
||||
if (body.size && body.size !== "auto") {
|
||||
return sizeToAspectRatio(body.size);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveResolution(body) {
|
||||
if (typeof body.resolution !== "string") return undefined;
|
||||
const value = body.resolution.trim().toLowerCase();
|
||||
if (!value || value === "auto") return undefined;
|
||||
return value; // "1k" | "2k"
|
||||
}
|
||||
|
||||
export default {
|
||||
buildUrl: (_model, _credentials, body) => (hasEditInput(body) ? EDITS_URL : GENERATIONS_URL),
|
||||
|
||||
buildHeaders: (creds) => {
|
||||
const headers = { "Content-Type": "application/json", ...(IMG_CFG.headers || {}) };
|
||||
const key = creds?.apiKey || creds?.accessToken;
|
||||
if (key) headers["Authorization"] = `Bearer ${key}`;
|
||||
return headers;
|
||||
},
|
||||
|
||||
buildBody: (model, body) => {
|
||||
const req = {
|
||||
model,
|
||||
prompt: body.prompt,
|
||||
};
|
||||
|
||||
if (body.n != null) req.n = body.n;
|
||||
if (body.response_format) req.response_format = body.response_format;
|
||||
|
||||
const aspectRatio = resolveAspectRatio(body);
|
||||
if (aspectRatio) req.aspect_ratio = aspectRatio;
|
||||
|
||||
const resolution = resolveResolution(body);
|
||||
if (resolution) req.resolution = resolution;
|
||||
|
||||
const refs = collectImageRefs(body);
|
||||
if (refs.length === 1) {
|
||||
req.image = refs[0];
|
||||
} else if (refs.length > 1) {
|
||||
req.images = refs;
|
||||
}
|
||||
|
||||
return req;
|
||||
},
|
||||
|
||||
// xAI already returns OpenAI-compatible { created, data: [{ url | b64_json }] }
|
||||
normalize: (responseBody) => responseBody,
|
||||
};
|
||||
@@ -31,10 +31,27 @@ export default {
|
||||
{ id: "grok-4-fast-reasoning", name: "Grok 4 Fast Reasoning" },
|
||||
{ id: "grok-code-fast-1", name: "Grok Code Fast" },
|
||||
{ id: "grok-3", name: "Grok 3" },
|
||||
{ id: "grok-2-image-1212", name: "Grok 2 Image", params: ["n","response_format"], kind: "image" },
|
||||
{
|
||||
id: "grok-imagine-image-quality",
|
||||
name: "Grok Imagine Image Quality",
|
||||
capabilities: ["text2img", "edit"],
|
||||
params: ["n", "aspect_ratio", "resolution", "response_format", "size"],
|
||||
kind: "image",
|
||||
},
|
||||
{
|
||||
id: "grok-2-image-1212",
|
||||
name: "Grok 2 Image",
|
||||
capabilities: ["text2img", "edit"],
|
||||
params: ["n", "aspect_ratio", "resolution", "response_format", "size"],
|
||||
kind: "image",
|
||||
},
|
||||
],
|
||||
serviceKinds: ["llm","imageToText","webSearch","image"],
|
||||
imageConfig: { baseUrl: "https://api.x.ai/v1/images/generations", bodyFields: ["model","prompt","n","response_format"] },
|
||||
imageConfig: {
|
||||
baseUrl: "https://api.x.ai/v1/images/generations",
|
||||
editsUrl: "https://api.x.ai/v1/images/edits",
|
||||
bodyFields: ["model", "prompt", "n", "response_format", "aspect_ratio", "resolution", "image", "images"],
|
||||
},
|
||||
searchViaChat: {
|
||||
defaultModel: "grok-4.20-reasoning",
|
||||
endpoint: "https://api.x.ai/v1/responses",
|
||||
|
||||
56
open-sse/services/providerTimeout.js
Normal file
56
open-sse/services/providerTimeout.js
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Per-provider connect timeout overrides from user settings.
|
||||
* Settings are read from the DB lazily and cached with a short TTL
|
||||
* so UI changes take effect without requiring a restart.
|
||||
*/
|
||||
|
||||
let cached = {};
|
||||
let cacheTs = 0;
|
||||
const CACHE_TTL_MS = 10_000; // 10s — responsive enough for dashboard changes
|
||||
|
||||
async function refreshCache() {
|
||||
const now = Date.now();
|
||||
if (now - cacheTs < CACHE_TTL_MS && Object.keys(cached).length > 0) return cached;
|
||||
|
||||
try {
|
||||
const { getSettings } = await import("@/lib/localDb");
|
||||
// Return full settings so we can read providerTimeouts + globalTimeoutMs
|
||||
cached = await getSettings();
|
||||
cacheTs = now;
|
||||
} catch {
|
||||
// If DB is unavailable, keep stale cache — don't throw on hot path
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective connect timeout for a provider.
|
||||
* Priority: per-provider override > global default timeout (settings) > registry config > env default.
|
||||
* @param {string} providerId
|
||||
* @param {number} configTimeoutMs - timeoutMs from the static provider registry config
|
||||
* @param {number} envDefaultMs - global default from env (FETCH_CONNECT_TIMEOUT_MS)
|
||||
* @returns {number} timeout in milliseconds
|
||||
*/
|
||||
export async function resolveProviderTimeoutMs(providerId, configTimeoutMs, envDefaultMs) {
|
||||
const overrides = await refreshCache();
|
||||
|
||||
// 1. Per-provider override (set in provider detail page)
|
||||
const providerOverride = overrides.providerTimeouts?.[providerId];
|
||||
if (providerOverride?.timeoutMs && Number.isFinite(providerOverride.timeoutMs) && providerOverride.timeoutMs > 0) {
|
||||
return providerOverride.timeoutMs;
|
||||
}
|
||||
|
||||
// 2. Global default timeout (set in Profile / Settings page)
|
||||
const globalDefault = overrides.defaultTimeoutMs;
|
||||
if (globalDefault && Number.isFinite(globalDefault) && globalDefault > 0) {
|
||||
return globalDefault;
|
||||
}
|
||||
|
||||
// 3. Registry per-provider config
|
||||
if (configTimeoutMs && Number.isFinite(configTimeoutMs) && configTimeoutMs > 0) {
|
||||
return configTimeoutMs;
|
||||
}
|
||||
|
||||
// 4. Env default
|
||||
return envDefaultMs;
|
||||
}
|
||||
@@ -6,95 +6,12 @@ const originalFetch = globalThis.fetch;
|
||||
const proxyDispatchers = new Map();
|
||||
|
||||
// ─── TLS fingerprinting via got-scraping (browser-like JA3) ───────────────
|
||||
// Disabled: not in use. Kept commented for future re-enable.
|
||||
// Restore the original block to re-enable per-host JA3 spoofing.
|
||||
// Disabled: not in use.
|
||||
/*
|
||||
let _gotScraping = null;
|
||||
let _gotScrapingChecked = false;
|
||||
const _gotScrapingLoggedHosts = new Set();
|
||||
|
||||
async function getGotScraping() {
|
||||
if (_gotScrapingChecked) return _gotScraping;
|
||||
_gotScrapingChecked = true;
|
||||
try {
|
||||
const mod = await import("got-scraping");
|
||||
_gotScraping = typeof mod.gotScraping === "function" ? mod.gotScraping : null;
|
||||
if (_gotScraping) dbg("TLS", "got-scraping loaded (browser-like JA3 enabled)");
|
||||
} catch (e) {
|
||||
console.warn(`[ProxyFetch] got-scraping unavailable, falling back to native fetch: ${e.message}`);
|
||||
_gotScraping = null;
|
||||
}
|
||||
return _gotScraping;
|
||||
}
|
||||
|
||||
async function gotScrapingFetch(url, options) {
|
||||
const gs = await getGotScraping();
|
||||
if (!gs) return null;
|
||||
|
||||
const method = (options.method || "GET").toUpperCase();
|
||||
const headersInit = options.headers || {};
|
||||
const headers = headersInit instanceof Headers
|
||||
? Object.fromEntries(headersInit.entries())
|
||||
: { ...headersInit };
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const stream = gs.stream({
|
||||
url,
|
||||
method,
|
||||
headers,
|
||||
body: method === "GET" || method === "HEAD" ? undefined : options.body,
|
||||
throwHttpErrors: false,
|
||||
retry: { limit: 0 },
|
||||
timeout: { request: undefined },
|
||||
followRedirect: false,
|
||||
decompress: true,
|
||||
});
|
||||
|
||||
if (options.signal) {
|
||||
const onAbort = () => { try { stream.destroy(new Error("aborted")); } catch { } };
|
||||
if (options.signal.aborted) onAbort();
|
||||
else options.signal.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
|
||||
stream.once("response", (res) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
const resHeaders = new Headers();
|
||||
for (const [k, v] of Object.entries(res.headers || {})) {
|
||||
if (Array.isArray(v)) v.forEach((x) => resHeaders.append(k, String(x)));
|
||||
else if (v != null) resHeaders.set(k, String(v));
|
||||
}
|
||||
const body = Readable.toWeb(stream);
|
||||
resolve(new Response(body, { status: res.statusCode, statusText: res.statusMessage || "", headers: resHeaders }));
|
||||
});
|
||||
|
||||
stream.once("error", (err) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function tryGotScrapingFetch(url, options) {
|
||||
try {
|
||||
const res = await gotScrapingFetch(url, options);
|
||||
if (res) {
|
||||
try {
|
||||
const host = new URL(typeof url === "string" ? url : url.toString()).hostname;
|
||||
if (!_gotScrapingLoggedHosts.has(host)) {
|
||||
_gotScrapingLoggedHosts.add(host);
|
||||
dbg("TLS", `using got-scraping for ${host}`);
|
||||
}
|
||||
} catch { }
|
||||
}
|
||||
return res;
|
||||
} catch (e) {
|
||||
console.warn(`[ProxyFetch] got-scraping request failed, fallback to native fetch: ${e.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
async function getGotScraping() { return null; }
|
||||
async function tryGotScrapingFetch() { return null; }
|
||||
*/
|
||||
|
||||
// DNS cache — use Map to avoid prototype pollution via malformed hostnames
|
||||
@@ -349,7 +266,6 @@ export async function proxyAwareFetch(url, options = {}, proxyOptions = null) {
|
||||
}
|
||||
|
||||
// got-scraping disabled — use native fetch directly
|
||||
// (Re-enable per-host by wrapping with tryGotScrapingFetch when needed)
|
||||
return originalFetch(url, options);
|
||||
}
|
||||
|
||||
|
||||
@@ -75,6 +75,7 @@ Common fields above work everywhere. These add/override:
|
||||
| Provider | Extra/changed fields | Notes |
|
||||
|---|---|---|
|
||||
| `openai`, `minimax`, `openrouter`, `recraft` | `quality`, `style`, `response_format` | Standard OpenAI shape |
|
||||
| `xai` (Grok Imagine) | `aspect_ratio`, `resolution`, `image`, `images[]` | Generate → `/images/generations`; edit/multi-edit → `/images/edits` (auto when `image`/`images` present). `size` maps to `aspect_ratio`. Up to 3 source images. |
|
||||
| `gemini` (nano-banana) | — | Only `prompt`; ignores `size`/`n` |
|
||||
| `codex` (gpt-5.4-image) | `image`, `images[]`, `image_detail`, `output_format`, `background` | SSE stream; **ChatGPT Plus/Pro required** |
|
||||
| `huggingface` | — | Only `prompt`; returns single image |
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -543,4 +543,146 @@ describe("handleImageGenerationCore", () => {
|
||||
expect(result.success).toBe(true);
|
||||
expect(onRequestSuccess).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("generates image with xAI Imagine API", async () => {
|
||||
global.fetch.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
created: 1234567890,
|
||||
data: [{ url: "https://example.com/xai-gen.png" }],
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
)
|
||||
);
|
||||
|
||||
const result = await handleImageGenerationCore({
|
||||
body: {
|
||||
prompt: "Mountain landscape at sunrise",
|
||||
n: 2,
|
||||
aspect_ratio: "16:9",
|
||||
resolution: "2k",
|
||||
response_format: "url",
|
||||
},
|
||||
modelInfo: { provider: "xai", model: "grok-imagine-image-quality" },
|
||||
credentials: { apiKey: "xai-key" },
|
||||
log: null,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"https://api.x.ai/v1/images/generations",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: expect.objectContaining({
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer xai-key",
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
const reqBody = JSON.parse(global.fetch.mock.calls[0][1].body);
|
||||
expect(reqBody).toEqual({
|
||||
model: "grok-imagine-image-quality",
|
||||
prompt: "Mountain landscape at sunrise",
|
||||
n: 2,
|
||||
response_format: "url",
|
||||
aspect_ratio: "16:9",
|
||||
resolution: "2k",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps OpenAI size to aspect_ratio for xAI generation", async () => {
|
||||
global.fetch.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({ created: 1, data: [{ url: "https://example.com/xai.png" }] }),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
)
|
||||
);
|
||||
|
||||
await handleImageGenerationCore({
|
||||
body: { prompt: "city", size: "1792x1024" },
|
||||
modelInfo: { provider: "xai", model: "grok-imagine-image-quality" },
|
||||
credentials: { accessToken: "oauth-token" },
|
||||
log: null,
|
||||
});
|
||||
|
||||
const reqBody = JSON.parse(global.fetch.mock.calls[0][1].body);
|
||||
expect(reqBody.aspect_ratio).toBe("16:9");
|
||||
expect(reqBody.size).toBeUndefined();
|
||||
expect(global.fetch.mock.calls[0][1].headers.Authorization).toBe("Bearer oauth-token");
|
||||
});
|
||||
|
||||
it("edits a single image via xAI /images/edits", async () => {
|
||||
global.fetch.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({ created: 1, data: [{ url: "https://example.com/xai-edit.png" }] }),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
)
|
||||
);
|
||||
|
||||
const result = await handleImageGenerationCore({
|
||||
body: {
|
||||
prompt: "Render this as a pencil sketch",
|
||||
image: "https://docs.x.ai/assets/api-examples/images/style-realistic.png",
|
||||
},
|
||||
modelInfo: { provider: "xai", model: "grok-imagine-image-quality" },
|
||||
credentials: { apiKey: "xai-key" },
|
||||
log: null,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"https://api.x.ai/v1/images/edits",
|
||||
expect.objectContaining({ method: "POST" })
|
||||
);
|
||||
|
||||
const reqBody = JSON.parse(global.fetch.mock.calls[0][1].body);
|
||||
expect(reqBody.image).toEqual({
|
||||
type: "image_url",
|
||||
url: "https://docs.x.ai/assets/api-examples/images/style-realistic.png",
|
||||
});
|
||||
expect(reqBody.images).toBeUndefined();
|
||||
});
|
||||
|
||||
it("supports multi-image edit for xAI (up to 3 refs)", async () => {
|
||||
global.fetch.mockResolvedValueOnce(
|
||||
new Response(
|
||||
JSON.stringify({ created: 1, data: [{ b64_json: "abc" }] }),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
)
|
||||
);
|
||||
|
||||
const result = await handleImageGenerationCore({
|
||||
body: {
|
||||
prompt: "Show all subjects sitting together on the grass",
|
||||
images: [
|
||||
"https://docs.x.ai/assets/api-examples/images/image-merge/woman.jpg",
|
||||
{ url: "https://docs.x.ai/assets/api-examples/images/image-merge/man.jpg" },
|
||||
"rawbase64payload",
|
||||
"https://example.com/extra-ignored.jpg",
|
||||
],
|
||||
aspect_ratio: "3:2",
|
||||
response_format: "b64_json",
|
||||
},
|
||||
modelInfo: { provider: "xai", model: "grok-imagine-image-quality" },
|
||||
credentials: { apiKey: "xai-key" },
|
||||
log: null,
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"https://api.x.ai/v1/images/edits",
|
||||
expect.any(Object)
|
||||
);
|
||||
|
||||
const reqBody = JSON.parse(global.fetch.mock.calls[0][1].body);
|
||||
expect(reqBody.images).toEqual([
|
||||
{ type: "image_url", url: "https://docs.x.ai/assets/api-examples/images/image-merge/woman.jpg" },
|
||||
{ type: "image_url", url: "https://docs.x.ai/assets/api-examples/images/image-merge/man.jpg" },
|
||||
{ type: "image_url", url: "data:image/png;base64,rawbase64payload" },
|
||||
]);
|
||||
expect(reqBody.image).toBeUndefined();
|
||||
expect(reqBody.aspect_ratio).toBe("3:2");
|
||||
expect(reqBody.response_format).toBe("b64_json");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user