Merge branch 'master' of https://github.com/decolua/9router
Some checks failed
Deploy GitBook to 9router.github.io / build-deploy (push) Has been cancelled

# Conflicts:
#	open-sse/utils/usageTracking.js
This commit is contained in:
2026-06-29 10:10:13 +07:00
466 changed files with 28067 additions and 11945 deletions

View File

@@ -1,12 +1,13 @@
import { detectFormat, getTargetFormat } from "../services/provider.js";
import { detectFormat, getTargetFormat, resolveTransport } from "../services/provider.js";
import { translateRequest } from "../translator/index.js";
import { FORMATS } from "../translator/formats.js";
import { normalizeClaudePassthrough } from "../translator/helpers/claudeHelper.js";
import { normalizeClaudePassthrough } from "../translator/formats/claude.js";
import { COLORS } from "../utils/stream.js";
import { createStreamController } from "../utils/streamHandler.js";
import { refreshWithRetry } from "../services/tokenRefresh.js";
import { createRequestLogger } from "../utils/requestLogger.js";
import { getModelTargetFormat, getModelStrip, getModelUpstreamId, getModelType, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.js";
import { PROVIDERS } from "../config/providers.js";
import { createErrorResult, parseUpstreamError, formatProviderError } from "../utils/error.js";
import { HTTP_STATUS } from "../config/runtimeConfig.js";
import { handleBypassRequest } from "../utils/bypassHandler.js";
@@ -19,7 +20,12 @@ import { handleStreamingResponse, buildOnStreamComplete } from "./chatCore/strea
import { detectClientTool, isNativePassthrough } from "../utils/clientDetector.js";
import { dedupeTools } from "../utils/toolDeduper.js";
import { injectCaveman } from "../rtk/caveman.js";
import { injectPonytail } from "../rtk/ponytail.js";
import { compressMessages, formatRtkLog } from "../rtk/index.js";
import { compressWithHeadroom, formatHeadroomLog, formatHeadroomSizeLog, isHeadroomPhantomSavings } from "../rtk/headroom.js";
import { getCapabilitiesForModel } from "../providers/capabilities.js";
import { stripUnsupportedModalities } from "../translator/concerns/modality.js";
import { prefetchRemoteImages } from "../translator/concerns/prefetch.js";
/**
* Core chat handler - shared between SSE and Worker
@@ -28,7 +34,7 @@ import { compressMessages, formatRtkLog } from "../rtk/index.js";
* @param {object} options.credentials - Provider credentials
* @param {string} options.sourceFormatOverride - Override detected source format (e.g. "openai-responses")
*/
export async function handleChatCore({ body, modelInfo, credentials, log, onCredentialsRefreshed, onRequestSuccess, onDisconnect, clientRawRequest, connectionId, userAgent, apiKey, ccFilterNaming, rtkEnabled, cavemanEnabled, cavemanLevel, sourceFormatOverride, providerThinking }) {
export async function handleChatCore({ body, modelInfo, credentials, log, onCredentialsRefreshed, onRequestSuccess, onDisconnect, clientRawRequest, connectionId, userAgent, apiKey, ccFilterNaming, rtkEnabled, headroomEnabled, headroomUrl, headroomCompressUserMessages, cavemanEnabled, cavemanLevel, ponytailEnabled, ponytailLevel, sourceFormatOverride, providerThinking }) {
const { provider, model } = modelInfo;
const requestStartTime = Date.now();
@@ -40,7 +46,10 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
const alias = PROVIDER_ID_TO_ALIAS[provider] || provider;
const modelTargetFormat = getModelTargetFormat(alias, model);
const targetFormat = modelTargetFormat || getTargetFormat(provider);
// Multi-endpoint providers: pick transport matching sourceFormat → zero translation
const runtimeTransport = resolveTransport(provider, sourceFormat);
const targetFormat = modelTargetFormat || runtimeTransport?.format || getTargetFormat(provider);
if (runtimeTransport && credentials) credentials.runtimeTransport = runtimeTransport;
const stripList = getModelStrip(alias, model);
const upstreamModel = getModelUpstreamId(alias, model);
@@ -59,9 +68,16 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
}
const clientRequestedStreaming = body.stream === true || sourceFormat === FORMATS.ANTIGRAVITY || sourceFormat === FORMATS.GEMINI || sourceFormat === FORMATS.GEMINI_CLI;
const providerRequiresStreaming = provider === "openai" || provider === "codex" || provider === "commandcode";
const providerRequiresStreaming = PROVIDERS[provider]?.forceStream === true;
let stream = providerRequiresStreaming ? true : (body.stream !== false);
// Image generation models require non-streaming (Google v1internal:generateContent)
const modelType = getModelType(alias, model);
const isImageGenModel = modelType === "imageGen" || /image|imagen|image-generation/i.test(model);
if (isImageGenModel && (provider === "antigravity" || provider === "gemini-cli")) {
stream = false;
}
// DeepSeek-TUI: interactive TUI panel sends stream:true and needs SSE.
// Non-interactive mode (-p flag) sends without stream and can't parse SSE.
// Only force non-streaming when client didn't explicitly request it.
@@ -73,7 +89,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
const acceptHeader = clientRawRequest?.headers?.accept || "";
const clientPrefersJson = acceptHeader.includes("application/json");
const clientPrefersSSE = acceptHeader.includes("text/event-stream");
if (clientPrefersJson && !clientPrefersSSE && body.stream !== true) {
if (clientPrefersJson && !clientPrefersSSE && body.stream !== true && !providerRequiresStreaming) {
stream = false;
}
@@ -87,6 +103,22 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
const clientTool = detectClientTool(clientRawRequest?.headers || {}, body);
const passthrough = isNativePassthrough(clientTool, provider);
// Expose raw client headers to translators/executors for session-id resolution
if (credentials) credentials.rawHeaders = clientRawRequest?.headers || {};
// Auto-strip media blocks the model can't read (vision/audio/pdf) before translation.
if (!passthrough) {
const caps = getCapabilitiesForModel(provider, model);
if (stripUnsupportedModalities(body, sourceFormat, caps)) {
log?.debug?.("MODALITY", `stripped unsupported media for ${provider}/${model}`);
}
// Convert remote image URLs to base64 for targets that can't fetch URLs.
try {
const n = await prefetchRemoteImages(body, sourceFormat, targetFormat, { signal: undefined });
if (n > 0) log?.debug?.("MODALITY", `prefetched ${n} remote image(s) for ${targetFormat}`);
} catch (e) { log?.warn?.("MODALITY", `image prefetch failed: ${e.message}`); }
}
let translatedBody;
let toolNameMap;
if (passthrough) {
@@ -129,12 +161,30 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
const rtkLine = formatRtkLog(rtkStats);
if (rtkLine) console.log(rtkLine);
// Headroom: optional external proxy compression; fail open if proxy is absent.
const headroomDiagnostics = {};
const headroomStats = await compressWithHeadroom(translatedBody, { enabled: headroomEnabled, url: headroomUrl, model: upstreamModel, format: finalFormat, compressUserMessages: headroomCompressUserMessages, diagnostics: headroomDiagnostics });
const headroomLine = formatHeadroomLog(headroomStats);
const headroomSizeLine = formatHeadroomSizeLog(headroomDiagnostics);
if (headroomLine) {
log?.info?.("HEADROOM", `${headroomLine}${headroomSizeLine ? ` | ${headroomSizeLine}` : ""}`);
if (isHeadroomPhantomSavings(headroomStats, headroomDiagnostics)) {
log?.warn?.("HEADROOM", `reported token delta, but outbound JSON shrank <5%; provider may bill near-original payload | ${headroomSizeLine}`);
}
} else if (headroomEnabled) log?.warn?.("HEADROOM", `skipped: ${headroomDiagnostics.reason || "compression unavailable"}${headroomDiagnostics.endpoint ? ` (${headroomDiagnostics.endpoint})` : ""}`);
// Caveman: inject terse-style system prompt
if (cavemanEnabled && cavemanLevel) {
injectCaveman(translatedBody, finalFormat, cavemanLevel);
log?.debug?.("CAVEMAN", `${cavemanLevel} | ${finalFormat}`);
}
// Ponytail: inject lazy-senior-dev system prompt
if (ponytailEnabled && ponytailLevel) {
injectPonytail(translatedBody, finalFormat, ponytailLevel);
log?.debug?.("PONYTAIL", `${ponytailLevel} | ${finalFormat}`);
}
const executor = getExecutor(provider);
trackPendingRequest(model, provider, connectionId, true);
appendRequestLog({ model, provider, connectionId, status: "PENDING" }).catch(() => { });

View File

@@ -37,6 +37,12 @@ export function translateNonStreamingResponse(responseBody, targetFormat, source
function: { name: part.functionCall.name, arguments: JSON.stringify(part.functionCall.args || {}) }
});
}
// Handle inline image data (from image generation models)
const inlineData = part.inlineData || part.inline_data;
if (inlineData?.data) {
const mimeType = inlineData.mimeType || inlineData.mime_type || "image/png";
textContent += `\n![image](data:${mimeType};base64,${inlineData.data})\n`;
}
}
}
@@ -76,7 +82,12 @@ export function translateNonStreamingResponse(responseBody, targetFormat, source
// missing/null (e.g. M3 with max_tokens:1 spends the budget on thinking
// and returns `content: null`). Returning the raw body would leave the
// OpenAI client without a `choices` array and surface as a UI test error.
if (responseBody.content && !Array.isArray(responseBody.content)) return responseBody;
// Early return if the response is already in OpenAI format (has choices array)
// or if it has content as a non-array value (likely a different non-Claude format).
// Some providers (e.g. xiaomi-tokenplan) return OpenAI-format responses even when
// the request was translated to Claude format — the targetFormat is Claude but the
// actual response is OpenAI-native and needs no further translation.
if (responseBody.choices || (responseBody.content && !Array.isArray(responseBody.content))) return responseBody;
let textContent = "", thinkingContent = "";
const toolCalls = [];
@@ -156,7 +167,13 @@ export async function handleNonStreamingResponse({ providerResponse, provider, m
}
reqLogger.logProviderResponse(providerResponse.status, providerResponse.statusText, providerResponse.headers, responseBody);
if (onRequestSuccess) await onRequestSuccess();
if (onRequestSuccess) {
Promise.resolve()
.then(onRequestSuccess)
.catch(err => {
console.error("[ChatCore] onRequestSuccess failed:", err?.message || err);
});
}
// Decloak tool_use names once on raw Claude body, before any translation (INPUT side)
responseBody = decloakToolNames(responseBody, toolNameMap);
@@ -193,11 +210,14 @@ export async function handleNonStreamingResponse({ providerResponse, provider, m
translatedResponse.usage = filterUsageForFormat(addBufferToUsage(translatedResponse.usage), sourceFormat);
}
// Strip reasoning_content — some clients (e.g. Firecrawl AI SDK) have JSON parsers that
// break on this non-standard field, even though OpenAI allows it in extensions.
// Strip reasoning_content only when content is non-empty.
// When content is empty (e.g. thinking models that used all tokens for reasoning),
// reasoning_content is the only useful output and must be preserved.
if (translatedResponse?.choices) {
for (const choice of translatedResponse.choices) {
if (choice?.message) delete choice.message.reasoning_content;
if (choice?.message?.reasoning_content && choice.message.content) {
delete choice.message.reasoning_content;
}
}
}

View File

@@ -2,7 +2,11 @@ import { convertResponsesStreamToJson } from "../../transformer/streamToJsonConv
import { createErrorResult } from "../../utils/error.js";
import { HTTP_STATUS } from "../../config/runtimeConfig.js";
import { FORMATS } from "../../translator/formats.js";
import { PROVIDERS } from "../../config/providers.js";
import { buildRequestDetail, extractRequestConfig, saveUsageStats } from "./requestDetail.js";
// Responses-API providers (e.g. codex) may emit SSE without content-type + use Responses output shape
const isResponsesProvider = (p) => PROVIDERS[p]?.format === FORMATS.OPENAI_RESPONSES;
import { saveRequestDetail, appendRequestLog } from "@/lib/usageDb.js";
function textFromResponsesMessageItem(item) {
@@ -100,7 +104,7 @@ export function parseSSEToOpenAIResponse(rawSSE, fallbackModel) {
*/
export async function handleForcedSSEToJson({ providerResponse, sourceFormat, provider, model, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, trackDone, appendLog }) {
const contentType = providerResponse.headers.get("content-type") || "";
const isSSE = contentType.includes("text/event-stream") || (contentType === "" && provider === "codex");
const isSSE = contentType.includes("text/event-stream") || (contentType === "" && isResponsesProvider(provider));
if (!isSSE) return null; // not handled here
trackDone();
@@ -112,7 +116,7 @@ export async function handleForcedSSEToJson({ providerResponse, sourceFormat, pr
};
// Codex/Responses API SSE path
const isCodexResponsesApi = provider === "codex" || sourceFormat === FORMATS.OPENAI_RESPONSES;
const isCodexResponsesApi = isResponsesProvider(provider) || sourceFormat === FORMATS.OPENAI_RESPONSES;
if (isCodexResponsesApi) {
try {
const jsonResponse = await convertResponsesStreamToJson(providerResponse.body);

View File

@@ -7,12 +7,16 @@ import { STREAM_STALL_TIMEOUT_MS } from "../../config/runtimeConfig.js";
import { buildAbortedResponsesTerminalBytes } from "../../utils/responsesStreamHelpers.js";
import { buildRequestDetail, extractRequestConfig } from "./requestDetail.js";
import { saveRequestDetail } from "@/lib/usageDb.js";
import { SSE_HEADERS_CORS as SSE_HEADERS } from "../../utils/sseConstants.js";
const SSE_HEADERS = {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"Access-Control-Allow-Origin": "*"
// Codex returns Responses API SSE → which client format to translate INTO, by request sourceFormat.
// Gemini-family all map to ANTIGRAVITY decoder; unknown sources fall back to OPENAI.
const CODEX_SOURCE_TO_TARGET = {
[FORMATS.OPENAI_RESPONSES]: FORMATS.OPENAI_RESPONSES,
[FORMATS.CLAUDE]: FORMATS.CLAUDE,
[FORMATS.ANTIGRAVITY]: FORMATS.ANTIGRAVITY,
[FORMATS.GEMINI]: FORMATS.ANTIGRAVITY,
[FORMATS.GEMINI_CLI]: FORMATS.ANTIGRAVITY,
};
/**
@@ -20,15 +24,12 @@ const SSE_HEADERS = {
*/
function buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey }) {
const isDroidCLI = userAgent?.toLowerCase().includes("droid") || userAgent?.toLowerCase().includes("codex-cli");
const needsCodexTranslation = provider === "codex" && targetFormat === FORMATS.OPENAI_RESPONSES && !isDroidCLI;
// Responses-API providers (e.g. codex) emit Responses SSE → translate into client format
const isResponsesProvider = PROVIDERS[provider]?.format === FORMATS.OPENAI_RESPONSES;
const needsCodexTranslation = isResponsesProvider && targetFormat === FORMATS.OPENAI_RESPONSES && !isDroidCLI;
if (needsCodexTranslation) {
// Codex returns Responses API SSE → translate to client format
let codexTarget;
if (sourceFormat === FORMATS.OPENAI_RESPONSES) codexTarget = FORMATS.OPENAI_RESPONSES;
else if (sourceFormat === FORMATS.CLAUDE) codexTarget = FORMATS.CLAUDE;
else if (sourceFormat === FORMATS.ANTIGRAVITY || sourceFormat === FORMATS.GEMINI || sourceFormat === FORMATS.GEMINI_CLI) codexTarget = FORMATS.ANTIGRAVITY;
else codexTarget = FORMATS.OPENAI;
const codexTarget = CODEX_SOURCE_TO_TARGET[sourceFormat] || FORMATS.OPENAI;
return createSSETransformStreamWithLogger(FORMATS.OPENAI_RESPONSES, codexTarget, provider, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey);
}
@@ -43,7 +44,21 @@ function buildTransformStream({ provider, sourceFormat, targetFormat, userAgent,
* Handle streaming response — pipe provider SSE through transform stream to client.
*/
export function handleStreamingResponse({ providerResponse, provider, model, sourceFormat, targetFormat, userAgent, body, stream, translatedBody, finalBody, requestStartTime, connectionId, apiKey, clientRawRequest, onRequestSuccess, reqLogger, toolNameMap, streamController, onStreamComplete }) {
if (onRequestSuccess) onRequestSuccess();
if (onRequestSuccess) {
Promise.resolve()
.then(onRequestSuccess)
.catch(err => {
console.error("[ChatCore] onRequestSuccess failed:", err?.message || err);
});
}
// Warn when upstream returns unexpected Content-Type for a streaming response.
// This often means the provider returned an HTML error page or plain-text error
// that the SSE transform stream would forward as garbage to the client.
const upstreamContentType = (providerResponse.headers.get('content-type') || '').toLowerCase();
if (upstreamContentType && !upstreamContentType.includes('text/event-stream') && !upstreamContentType.includes('application/json')) {
console.warn('[STREAM] ' + provider + ' | ' + model + ' | unexpected Content-Type: ' + upstreamContentType);
}
const transformStream = buildTransformStream({ provider, sourceFormat, targetFormat, userAgent, reqLogger, toolNameMap, model, connectionId, body, onStreamComplete, apiKey });

View File

@@ -1,30 +1,21 @@
// OpenAI-compatible embeddings adapter (most providers)
import { bearerAuth } from "./_base.js";
import { PROVIDER_MEDIA } from "../../providers/index.js";
// media-only providers without a registry file keep URL here; rest derive from registry media.embeddingConfig.baseUrl
const ENDPOINTS = {
openai: "https://api.openai.com/v1/embeddings",
openrouter: "https://openrouter.ai/api/v1/embeddings",
mistral: "https://api.mistral.ai/v1/embeddings",
"voyage-ai": "https://api.voyageai.com/v1/embeddings",
fireworks: "https://api.fireworks.ai/inference/v1/embeddings",
together: "https://api.together.xyz/v1/embeddings",
nebius: "https://api.tokenfactory.nebius.com/v1/embeddings",
github: "https://models.github.ai/inference/embeddings",
nvidia: "https://integrate.api.nvidia.com/v1/embeddings",
"jina-ai": "https://api.jina.ai/v1/embeddings",
"vercel-ai-gateway": "https://ai-gateway.vercel.sh/v1/embeddings",
};
const embedCfg = (id) => PROVIDER_MEDIA[id]?.embeddingConfig || {};
const embedUrl = (id) => embedCfg(id).baseUrl || ENDPOINTS[id];
export default function createOpenAIEmbeddingAdapter(providerId) {
const cfg = embedCfg(providerId);
return {
buildUrl: () => ENDPOINTS[providerId],
buildUrl: () => embedUrl(providerId),
buildHeaders: (creds) => {
const headers = { "Content-Type": "application/json", ...bearerAuth(creds) };
if (providerId === "openrouter") {
headers["HTTP-Referer"] = "https://endpoint-proxy.local";
headers["X-Title"] = "Endpoint Proxy";
}
return headers;
return { "Content-Type": "application/json", ...bearerAuth(creds), ...(cfg.headers || {}) };
},
buildBody: (model, { input, encoding_format, dimensions }) => {
const body = { model, input };

View File

@@ -50,6 +50,47 @@ export async function handleImageGenerationCore({
);
}
// Executor-delegating adapters: skip manual URL/headers/body, use the proven executor flow
if (adapter.useExecutor && adapter.executeViaExecutor) {
try {
log?.debug?.("IMAGE", `${provider.toUpperCase()} | ${model} | prompt="${body.prompt.slice(0, 50)}..." (executor)`);
const responseBody = await adapter.executeViaExecutor(model, body, credentials, log);
if (onRequestSuccess) await onRequestSuccess();
const normalized = adapter.normalize(responseBody, body.prompt);
const finalBody = (normalized.created && Array.isArray(normalized.data)) ? normalized : responseBody;
if (binaryOutput) {
const first = finalBody.data?.[0];
let b64 = first?.b64_json;
if (!b64 && first?.url) {
try { b64 = await urlToBase64(first.url); } catch {}
}
if (b64) {
const buf = Buffer.from(b64, "base64");
const fmt = (body.output_format || "png").toLowerCase();
const mime = fmt === "jpeg" || fmt === "jpg" ? "image/jpeg" : fmt === "webp" ? "image/webp" : "image/png";
return {
success: true,
response: new Response(buf, {
headers: { "Content-Type": mime, "Content-Disposition": `inline; filename="image.${fmt === "jpeg" ? "jpg" : fmt}"`, "Access-Control-Allow-Origin": "*" },
}),
};
}
}
return {
success: true,
response: new Response(JSON.stringify(finalBody), {
headers: { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" },
}),
};
} catch (error) {
const errMsg = formatProviderError(error, provider, model, HTTP_STATUS.BAD_GATEWAY);
log?.debug?.("IMAGE", `Executor error: ${errMsg}`);
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, errMsg);
}
}
let url;
let headers;
let requestBody;

View File

@@ -0,0 +1,73 @@
// Antigravity image adapter - delegates to the executor for correct request
// envelope (project, model, requestType, sessionId) and auth headers.
import { nowSec } from "./_base.js";
import { getExecutor } from "../../executors/index.js";
// Convert image input (data URI or raw base64) to Gemini inlineData part
function resolveImageInput(input) {
if (!input || typeof input !== "string") return null;
// data:image/png;base64,... format
const dataUriMatch = input.match(/^data:(image\/[^;]+);base64,(.+)$/);
if (dataUriMatch) {
return { inlineData: { mimeType: dataUriMatch[1], data: dataUriMatch[2] } };
}
// Raw base64 string (assume PNG)
if (/^[A-Za-z0-9+/]/.test(input) && input.length > 100 && !input.startsWith("http")) {
return { inlineData: { mimeType: "image/png", data: input } };
}
return null;
}
export default {
// Delegate to executor instead of building URL/headers/body manually
useExecutor: true,
// Stubs - required by imageGenerationCore interface but unused with useExecutor
buildUrl: () => "",
buildHeaders: () => ({}),
buildBody: () => ({}),
async executeViaExecutor(model, body, credentials, log) {
const executor = getExecutor("antigravity");
if (!executor) throw new Error("Antigravity executor not found");
// Build parts: text prompt + optional input image for editing
const parts = [{ text: body.prompt }];
const imageInput = body.image || (Array.isArray(body.images) && body.images[0]);
if (imageInput) {
const inlineData = resolveImageInput(imageInput);
if (inlineData) parts.unshift(inlineData);
}
const chatBody = {
contents: [{ role: "user", parts }],
};
const result = await executor.execute({
model,
body: chatBody,
stream: false,
credentials,
log,
});
if (!result.response.ok) {
const text = await result.response.text();
throw new Error(text || `HTTP ${result.response.status}`);
}
return result.response.json();
},
normalize: (responseBody, prompt) => {
const candidates = responseBody.candidates || responseBody.response?.candidates || [];
const parts = candidates[0]?.content?.parts || [];
const images = parts.filter((p) => p.inlineData?.data).map((p) => ({
b64_json: p.inlineData.data,
}));
return {
created: nowSec(),
data: images.length > 0 ? images : [{ b64_json: "", revised_prompt: prompt }],
};
},
};

View File

@@ -1,7 +1,8 @@
// Black Forest Labs (FLUX) — async submit + polling_url
import { sleep, nowSec, POLL_INTERVAL_MS, POLL_TIMEOUT_MS } from "./_base.js";
import { PROVIDER_MEDIA } from "../../providers/index.js";
const BASE_URL = "https://api.bfl.ai/v1";
const BASE_URL = PROVIDER_MEDIA["black-forest-labs"]?.imageConfig?.baseUrl;
export default {
async: true,

View File

@@ -1,6 +1,7 @@
import { nowSec, urlToBase64 } from "./_base.js";
import { PROVIDER_MEDIA } from "../../providers/index.js";
const BASE_URL = "https://api.cloudflare.com/client/v4/accounts";
const BASE_URL = PROVIDER_MEDIA["cloudflare-ai"]?.imageConfig?.baseUrl;
const MULTIPART_MODELS = new Set([
"@cf/black-forest-labs/flux-2-dev",

View File

@@ -1,8 +1,9 @@
// Codex (ChatGPT Plus/Pro) image generation via Responses API + SSE
import { randomUUID } from "node:crypto";
import { nowSec } from "./_base.js";
import { PROVIDERS } from "../../config/providers.js";
const CODEX_RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses";
const CODEX_RESPONSES_URL = PROVIDERS["codex"].baseUrl;
const CODEX_USER_AGENT = "codex_cli_rs/0.136.0";
const CODEX_VERSION = "0.136.0";
const CODEX_ORIGINATOR = "codex_cli_rs";

View File

@@ -1,7 +1,11 @@
// ComfyUI — local, noAuth (placeholder; full graph workflow not implemented)
import { PROVIDER_MEDIA } from "../../providers/index.js";
const BASE_URL = PROVIDER_MEDIA["comfyui"]?.imageConfig?.baseUrl;
export default {
noAuth: true,
buildUrl: () => "http://localhost:8188",
buildUrl: () => BASE_URL,
buildHeaders: () => ({ "Content-Type": "application/json" }),
buildBody: (_model, body) => ({ prompt: body.prompt }),
normalize: (responseBody) => responseBody,

View File

@@ -1,7 +1,8 @@
// Fal.ai — async submit + queue polling
import { sleep, nowSec, sizeToAspectRatio, POLL_INTERVAL_MS, POLL_TIMEOUT_MS } from "./_base.js";
import { PROVIDER_MEDIA } from "../../providers/index.js";
const BASE_URL = "https://queue.fal.run";
const BASE_URL = PROVIDER_MEDIA["fal-ai"]?.imageConfig?.baseUrl;
export default {
async: true,

View File

@@ -1,7 +1,8 @@
// Google Gemini adapter (Nano Banana models)
import { nowSec } from "./_base.js";
import { PROVIDER_MEDIA } from "../../providers/index.js";
const BASE_URL = "https://generativelanguage.googleapis.com/v1beta/models";
const BASE_URL = PROVIDER_MEDIA["gemini"]?.imageConfig?.baseUrl;
export default {
buildUrl: (model, creds) => {

View File

@@ -1,7 +1,8 @@
// HuggingFace Inference API — returns binary image
import { nowSec } from "./_base.js";
import { PROVIDER_MEDIA } from "../../providers/index.js";
const BASE_URL = "https://api-inference.huggingface.co/models";
const BASE_URL = PROVIDER_MEDIA["huggingface"]?.imageConfig?.baseUrl;
export default {
buildUrl: (model) => `${BASE_URL}/${model}`,

View File

@@ -11,6 +11,7 @@ import stabilityAi from "./stabilityAi.js";
import blackForestLabs from "./blackForestLabs.js";
import runwayml from "./runwayml.js";
import cloudflareAi from "./cloudflareAi.js";
import antigravity from "./antigravity.js";
const ADAPTERS = {
openai: createOpenAIAdapter("openai"),
@@ -25,6 +26,7 @@ const ADAPTERS = {
comfyui,
huggingface,
nanobanana,
antigravity,
"fal-ai": falAi,
"stability-ai": stabilityAi,
"black-forest-labs": blackForestLabs,

View File

@@ -1,8 +1,10 @@
// NanoBanana API — async submit + poll record-info
import { sleep, nowSec, sizeToAspectRatio, POLL_INTERVAL_MS, POLL_TIMEOUT_MS } from "./_base.js";
import { PROVIDER_MEDIA } from "../../providers/index.js";
const SUBMIT_URL = "https://api.nanobananaapi.ai/api/v1/nanobanana/generate";
const POLL_BASE = "https://api.nanobananaapi.ai/api/v1/nanobanana/record-info";
const IMG_CFG = PROVIDER_MEDIA["nanobanana"]?.imageConfig || {};
const SUBMIT_URL = IMG_CFG.baseUrl;
const POLL_BASE = IMG_CFG.pollUrl;
export default {
async: true,

View File

@@ -1,40 +1,32 @@
// OpenAI-compatible adapter (used by openai, minimax, openrouter, recraft)
import { PROVIDER_MEDIA } from "../../providers/index.js";
const ENDPOINTS = {
openai: "https://api.openai.com/v1/images/generations",
minimax: "https://api.minimaxi.com/v1/images/generations",
openrouter: "https://openrouter.ai/api/v1/images/generations",
recraft: "https://external.api.recraft.ai/v1/images/generations",
"vercel-ai-gateway": "https://ai-gateway.vercel.sh/v1/images/generations",
xai: "https://api.x.ai/v1/images/generations",
};
const imageCfg = (id) => PROVIDER_MEDIA[id]?.imageConfig || {};
const imageUrl = (id) => imageCfg(id).baseUrl;
export default function createOpenAIAdapter(providerId) {
const cfg = imageCfg(providerId);
return {
buildUrl: () => ENDPOINTS[providerId],
buildUrl: () => imageUrl(providerId),
buildHeaders: (creds) => {
const headers = { "Content-Type": "application/json" };
const headers = { "Content-Type": "application/json", ...(cfg.headers || {}) };
const key = creds?.apiKey || creds?.accessToken;
if (key) headers["Authorization"] = `Bearer ${key}`;
if (providerId === "openrouter") {
headers["HTTP-Referer"] = "https://endpoint-proxy.local";
headers["X-Title"] = "Endpoint Proxy";
}
return headers;
},
buildBody: (model, body) => {
const { prompt, n = 1, size = "1024x1024", quality, style, response_format } = body;
// xAI only accepts prompt, model, n, response_format
if (providerId === "xai") {
const req = { model, prompt, n };
if (response_format) req.response_format = response_format;
const full = { model, prompt, n, size };
if (quality) full.quality = quality;
if (style) full.style = style;
if (response_format) full.response_format = response_format;
// bodyFields whitelist (e.g. xAI accepts only model/prompt/n/response_format)
if (Array.isArray(cfg.bodyFields)) {
const req = {};
for (const f of cfg.bodyFields) if (full[f] !== undefined) req[f] = full[f];
return req;
}
const req = { model, prompt, n, size };
if (quality) req.quality = quality;
if (style) req.style = style;
if (response_format) req.response_format = response_format;
return req;
return full;
},
normalize: (responseBody) => responseBody,
};

View File

@@ -1,7 +1,8 @@
// Runway ML — async submit + /tasks/{id} polling
import { sleep, nowSec, sizeToAspectRatio, POLL_INTERVAL_MS, POLL_TIMEOUT_MS } from "./_base.js";
import { PROVIDER_MEDIA } from "../../providers/index.js";
const BASE_URL = "https://api.dev.runwayml.com/v1";
const BASE_URL = PROVIDER_MEDIA["runwayml"]?.imageConfig?.baseUrl;
export default {
async: true,

View File

@@ -1,9 +1,12 @@
// SD WebUI (AUTOMATIC1111) — local, noAuth
import { nowSec } from "./_base.js";
import { PROVIDER_MEDIA } from "../../providers/index.js";
const BASE_URL = PROVIDER_MEDIA["sdwebui"]?.imageConfig?.baseUrl;
export default {
noAuth: true,
buildUrl: () => "http://localhost:7860/sdapi/v1/txt2img",
buildUrl: () => BASE_URL,
buildHeaders: () => ({ "Content-Type": "application/json" }),
buildBody: (_model, body) => {
const { prompt, n = 1, size = "1024x1024" } = body;

View File

@@ -1,7 +1,8 @@
// Stability AI v2 — sync, returns { image: "<b64>" }
import { nowSec, sizeToAspectRatio } from "./_base.js";
import { PROVIDER_MEDIA } from "../../providers/index.js";
const BASE_URL = "https://api.stability.ai/v2beta/stable-image/generate";
const BASE_URL = PROVIDER_MEDIA["stability-ai"]?.imageConfig?.baseUrl;
// Map model id → endpoint segment
function modelToEndpoint(model) {

View File

@@ -4,9 +4,10 @@
*/
import { handleChatCore } from "./chatCore.js";
import { convertResponsesApiFormat } from "../translator/helpers/responsesApiHelper.js";
import { convertResponsesApiFormat } from "../translator/formats/responsesApi.js";
import { createResponsesApiTransformStream } from "../transformer/responsesTransformer.js";
import { convertResponsesStreamToJson } from "../transformer/streamToJsonConverter.js";
import { SSE_HEADERS_CORS } from "../utils/sseConstants.js";
/**
* Handle /v1/responses request
@@ -87,12 +88,7 @@ export async function handleResponsesCore({ body, modelInfo, credentials, log, o
success: true,
response: new Response(transformedBody, {
status: 200,
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"Access-Control-Allow-Origin": "*"
}
headers: { ...SSE_HEADERS_CORS }
})
};
}

View File

@@ -2,6 +2,12 @@
* Wrap chat-completions endpoints (with built-in web search) into the unified
* /v1/search response format. Supports gemini, openai, xai, kimi, minimax, perplexity.
*/
import { PROVIDER_MEDIA } from "../../providers/index.js";
// Default search model + endpoint derive from registry searchViaChat (single source)
const searchModel = (id) => PROVIDER_MEDIA[id]?.searchViaChat?.defaultModel;
const searchEndpoint = (id, model) =>
(PROVIDER_MEDIA[id]?.searchViaChat?.endpoint || "").replace("{model}", model || "");
const REQUEST_TIMEOUT_MS = 15000;
const DEFAULT_MAX_RESULTS = 10;
@@ -43,9 +49,7 @@ function normalizeCitation(c) {
*/
const CHAT_SEARCH_CONFIG = {
gemini: {
endpoint: (model) =>
`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`,
defaultModel: "gemini-2.5-flash",
endpoint: (model) => searchEndpoint("gemini", model),
buildBody: (query) => ({
contents: [{ role: "user", parts: [{ text: query }] }],
tools: [{ google_search: {} }]
@@ -70,8 +74,7 @@ const CHAT_SEARCH_CONFIG = {
},
openai: {
endpoint: () => "https://api.openai.com/v1/chat/completions",
defaultModel: "gpt-4o-mini",
endpoint: () => searchEndpoint("openai"),
buildBody: (query, model) => {
const body = {
model,
@@ -105,8 +108,7 @@ const CHAT_SEARCH_CONFIG = {
},
xai: {
endpoint: () => "https://api.x.ai/v1/responses",
defaultModel: "grok-4.20-reasoning",
endpoint: () => searchEndpoint("xai"),
buildBody: (query, model) => ({
model,
input: [{ role: "user", content: query }],
@@ -145,8 +147,7 @@ const CHAT_SEARCH_CONFIG = {
},
kimi: {
endpoint: () => "https://api.moonshot.cn/v1/chat/completions",
defaultModel: "kimi-k2.5",
endpoint: () => searchEndpoint("kimi"),
buildBody: (query, model) => ({
model,
messages: [{ role: "user", content: query }],
@@ -195,8 +196,7 @@ const CHAT_SEARCH_CONFIG = {
},
minimax: {
endpoint: () => "https://api.minimaxi.com/v1/text/chatcompletion_v2",
defaultModel: "MiniMax-M2.7",
endpoint: () => searchEndpoint("minimax"),
buildBody: (query, model) => ({
model,
messages: [{ role: "user", content: query }],
@@ -254,8 +254,7 @@ const CHAT_SEARCH_CONFIG = {
},
perplexity: {
endpoint: () => "https://api.perplexity.ai/chat/completions",
defaultModel: "sonar",
endpoint: () => searchEndpoint("perplexity"),
buildBody: (query, model) => ({
model,
messages: [{ role: "user", content: query }]
@@ -324,7 +323,7 @@ export async function handleChatSearch({
Number.isFinite(maxResults) && maxResults > 0
? Math.floor(maxResults)
: DEFAULT_MAX_RESULTS;
const useModel = model || cfg.defaultModel;
const useModel = model || searchModel(provider);
const url = cfg.endpoint(useModel);
const body = cfg.buildBody(query, useModel);
const headers = cfg.buildHeaders(token);

View File

@@ -1,7 +1,6 @@
import { Buffer } from "node:buffer";
import { createErrorResult } from "../utils/error.js";
import { HTTP_STATUS } from "../config/runtimeConfig.js";
import { AI_PROVIDERS } from "../../src/shared/constants/providers.js";
// Build auth headers from sttConfig + token
function buildAuthHeaders(cfg, token) {
@@ -167,11 +166,11 @@ function jsonResponse(obj) {
* STT core handler — dispatch by sttConfig.format.
* @returns {Promise<{success, response, status?, error?}>}
*/
export async function handleSttCore({ provider, model, formData, credentials }) {
export async function handleSttCore({ provider, model, formData, credentials, sttConfig }) {
const file = formData.get("file");
if (!file) return createErrorResult(HTTP_STATUS.BAD_REQUEST, "Missing required field: file");
const cfg = AI_PROVIDERS[provider]?.sttConfig;
const cfg = sttConfig;
if (!cfg) return createErrorResult(HTTP_STATUS.BAD_REQUEST, `Provider '${provider}' does not support STT`);
const token = cfg.authType === "none" ? null : (credentials?.apiKey || credentials?.accessToken);

View File

@@ -1,9 +1,20 @@
// Gemini TTS — generateContent with AUDIO modality returns PCM L16, wrap as WAV
import { Buffer } from "node:buffer";
import { PROVIDER_MEDIA, PROVIDER_MODELS } from "../../providers/index.js";
const DEFAULT_MODEL = "gemini-2.5-flash-preview-tts";
const TTS_CFG = PROVIDER_MEDIA["gemini"]?.ttsConfig || {};
const TTS_BASE = TTS_CFG.baseUrl;
const FALLBACK_MODEL = "gemini-3.1-flash-tts-preview";
const KNOWN_MODELS = [
...(TTS_CFG.models || []),
...(PROVIDER_MODELS["gemini-tts-models"] || []),
...(PROVIDER_MODELS.gemini || []).filter((m) => (m.kind || m.type) === "tts"),
]
.map((m) => m?.id)
.filter(Boolean)
.filter((id, index, list) => list.indexOf(id) === index);
const DEFAULT_MODEL = KNOWN_MODELS[0] || FALLBACK_MODEL;
const DEFAULT_VOICE = "Kore";
const KNOWN_MODELS = ["gemini-2.5-flash-preview-tts", "gemini-2.5-pro-preview-tts"];
// Parse "model/voice" — if input doesn't match a known TTS model, treat it as voice with default model
function parseGeminiModelVoice(input) {
@@ -51,7 +62,7 @@ export default {
async synthesize(text, model, credentials, _responseFormat, opts = {}) {
if (!credentials?.apiKey) throw new Error("No Gemini API key configured");
const { modelId, voiceId } = parseGeminiModelVoice(model);
const url = `https://generativelanguage.googleapis.com/v1beta/models/${modelId}:generateContent?key=${credentials.apiKey}`;
const url = `${TTS_BASE}/${modelId}:generateContent?key=${credentials.apiKey}`;
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },

View File

@@ -33,8 +33,10 @@ export async function synthesizeViaConfig(provider, text, model, credentials) {
if (!handler) return null;
const apiKey = credentials?.apiKey;
if (cfg.authType !== "none" && !apiKey) throw new Error(`${provider} API key required`);
const defaultModel = cfg.models?.[0]?.id || "";
const { modelId, voiceId } = parseModelVoice(model, defaultModel, "", cfg.models || []);
const { PROVIDER_MODELS } = await import("open-sse/config/providerModels.js");
const ttsModels = (PROVIDER_MODELS[provider] || []).filter(m => (m.kind || m.type) === "tts");
const defaultModel = ttsModels[0]?.id || "";
const { modelId, voiceId } = parseModelVoice(model, defaultModel, "", ttsModels);
return handler({ baseUrl: cfg.baseUrl, apiKey, text, modelId, voiceId });
}

View File

@@ -1,11 +1,14 @@
// OpenAI TTS — model format: "tts-model/voice"
import { Buffer } from "node:buffer";
import { PROVIDER_MEDIA } from "../../providers/index.js";
const DEFAULT_TTS_MODEL = PROVIDER_MEDIA["openai"]?.ttsConfig?.defaultModel;
export default {
async synthesize(text, model, credentials) {
if (!credentials?.apiKey) throw new Error("No OpenAI API key configured");
let ttsModel = "gpt-4o-mini-tts";
let ttsModel = DEFAULT_TTS_MODEL;
let voice = "alloy";
if (model && model.includes("/")) {
const parts = model.split("/");

View File

@@ -1,10 +1,14 @@
// OpenRouter TTS — via chat completions + audio modality (SSE stream)
import { PROVIDER_MEDIA } from "../../providers/index.js";
const TTS_CFG = PROVIDER_MEDIA["openrouter"]?.ttsConfig || {};
export default {
async synthesize(text, model, credentials) {
if (!credentials?.apiKey) throw new Error("No OpenRouter API key configured");
// model format: "tts-model/voice" e.g. "openai/gpt-4o-mini-tts/alloy"
let ttsModel = "openai/gpt-4o-mini-tts";
let ttsModel = TTS_CFG.defaultModel;
let voice = "alloy";
if (model && model.includes("/")) {
const lastSlash = model.lastIndexOf("/");
@@ -20,13 +24,12 @@ export default {
voice = model;
}
const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
const res = await fetch(TTS_CFG.baseUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${credentials.apiKey}`,
"HTTP-Referer": "https://endpoint-proxy.local",
"X-Title": "Endpoint Proxy",
...(TTS_CFG.headers || {}),
},
body: JSON.stringify({
model: ttsModel,