Merge remote-tracking branch 'upstream/master'
# Conflicts: # open-sse/handlers/chatCore.js # open-sse/providers/registry/index.js # open-sse/services/usage.js
This commit is contained in:
commit
eaf204b83c
114 files changed
+5587
-2184
No files matched your search
@@ -28,6 +28,7 @@ import { compressWithPxpipe } from "../rtk/pxpipe.js";
|
||||
import { getCapabilitiesForModel } from "../providers/capabilities.js";
|
||||
import { stripUnsupportedModalities } from "../translator/concerns/modality.js";
|
||||
import { prefetchRemoteImages } from "../translator/concerns/prefetch.js";
|
||||
import { defaultClaudeToolType } from "../translator/concerns/toolCall.js";
|
||||
import { resolveSessionId } from "../utils/sessionManager.js";
|
||||
import { markPoolUnfit, clearPoolUnfit } from "../services/proxyPoolFitness.js";
|
||||
|
||||
@@ -63,7 +64,7 @@ export function stripContinuityFields(body) {
|
||||
return body;
|
||||
}
|
||||
|
||||
export async function handleChatCore({ body, modelInfo, credentials, log, onCredentialsRefreshed, onRequestSuccess, onDisconnect, clientRawRequest, connectionId, userAgent, apiKey, ccFilterNaming, rtkEnabled, headroomEnabled, headroomUrl, headroomCompressUserMessages, cavemanEnabled, cavemanLevel, ponytailEnabled, ponytailLevel, pxpipeEnabled, pxpipeMinChars, pxpipeTimeoutMs, pxpipeTransform, onPxpipeEvent, sourceFormatOverride, providerThinking, resolveProxyConfig }) {
|
||||
export async function handleChatCore({ body, modelInfo, credentials, log, onCredentialsRefreshed, onRequestSuccess, onDisconnect, clientRawRequest, connectionId, userAgent, apiKey, ccFilterNaming, rtkEnabled, headroomEnabled, headroomUrl, headroomCompressUserMessages, headroomTimeoutMs, cavemanEnabled, cavemanLevel, ponytailEnabled, ponytailLevel, pxpipeEnabled, pxpipeMinChars, pxpipeTimeoutMs, pxpipeTransform, onPxpipeEvent, sourceFormatOverride, providerThinking, resolveProxyConfig }) {
|
||||
const { provider, model } = modelInfo;
|
||||
const requestStartTime = Date.now();
|
||||
// Stable per-session color so all lines of one CLI conversation share a tag
|
||||
@@ -96,7 +97,12 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
||||
// differ — kimi/glm only do /chat/completions). Undeclared models keep the
|
||||
// upstream default (use the transport), preserving behavior for glm/deepseek/...
|
||||
const useTransport = (!modelSupportedFormats || modelSupportedFormats.includes(sourceFormat)) ? runtimeTransport : null;
|
||||
const targetFormat = modelTargetFormat || useTransport?.format || getTargetFormat(provider, credentials);
|
||||
// A source-format-matched endpoint keeps the request lossless. Prefer it
|
||||
// over a model-level targetFormat, which is only the fallback for clients
|
||||
// whose wire format has no supported transport (for example MiniMax-M3:
|
||||
// OpenAI clients should stay on /chat/completions; other clients can fall
|
||||
// back to its declared Claude target).
|
||||
const targetFormat = useTransport?.format || modelTargetFormat || getTargetFormat(provider, credentials);
|
||||
if (useTransport && credentials) credentials.runtimeTransport = useTransport;
|
||||
const stripList = getModelStrip(alias, model);
|
||||
const upstreamModel = getModelUpstreamId(alias, model);
|
||||
@@ -241,6 +247,12 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
||||
delete translatedBody.tools;
|
||||
}
|
||||
|
||||
// Claude tool schema requires `type` to be explicitly set; strict gateways (e.g., MiniMax)
|
||||
// reject legacy payloads that omit it with HTTP 400. Default to "custom" when missing.
|
||||
if (finalFormat === FORMATS.CLAUDE && Array.isArray(translatedBody.tools)) {
|
||||
translatedBody.tools = defaultClaudeToolType(translatedBody.tools);
|
||||
}
|
||||
|
||||
// Per-request opt-out: client can bypass all token savers via header
|
||||
const tokenSaverEnabled = clientRawRequest?.headers?.[TOKEN_SAVER_HEADER]?.toLowerCase() !== "off";
|
||||
|
||||
@@ -251,7 +263,7 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred
|
||||
|
||||
// Headroom: optional external proxy compression; fail open if proxy is absent.
|
||||
const headroomDiagnostics = {};
|
||||
const headroomStats = await compressWithHeadroom(translatedBody, { enabled: tokenSaverEnabled && headroomEnabled, url: headroomUrl, model: upstreamModel, format: finalFormat, compressUserMessages: headroomCompressUserMessages, diagnostics: headroomDiagnostics });
|
||||
const headroomStats = await compressWithHeadroom(translatedBody, { enabled: tokenSaverEnabled && headroomEnabled, url: headroomUrl, model: upstreamModel, format: finalFormat, compressUserMessages: headroomCompressUserMessages, timeoutMs: headroomTimeoutMs, diagnostics: headroomDiagnostics });
|
||||
const headroomLine = formatHeadroomLog(headroomStats);
|
||||
const headroomSizeLine = formatHeadroomSizeLog(headroomDiagnostics);
|
||||
if (headroomLine) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Antigravity image adapter - delegates to the executor for correct request
|
||||
// envelope (project, model, requestType, sessionId) and auth headers.
|
||||
import { nowSec } from "./_base.js";
|
||||
import { nowSec, sizeToAspectRatio } from "./_base.js";
|
||||
import { getExecutor } from "../../executors/index.js";
|
||||
|
||||
// Convert image input (data URI or raw base64) to Gemini inlineData part
|
||||
@@ -31,6 +31,19 @@ export default {
|
||||
const executor = getExecutor("antigravity");
|
||||
if (!executor) throw new Error("Antigravity executor not found");
|
||||
|
||||
// Ensure we use an image model for image generation
|
||||
const isImageModel = (m) => /image|imagen|image-generation/i.test(m || "");
|
||||
let targetModel = isImageModel(model) ? model : "gemini-3.1-flash-image";
|
||||
|
||||
// If body.size is provided, resolve aspect ratio and append to model
|
||||
if (body.size && typeof body.size === "string") {
|
||||
const ratio = sizeToAspectRatio(body.size);
|
||||
const suffix = ratio.replace(":", "x");
|
||||
if (!targetModel.includes(suffix)) {
|
||||
targetModel = `${targetModel}-${suffix}`;
|
||||
}
|
||||
}
|
||||
|
||||
// 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]);
|
||||
@@ -44,7 +57,7 @@ export default {
|
||||
};
|
||||
|
||||
const result = await executor.execute({
|
||||
model,
|
||||
model: targetModel,
|
||||
body: chatBody,
|
||||
stream: false,
|
||||
credentials,
|
||||
|
||||
@@ -347,6 +347,81 @@ function buildSearxngRequest(config, params) {
|
||||
};
|
||||
}
|
||||
|
||||
function buildXquikRequest(config, params) {
|
||||
const apiKey = params.token;
|
||||
if (!apiKey) throw new Error("Xquik requires an API key");
|
||||
|
||||
const queryType = getProviderSetting(params, "queryType");
|
||||
if (queryType && !["Latest", "Top"].includes(queryType)) {
|
||||
throw new Error("Xquik queryType must be Latest or Top");
|
||||
}
|
||||
|
||||
const qp = new URLSearchParams({
|
||||
q: params.query,
|
||||
limit: String(params.maxResults),
|
||||
});
|
||||
const cursor = getProviderSetting(params, "cursor");
|
||||
if (cursor) qp.set("cursor", cursor);
|
||||
if (queryType) qp.set("queryType", queryType);
|
||||
if (params.language) qp.set("language", params.language);
|
||||
|
||||
return {
|
||||
url: `${resolveBaseUrl(config, params)}?${qp}`,
|
||||
init: {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json", "x-api-key": apiKey },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── Ollama Cloud web_search ──────────────────────────────────────────────
|
||||
// POST https://ollama.com/api/web_search { query, max_results }
|
||||
// Response: { results: [{ title, url, content, published_at? }] }
|
||||
function buildOllamaSearchRequest(config, params) {
|
||||
const body = { query: params.query, max_results: params.maxResults };
|
||||
if (params.country) body.country = params.country;
|
||||
if (params.language) body.language = params.language;
|
||||
return {
|
||||
url: resolveBaseUrl(config, params),
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(params.token ? { Authorization: `Bearer ${params.token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── GLM Coding plan MCP web_search_prime ──────────────────────────────────
|
||||
// POST https://api.z.ai/api/mcp/web_search_prime/mcp
|
||||
// JSON-RPC envelope: { jsonrpc, id, method: "tools/call",
|
||||
// params: { name: "web_search_prime", arguments: { search_query, count } } }
|
||||
// Response: { result: { content: [{ type: "text", text: "<json>" }] } }
|
||||
function buildGlmSearchRequest(config, params) {
|
||||
const body = {
|
||||
jsonrpc: "2.0",
|
||||
id: `9r-${Date.now()}`,
|
||||
method: "tools/call",
|
||||
params: {
|
||||
name: "web_search_prime",
|
||||
arguments: { search_query: params.query, count: params.maxResults },
|
||||
},
|
||||
};
|
||||
return {
|
||||
url: resolveBaseUrl(config, params),
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(params.token ? { Authorization: `Bearer ${params.token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── Dispatcher ──────────────────────────────────────────────────────────
|
||||
|
||||
const BUILDERS = {
|
||||
@@ -360,6 +435,9 @@ const BUILDERS = {
|
||||
"searchapi": buildSearchApiRequest,
|
||||
"youcom": buildYouComRequest,
|
||||
"searxng": buildSearxngRequest,
|
||||
"xquik": buildXquikRequest,
|
||||
"ollama-search": buildOllamaSearchRequest,
|
||||
"glm": buildGlmSearchRequest,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
/**
|
||||
* Wrap chat-completions endpoints (with built-in web search) into the unified
|
||||
* /v1/search response format. Supports gemini, openai, xai, kimi, minimax, perplexity.
|
||||
* /v1/search response format. Supports gemini, antigravity, openai, xai, kimi,
|
||||
* minimax, perplexity.
|
||||
*/
|
||||
import { PROVIDER_MEDIA } from "../../providers/index.js";
|
||||
import { ANTIGRAVITY_IDE_USER_AGENT } from "../../providers/shared.js";
|
||||
|
||||
// Default search model + endpoint derive from registry searchViaChat (single source)
|
||||
const searchModel = (id) => PROVIDER_MEDIA[id]?.searchViaChat?.defaultModel;
|
||||
@@ -28,13 +30,37 @@ function toResult(c, index, provider, retrievedAt) {
|
||||
score: null,
|
||||
published_at: null,
|
||||
favicon_url: null,
|
||||
content: null,
|
||||
content: c.content || null,
|
||||
metadata: {},
|
||||
citation: { provider, retrieved_at: retrievedAt, rank: index + 1 },
|
||||
provider_raw: null
|
||||
};
|
||||
}
|
||||
|
||||
// Antigravity search request envelope (mirrors the IDE client)
|
||||
const AG_CLIENT_NAME = "antigravity";
|
||||
const AG_SEARCH_GENERATION_CONFIG = { temperature: 1.0, maxOutputTokens: 8192 };
|
||||
const AG_CONTEXT_BEFORE = 150;
|
||||
const AG_CONTEXT_AFTER = 250;
|
||||
|
||||
/** Widen a grounded segment to its surrounding sentence(s) in the answer text. */
|
||||
function expandSegment(text, segment) {
|
||||
const { startIndex, endIndex } = segment || {};
|
||||
if (!text || !Number.isInteger(startIndex) || !Number.isInteger(endIndex)) return "";
|
||||
const start = Math.max(0, startIndex - AG_CONTEXT_BEFORE);
|
||||
const end = Math.min(text.length, endIndex + AG_CONTEXT_AFTER);
|
||||
let out = text.slice(start, end).trim();
|
||||
// Drop the partial words the window cut off at either edge
|
||||
if (start > 0) out = `...${out.replace(/^\S+/, "")}`;
|
||||
if (end < text.length) out = `${out.replace(/\S+$/, "")}...`;
|
||||
return out.trim();
|
||||
}
|
||||
|
||||
/** Join deduped grounding pieces, skipping empties. */
|
||||
function joinPieces(set, sep) {
|
||||
return [...(set || [])].filter(Boolean).join(sep).trim();
|
||||
}
|
||||
|
||||
/** Coerce a citation that might be a raw URL string or an object. */
|
||||
function normalizeCitation(c) {
|
||||
if (!c) return null;
|
||||
@@ -46,6 +72,8 @@ function normalizeCitation(c) {
|
||||
/**
|
||||
* Provider-specific configuration map. All providers must implement:
|
||||
* { endpoint, defaultModel, buildBody, buildHeaders, extractAnswer }
|
||||
* Optional: requireCredentials(credentials) → error string when a provider needs
|
||||
* more than a token (returns null when satisfied).
|
||||
*/
|
||||
const CHAT_SEARCH_CONFIG = {
|
||||
gemini: {
|
||||
@@ -73,6 +101,71 @@ const CHAT_SEARCH_CONFIG = {
|
||||
}
|
||||
},
|
||||
|
||||
antigravity: {
|
||||
endpoint: () => searchEndpoint("antigravity"),
|
||||
// Upstream 403s on a missing or fabricated project — surface the real cause
|
||||
requireCredentials: (credentials) =>
|
||||
credentials?.projectId ? null : "Antigravity account has no projectId — reconnect the account",
|
||||
buildBody: (query, model, credentials) => ({
|
||||
project: credentials.projectId,
|
||||
model,
|
||||
userAgent: AG_CLIENT_NAME,
|
||||
requestType: "search",
|
||||
request: {
|
||||
contents: [{ role: "user", parts: [{ text: query }] }],
|
||||
tools: [{ googleSearch: {} }],
|
||||
generationConfig: AG_SEARCH_GENERATION_CONFIG
|
||||
}
|
||||
}),
|
||||
buildHeaders: (token) => ({
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
"User-Agent": ANTIGRAVITY_IDE_USER_AGENT
|
||||
}),
|
||||
extractAnswer: (data) => {
|
||||
// Antigravity wraps the Gemini payload in { response: {...} }
|
||||
const response = data?.response || data;
|
||||
const candidate = response?.candidates?.[0];
|
||||
const parts = candidate?.content?.parts || [];
|
||||
const text = parts.map((p) => p?.text || "").filter(Boolean).join("");
|
||||
const grounding = candidate?.groundingMetadata || {};
|
||||
const chunks = grounding.groundingChunks || [];
|
||||
const supports = grounding.groundingSupports || [];
|
||||
|
||||
// Upstream repeats the same source across chunks — key by URL so it stays one citation.
|
||||
// Map, not a plain object: both the index and the URL come from upstream.
|
||||
const sources = new Map();
|
||||
const byIndex = chunks.map((ch) => {
|
||||
const web = ch?.web;
|
||||
const url = web?.uri || web?.url || "";
|
||||
if (!url) return null;
|
||||
if (!sources.has(url)) sources.set(url, { title: web.title || "", snippets: new Set(), contexts: new Set() });
|
||||
return sources.get(url);
|
||||
});
|
||||
|
||||
// Each support ties a sentence of the answer back to the chunks that grounded it
|
||||
for (const s of supports) {
|
||||
const segment = s?.segment;
|
||||
const grounded = segment?.text || "";
|
||||
const expanded = expandSegment(text, segment) || grounded;
|
||||
for (const idx of s?.groundingChunkIndices || []) {
|
||||
const source = Number.isInteger(idx) ? byIndex[idx] : null;
|
||||
if (!source) continue;
|
||||
if (grounded) source.snippets.add(grounded);
|
||||
if (expanded) source.contexts.add(expanded);
|
||||
}
|
||||
}
|
||||
|
||||
const citations = [...sources].map(([url, src]) => {
|
||||
const snippet = joinPieces(src.snippets, " | ") || src.title;
|
||||
return { url, title: src.title, snippet, content: joinPieces(src.contexts, "\n\n") || snippet };
|
||||
});
|
||||
|
||||
const tokens = response?.usageMetadata?.totalTokenCount || 0;
|
||||
return { text, citations, tokens };
|
||||
}
|
||||
},
|
||||
|
||||
openai: {
|
||||
endpoint: () => searchEndpoint("openai"),
|
||||
buildBody: (query, model) => {
|
||||
@@ -366,13 +459,18 @@ export async function handleChatSearch({
|
||||
};
|
||||
}
|
||||
|
||||
const credentialError = cfg.requireCredentials?.(credentials);
|
||||
if (credentialError) {
|
||||
return { success: false, status: 401, error: credentialError };
|
||||
}
|
||||
|
||||
const limit =
|
||||
Number.isFinite(maxResults) && maxResults > 0
|
||||
? Math.floor(maxResults)
|
||||
: DEFAULT_MAX_RESULTS;
|
||||
const useModel = model || searchModel(provider);
|
||||
const url = cfg.endpoint(useModel);
|
||||
const body = cfg.buildBody(query, useModel);
|
||||
const body = cfg.buildBody(query, useModel, credentials);
|
||||
const headers = cfg.buildHeaders(token);
|
||||
|
||||
const controller = new AbortController();
|
||||
|
||||
@@ -111,6 +111,13 @@ async function tryDedicatedProvider({ provider, providerConfig, body, credential
|
||||
const normalized = normalizeSearchResponse(provider.id, data, params.query, params.searchType);
|
||||
const results = normalized.results.slice(0, params.maxResults);
|
||||
const duration = Date.now() - startTime;
|
||||
const usage = {
|
||||
queries_used: 1,
|
||||
search_cost_usd: providerConfig.costPerQuery ?? null,
|
||||
};
|
||||
if (Number.isFinite(providerConfig.creditsPerResult)) {
|
||||
usage.provider_credits_used = results.length * providerConfig.creditsPerResult;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -119,7 +126,8 @@ async function tryDedicatedProvider({ provider, providerConfig, body, credential
|
||||
query: params.query,
|
||||
results,
|
||||
answer: null,
|
||||
usage: { queries_used: 1, search_cost_usd: providerConfig.costPerQuery || 0 },
|
||||
usage,
|
||||
...(normalized.pagination ? { pagination: normalized.pagination } : {}),
|
||||
metrics: { response_time_ms: duration, upstream_latency_ms: duration, total_results_available: normalized.totalResults },
|
||||
errors: []
|
||||
}
|
||||
|
||||
@@ -199,6 +199,89 @@ function normalizeSearxng(data, _query, _searchType) {
|
||||
return { results, totalResults: results.length };
|
||||
}
|
||||
|
||||
function normalizeXquik(data, _query, _searchType) {
|
||||
const now = new Date().toISOString();
|
||||
const items = Array.isArray(data.tweets) ? data.tweets : [];
|
||||
const results = items.map((item, idx) => {
|
||||
const username = typeof item?.author?.username === "string" ? item.author.username : "";
|
||||
const authorName = typeof item?.author?.name === "string" ? item.author.name : "";
|
||||
const tweetId = typeof item?.id === "string" ? item.id : String(item?.id || "");
|
||||
const url = username && tweetId
|
||||
? `https://x.com/${encodeURIComponent(username)}/status/${encodeURIComponent(tweetId)}`
|
||||
: tweetId
|
||||
? `https://x.com/i/web/status/${encodeURIComponent(tweetId)}`
|
||||
: "";
|
||||
const author = username ? `@${username}` : authorName || null;
|
||||
const title = author ? `${author} on X` : "X post";
|
||||
const imageUrl = Array.isArray(item?.media)
|
||||
? item.media.find((media) => typeof media?.mediaUrl === "string")?.mediaUrl
|
||||
: null;
|
||||
|
||||
return makeResult("xquik", {
|
||||
title,
|
||||
url,
|
||||
snippet: typeof item?.text === "string" ? item.text : "",
|
||||
published_at: typeof item?.createdAt === "string" ? item.createdAt : null,
|
||||
author,
|
||||
image_url: imageUrl || null,
|
||||
source_type: "x_post",
|
||||
full_text: typeof item?.text === "string" ? item.text : undefined,
|
||||
text_format: "text",
|
||||
}, idx, now);
|
||||
});
|
||||
const nextCursor = typeof data.next_cursor === "string" && data.next_cursor ? data.next_cursor : null;
|
||||
return {
|
||||
results,
|
||||
totalResults: null,
|
||||
pagination: {
|
||||
has_more: data.has_next_page === true,
|
||||
next_cursor: nextCursor,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeOllamaSearch(data, _query, _searchType) {
|
||||
const now = new Date().toISOString();
|
||||
const items = Array.isArray(data?.results) ? data.results : (Array.isArray(data) ? data : []);
|
||||
const results = items.map((item, idx) =>
|
||||
makeResult("ollama-search", {
|
||||
title: item.title,
|
||||
url: item.url,
|
||||
snippet: item.content || item.snippet || "",
|
||||
full_text: item.content,
|
||||
text_format: "text",
|
||||
published_at: item.published_at || null,
|
||||
source_type: item.source || null,
|
||||
}, idx, now)
|
||||
);
|
||||
return { results, totalResults: results.length };
|
||||
}
|
||||
|
||||
function normalizeGlmSearch(data, _query, _searchType) {
|
||||
const now = new Date().toISOString();
|
||||
// MCP envelope: { result: { content: [{ type: "text", text: "<json>" }] } }
|
||||
let payload = data;
|
||||
const textContent = data?.result?.content?.[0]?.text;
|
||||
if (typeof textContent === "string") {
|
||||
try { payload = JSON.parse(textContent); } catch { payload = {}; }
|
||||
}
|
||||
const items = Array.isArray(payload?.results) ? payload.results
|
||||
: Array.isArray(payload?.news) ? payload.news
|
||||
: Array.isArray(payload) ? payload
|
||||
: [];
|
||||
const results = items.map((item, idx) =>
|
||||
makeResult("glm", {
|
||||
title: item.title,
|
||||
url: item.link || item.url,
|
||||
snippet: item.content || "",
|
||||
published_at: item.publish_date || item.published_at || null,
|
||||
favicon_url: item.icon || null,
|
||||
source_type: item.media || null,
|
||||
}, idx, now)
|
||||
);
|
||||
return { results, totalResults: results.length };
|
||||
}
|
||||
|
||||
const NORMALIZERS = {
|
||||
"serper": normalizeSerper,
|
||||
"brave-search": normalizeBrave,
|
||||
@@ -210,11 +293,14 @@ const NORMALIZERS = {
|
||||
"searchapi": normalizeSearchApi,
|
||||
"youcom": normalizeYouCom,
|
||||
"searxng": normalizeSearxng,
|
||||
"xquik": normalizeXquik,
|
||||
"ollama-search": normalizeOllamaSearch,
|
||||
"glm": normalizeGlmSearch,
|
||||
};
|
||||
|
||||
/**
|
||||
* Dispatch to the appropriate normalizer based on providerId.
|
||||
* @returns {{results: Array, totalResults: number|null}}
|
||||
* @returns {{results: Array, totalResults: number|null, pagination?: object}}
|
||||
*/
|
||||
export function normalizeSearchResponse(providerId, data, query, searchType) {
|
||||
const fn = NORMALIZERS[providerId];
|
||||
|
||||
Reference in new issue
Block a user