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