Merge branch 'master' of https://github.com/decolua/9router
Some checks failed
Deploy GitBook to 9router.github.io / build-deploy (push) Has been cancelled
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:
@@ -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(() => { });
|
||||
|
||||
@@ -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\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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
73
open-sse/handlers/imageProviders/antigravity.js
Normal file
73
open-sse/handlers/imageProviders/antigravity.js
Normal 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 }],
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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("/");
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user