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

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

View File

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

View File

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

View File

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

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

View File

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

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

View File

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

View File

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

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

View File

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