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 - +
+ Paste an existing API key to add it to this instance. + Useful for transferring keys from another 9Router instance or adding externally generated keys. +
+All Providers
++ Timeout for upstream connect (applies globally unless overridden per provider). Set to 0 or leave empty for system default (60s). +
++ {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."} +
+- Add {isAnthropic ? "Anthropic" : "OpenAI"}-compatible models manually or import them from the /models endpoint. -
-- 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" && ( ++ warning + This key provides full access to your endpoint. Keep it secure. +
+