From b1d368d96066274ce53238a429a6458e219fdf7f Mon Sep 17 00:00:00 2001 From: luulam Date: Mon, 13 Jul 2026 16:52:22 +0700 Subject: [PATCH] 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 --- open-sse/executors/base.js | 3 +- open-sse/executors/qoder.js | 3 +- open-sse/handlers/imageGenerationCore.js | 4 +- open-sse/handlers/imageProviders/index.js | 3 +- open-sse/handlers/imageProviders/xai.js | 137 +++++++++++++++ open-sse/providers/registry/xai.js | 21 ++- open-sse/services/providerTimeout.js | 56 ++++++ open-sse/utils/proxyFetch.js | 90 +--------- skills/9router-image/SKILL.md | 1 + .../dashboard/endpoint/EndpointPageClient.js | 110 +++++++++++- .../[kind]/[id]/components/exampleShared.js | 2 + src/app/(dashboard)/dashboard/profile/page.js | 56 ++++++ .../providers/[id]/CompatibleModelsSection.js | 161 +++++++++++++----- .../dashboard/providers/[id]/ConnectionRow.js | 84 ++++++++- .../dashboard/providers/[id]/page.js | 95 +++++++++++ src/app/api/keys/import/route.js | 31 ++++ src/app/api/providers/[id]/api-key/route.js | 26 +++ src/app/api/providers/[id]/route.js | 10 ++ src/app/api/providers/client/route.js | 8 +- src/app/api/providers/route.js | 2 + src/app/api/usage/[connectionId]/route.js | 7 +- src/lib/db/index.js | 2 +- src/lib/db/repos/apiKeysRepo.js | 25 +++ src/lib/db/repos/settingsRepo.js | 2 + src/lib/localDb.js | 2 +- src/models/index.js | 1 + src/shared/components/EditConnectionModal.js | 4 +- tests/unit/image-generation.test.js | 142 +++++++++++++++ 28 files changed, 939 insertions(+), 149 deletions(-) create mode 100644 open-sse/handlers/imageProviders/xai.js create mode 100644 open-sse/services/providerTimeout.js create mode 100644 src/app/api/keys/import/route.js create mode 100644 src/app/api/providers/[id]/api-key/route.js diff --git a/open-sse/executors/base.js b/open-sse/executors/base.js index 71418deb..69d7a185 100644 --- a/open-sse/executors/base.js +++ b/open-sse/executors/base.js @@ -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; diff --git a/open-sse/executors/qoder.js b/open-sse/executors/qoder.js index 2a7714b3..ddaac8c0 100644 --- a/open-sse/executors/qoder.js +++ b/open-sse/executors/qoder.js @@ -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; diff --git a/open-sse/handlers/imageGenerationCore.js b/open-sse/handlers/imageGenerationCore.js index d2d0eba2..db408114 100644 --- a/open-sse/handlers/imageGenerationCore.js +++ b/open-sse/handlers/imageGenerationCore.js @@ -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, diff --git a/open-sse/handlers/imageProviders/index.js b/open-sse/handlers/imageProviders/index.js index 520c3d60..c52f837c 100644 --- a/open-sse/handlers/imageProviders/index.js +++ b/open-sse/handlers/imageProviders/index.js @@ -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, diff --git a/open-sse/handlers/imageProviders/xai.js b/open-sse/handlers/imageProviders/xai.js new file mode 100644 index 00000000..7bfd39ed --- /dev/null +++ b/open-sse/handlers/imageProviders/xai.js @@ -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, +}; diff --git a/open-sse/providers/registry/xai.js b/open-sse/providers/registry/xai.js index efe13bdd..eeff6622 100644 --- a/open-sse/providers/registry/xai.js +++ b/open-sse/providers/registry/xai.js @@ -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", diff --git a/open-sse/services/providerTimeout.js b/open-sse/services/providerTimeout.js new file mode 100644 index 00000000..ed05c1dc --- /dev/null +++ b/open-sse/services/providerTimeout.js @@ -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; +} diff --git a/open-sse/utils/proxyFetch.js b/open-sse/utils/proxyFetch.js index 341b8158..ad518fe8 100644 --- a/open-sse/utils/proxyFetch.js +++ b/open-sse/utils/proxyFetch.js @@ -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); } diff --git a/skills/9router-image/SKILL.md b/skills/9router-image/SKILL.md index f5bbfad1..579dc988 100644 --- a/skills/9router-image/SKILL.md +++ b/skills/9router-image/SKILL.md @@ -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 | diff --git a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js index a7183741..85ebec06 100644 --- a/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js +++ b/src/app/(dashboard)/dashboard/endpoint/EndpointPageClient.js @@ -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 }) { vpn_key API Keys - +
+ + +
@@ -1095,6 +1105,100 @@ export default function APIPageClient({ machineId }) {
+ {/* Import Key Modal */} + { + setShowImportModal(false); + setImportKeyValue(""); + setImportKeyName(""); + setImportError(null); + }} + > +
+
+

+ Paste an existing API key to add it to this instance. + Useful for transferring keys from another 9Router instance or adding externally generated keys. +

+
+ + {importError && ( +
+ error + {importError} +
+ )} + + { + setImportKeyValue(e.target.value); + setImportError(null); + }} + placeholder="Paste your API key here" + className="font-mono" + /> + setImportKeyName(e.target.value)} + placeholder="Imported Key" + /> +
+ + +
+
+
+ {/* Created Key Modal */} { + 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() { + {/* Default Timeout — global default for all providers */} + +
+
+ timer +
+

Default Connect Timeout

+
+
+
+
+

All Providers

+

+ Timeout for upstream connect (applies globally unless overridden per provider). Set to 0 or leave empty for system default (60s). +

+
+
+ + ms +
+
+

+ {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."} +

+
+
+ {/* Network */}
diff --git a/src/app/(dashboard)/dashboard/providers/[id]/CompatibleModelsSection.js b/src/app/(dashboard)/dashboard/providers/[id]/CompatibleModelsSection.js index bfc12a13..d59150a9 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/CompatibleModelsSection.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/CompatibleModelsSection.js @@ -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 (
-

- Add {isAnthropic ? "Anthropic" : "OpenAI"}-compatible models manually or import them from the /models endpoint. -

-
@@ -182,14 +198,71 @@ export default function CompatibleModelsSection({ providerStorageAlias, provider - + + {testAllRunning && ( + + )}
- {!canImport && ( + {(testAllResults || testAllRunning) && ( +
+
+ + {testAllRunning ? "progress_activity" : testAllResults?.failed === 0 ? "check_circle" : "warning"} + + + {testAllRunning + ? `Testing... ${(testAllResults?.passed || 0) + (testAllResults?.failed || 0)}/${allModels.length}` + : `${testAllResults?.passed || 0} passed, ${testAllResults?.failed || 0} failed` + } + + {testingModelId && testAllRunning && ( + + (current: {testingModelId}) + + )} + {!testAllRunning && failedIds.length > 0 && ( + + )} +
+
+ )} + + {!canFetch && (

- Add a connection to enable importing models. + Add a connection to enable fetching models.

)} @@ -229,4 +302,6 @@ CompatibleModelsSection.propTypes = { isActive: PropTypes.bool, })).isRequired, isAnthropic: PropTypes.bool, + onFetchModels: PropTypes.func, + fetchingModels: PropTypes.bool, }; diff --git a/src/app/(dashboard)/dashboard/providers/[id]/ConnectionRow.js b/src/app/(dashboard)/dashboard/providers/[id]/ConnectionRow.js index 0b5bea19..dcc9172f 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/ConnectionRow.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/ConnectionRow.js @@ -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 )} + {connection.authType === "apikey" && ( + + )}
+ + {/* Show Key Modal */} + { + setShowKeyModal(false); + setRevealedKey(""); + setKeyError(null); + }} + > +
+ {keyError ? ( +
+ error + {keyError} +
+ ) : ( + <> +

+ warning + This key provides full access to your endpoint. Keep it secure. +

+
+ e.target.select()} + /> + +
+ + )} + +
+
); } diff --git a/src/app/(dashboard)/dashboard/providers/[id]/page.js b/src/app/(dashboard)/dashboard/providers/[id]/page.js index af8de510..6f0c9032 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/page.js +++ b/src/app/(dashboard)/dashboard/providers/[id]/page.js @@ -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() {
)} */} + {/* Connect Timeout */} +
+ Connect Timeout +
+ 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" + /> + ms +
+
{/* Round Robin toggle */}
Round Robin diff --git a/src/app/api/keys/import/route.js b/src/app/api/keys/import/route.js new file mode 100644 index 00000000..55796460 --- /dev/null +++ b/src/app/api/keys/import/route.js @@ -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 }); + } +} diff --git a/src/app/api/providers/[id]/api-key/route.js b/src/app/api/providers/[id]/api-key/route.js new file mode 100644 index 00000000..7e74eca7 --- /dev/null +++ b/src/app/api/providers/[id]/api-key/route.js @@ -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 }); + } +} diff --git a/src/app/api/providers/[id]/route.js b/src/app/api/providers/[id]/route.js index 6ab51797..360ea620 100644 --- a/src/app/api/providers/[id]/route.js +++ b/src/app/api/providers/[id]/route.js @@ -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) { diff --git a/src/app/api/providers/client/route.js b/src/app/api/providers/client/route.js index be5342c1..c27a065c 100644 --- a/src/app/api/providers/client/route.js +++ b/src/app/api/providers/client/route.js @@ -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) ); } diff --git a/src/app/api/providers/route.js b/src/app/api/providers/route.js index 5885472b..65c9b93c 100644 --- a/src/app/api/providers/route.js +++ b/src/app/api/providers/route.js @@ -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, }; }); diff --git a/src/app/api/usage/[connectionId]/route.js b/src/app/api/usage/[connectionId]/route.js index 8ccdc015..cc9fd868 100644 --- a/src/app/api/usage/[connectionId]/route.js +++ b/src/app/api/usage/[connectionId]/route.js @@ -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" }); diff --git a/src/lib/db/index.js b/src/lib/db/index.js index 0d5dd652..5652afe5 100644 --- a/src/lib/db/index.js +++ b/src/lib/db/index.js @@ -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 diff --git a/src/lib/db/repos/apiKeysRepo.js b/src/lib/db/repos/apiKeysRepo.js index ff09d926..4feb3b51 100644 --- a/src/lib/db/repos/apiKeysRepo.js +++ b/src/lib/db/repos/apiKeysRepo.js @@ -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]); diff --git a/src/lib/db/repos/settingsRepo.js b/src/lib/db/repos/settingsRepo.js index 0057cc1c..53c7ae49 100644 --- a/src/lib/db/repos/settingsRepo.js +++ b/src/lib/db/repos/settingsRepo.js @@ -13,6 +13,8 @@ const DEFAULT_SETTINGS = { tailscaleUrl: "", stickyRoundRobinLimit: 3, providerStrategies: {}, + providerTimeouts: {}, + defaultTimeoutMs: null, comboStrategy: "fallback", comboStickyRoundRobinLimit: 1, comboStrategies: {}, diff --git a/src/lib/localDb.js b/src/lib/localDb.js index 71d086e6..3d6d6ca6 100644 --- a/src/lib/localDb.js +++ b/src/lib/localDb.js @@ -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, diff --git a/src/models/index.js b/src/models/index.js index 99444f62..57aeebe7 100644 --- a/src/models/index.js +++ b/src/models/index.js @@ -32,6 +32,7 @@ export { setMitmAliasAll, getApiKeys, createApiKey, + importApiKey, deleteApiKey, validateApiKey, isCloudEnabled, diff --git a/src/shared/components/EditConnectionModal.js b/src/shared/components/EditConnectionModal.js index 1cf13f16..3f5fed71 100644 --- a/src/shared/components/EditConnectionModal.js +++ b/src/shared/components/EditConnectionModal.js @@ -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 && ( <>
diff --git a/tests/unit/image-generation.test.js b/tests/unit/image-generation.test.js index e5504aec..fcd212ec 100644 --- a/tests/unit/image-generation.test.js +++ b/tests/unit/image-generation.test.js @@ -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"); + }); });