Merge origin/master (v0.5.69) into gitea/new_feature
This commit is contained in:
@@ -1,16 +1,16 @@
|
||||
import { createHash, randomBytes, randomUUID } from "crypto";
|
||||
import { CLAUDE_TOOL_SUFFIX, CC_DEFAULT_TOOLS } from "../config/appConstants.js";
|
||||
import { CLAUDE_CLI_VERSION } from "../providers/shared.js";
|
||||
|
||||
const CLAUDE_VERSION = "2.1.92";
|
||||
const CC_ENTRYPOINT = "sdk-cli";
|
||||
|
||||
// Generate billing header matching real Claude Code 2.1.92+ format:
|
||||
// Generate the billing header expected from current Claude Code clients.
|
||||
// x-anthropic-billing-header: cc_version=<ver>.<build>; cc_entrypoint=sdk-cli; cch=<hash>;
|
||||
function generateBillingHeader(payload) {
|
||||
const content = JSON.stringify(payload);
|
||||
const cch = createHash("sha256").update(content).digest("hex").slice(0, 5);
|
||||
const buildHash = randomBytes(2).toString("hex").slice(0, 3);
|
||||
return `x-anthropic-billing-header: cc_version=${CLAUDE_VERSION}.${buildHash}; cc_entrypoint=${CC_ENTRYPOINT}; cch=${cch};`;
|
||||
return `x-anthropic-billing-header: cc_version=${CLAUDE_CLI_VERSION}.${buildHash}; cc_entrypoint=${CC_ENTRYPOINT}; cch=${cch};`;
|
||||
}
|
||||
|
||||
// Derive a deterministic UUID-v4-shaped string from a seed (stable per account)
|
||||
@@ -19,7 +19,7 @@ function deriveUuid(seed) {
|
||||
return `${h.slice(0, 8)}-${h.slice(8, 12)}-4${h.slice(13, 16)}-${((parseInt(h[16], 16) & 0x3) | 0x8).toString(16)}${h.slice(17, 20)}-${h.slice(20, 32)}`;
|
||||
}
|
||||
|
||||
// Generate fake user ID in Claude Code 2.1.92+ JSON format:
|
||||
// Generate fake user ID in the current Claude Code JSON format:
|
||||
// {"device_id":"<64hex>","account_uuid":"<uuid>","session_id":"<uuid>"}
|
||||
// device_id/account_uuid derive from apiKey (stable per account), session_id per-conversation
|
||||
function generateFakeUserID(sessionId, apiKey) {
|
||||
@@ -31,8 +31,8 @@ function generateFakeUserID(sessionId, apiKey) {
|
||||
|
||||
/**
|
||||
* Cloak tools before sending to Claude provider (anti-ban):
|
||||
* - Rename non-CC client tools with _cc suffix in tools[] and messages[]
|
||||
* - Skip tools that are already CC default names (they become decoys as-is)
|
||||
* - Rename client tools with the CLAUDE_TOOL_SUFFIX ("_ide") in tools[] and messages[]
|
||||
* - Skip tools that carry a `type` (server-side built-ins) — sent as-is
|
||||
* - Inject CC_DECOY_TOOLS after client tools
|
||||
* Returns { body, toolNameMap } where toolNameMap maps suffixed → original
|
||||
* @param {object} body - Claude API request body
|
||||
@@ -101,6 +101,33 @@ export function decloakToolNames(body, toolNameMap) {
|
||||
return { ...body, content };
|
||||
}
|
||||
|
||||
/**
|
||||
* Decloak the tool name inside a single streamed Claude SSE event.
|
||||
*
|
||||
* Streaming counterpart of decloakToolNames(). Required for claude→claude
|
||||
* proxying: translateResponse() returns same-format chunks untouched, so
|
||||
* without this the client receives the cloaked ("_ide"-suffixed) tool name
|
||||
* and rejects the call as an unknown tool. In a Claude SSE stream a tool
|
||||
* name appears exactly once per call — on the content_block_start event of
|
||||
* a tool_use block; argument deltas carry no name.
|
||||
*
|
||||
* Unknown names (e.g. a CC decoy tool the model called anyway) pass through
|
||||
* unchanged, matching the non-streaming decloak behavior.
|
||||
*
|
||||
* @param {object|null} chunk - Parsed SSE event (may be null on stream flush)
|
||||
* @param {Map|null} toolNameMap - Suffixed → original name map from cloakClaudeTools()
|
||||
* @returns {object|null} The chunk, with the tool_use name restored when cloaked
|
||||
*/
|
||||
export function decloakStreamChunk(chunk, toolNameMap) {
|
||||
if (!toolNameMap?.size || !chunk || typeof chunk !== "object") return chunk;
|
||||
if (chunk.type !== "content_block_start") return chunk;
|
||||
const block = chunk.content_block;
|
||||
if (block?.type !== "tool_use" || typeof block.name !== "string") return chunk;
|
||||
const original = toolNameMap.get(block.name);
|
||||
if (!original) return chunk;
|
||||
return { ...chunk, content_block: { ...block, name: original } };
|
||||
}
|
||||
|
||||
// CC decoy tools — Claude Code native tool names, marked unavailable
|
||||
const CC_DECOY_TOOLS = [
|
||||
{ name: "Task", description: "This tool is currently unavailable.", input_schema: { type: "object", properties: {} } },
|
||||
|
||||
117
open-sse/utils/claudeToolTypeSelfCheck.mjs
Normal file
117
open-sse/utils/claudeToolTypeSelfCheck.mjs
Normal file
@@ -0,0 +1,117 @@
|
||||
// Claude tool type default self-check.
|
||||
// Run: node open-sse/utils/claudeToolTypeSelfCheck.mjs
|
||||
// No framework, no deps. Uses assert. Mirrors toolPairingSelfCheck.mjs style.
|
||||
import { defaultClaudeToolType } from "../translator/concerns/toolCall.js";
|
||||
|
||||
const results = [];
|
||||
function run(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
results.push({ name, ok: true });
|
||||
} catch (err) {
|
||||
results.push({ name, ok: false, err: err.message });
|
||||
}
|
||||
}
|
||||
const assert = {
|
||||
equal(a, b, msg) { if (a !== b) throw new Error(`${msg || ""} expected ${b}, got ${a}`); },
|
||||
ok(v, msg) { if (!v) throw new Error(msg || "expected truthy"); },
|
||||
};
|
||||
|
||||
// 1. Tool without `type` property → defaults to "custom"
|
||||
run("Tool without type property defaults to custom", () => {
|
||||
const tools = [{ name: "foo", description: "bar", input_schema: {} }];
|
||||
const out = defaultClaudeToolType(tools);
|
||||
assert.equal(out[0].type, "custom", "type defaulted");
|
||||
assert.equal(out[0].name, "foo", "other fields preserved");
|
||||
});
|
||||
|
||||
// 2. Tool with type:null → defaults to "custom" (the spread-order bug case)
|
||||
run("Tool with type:null defaults to custom", () => {
|
||||
const tools = [{ name: "foo", type: null, input_schema: {} }];
|
||||
const out = defaultClaudeToolType(tools);
|
||||
assert.equal(out[0].type, "custom", "null type overwritten to custom");
|
||||
});
|
||||
|
||||
// 3. Tool with type:undefined → defaults to "custom"
|
||||
run("Tool with type:undefined defaults to custom", () => {
|
||||
const tools = [{ name: "foo", type: undefined, input_schema: {} }];
|
||||
const out = defaultClaudeToolType(tools);
|
||||
assert.equal(out[0].type, "custom", "undefined type overwritten to custom");
|
||||
});
|
||||
|
||||
// 4. Tool with type:"" (empty string) → defaults to "custom"
|
||||
run("Tool with type:empty-string defaults to custom", () => {
|
||||
const tools = [{ name: "foo", type: "", input_schema: {} }];
|
||||
const out = defaultClaudeToolType(tools);
|
||||
assert.equal(out[0].type, "custom", "empty-string type overwritten to custom");
|
||||
});
|
||||
|
||||
// 5. Built-in tool with type:"computer_use" → passed through untouched
|
||||
run("Built-in tool (computer_use) passed through", () => {
|
||||
const tools = [{ type: "computer_use", name: "computer", display_width: 1024 }];
|
||||
const out = defaultClaudeToolType(tools);
|
||||
assert.equal(out[0].type, "computer_use", "built-in type preserved");
|
||||
assert.equal(out[0], tools[0], "same reference — not cloned");
|
||||
});
|
||||
|
||||
// 6. Tool already with type:"custom" → passed through untouched
|
||||
run("Tool already with type:custom passed through", () => {
|
||||
const tools = [{ type: "custom", name: "foo", input_schema: {} }];
|
||||
const out = defaultClaudeToolType(tools);
|
||||
assert.equal(out[0].type, "custom", "existing custom type preserved");
|
||||
assert.equal(out[0], tools[0], "same reference — not cloned");
|
||||
});
|
||||
|
||||
// 7. Mixed: built-in + function tool → only function tool gets default
|
||||
run("Mixed: built-in kept, function tool defaulted", () => {
|
||||
const tools = [
|
||||
{ type: "computer_use", name: "computer", display_width: 1024 },
|
||||
{ name: "search", description: "search the web", input_schema: {} },
|
||||
{ type: "web_search_20250305", name: "web_search" },
|
||||
];
|
||||
const out = defaultClaudeToolType(tools);
|
||||
assert.equal(out[0].type, "computer_use", "built-in preserved");
|
||||
assert.equal(out[1].type, "custom", "function tool defaulted");
|
||||
assert.equal(out[2].type, "web_search_20250305", "web_search preserved");
|
||||
});
|
||||
|
||||
// 8. Non-array input → returned unchanged
|
||||
run("Non-array input returned unchanged", () => {
|
||||
assert.equal(defaultClaudeToolType(null), null, "null returned as-is");
|
||||
assert.equal(defaultClaudeToolType(undefined), undefined, "undefined returned as-is");
|
||||
assert.equal(defaultClaudeToolType("not array"), "not array", "string returned as-is");
|
||||
});
|
||||
|
||||
// 9. Empty array → empty array
|
||||
run("Empty array returns empty array", () => {
|
||||
const out = defaultClaudeToolType([]);
|
||||
assert.equal(Array.isArray(out), true, "returns array");
|
||||
assert.equal(out.length, 0, "empty array");
|
||||
});
|
||||
|
||||
// 10. Original tools not mutated by reference (new objects for defaulted tools)
|
||||
run("Original tools not mutated by reference", () => {
|
||||
const original = { name: "foo", input_schema: {} };
|
||||
const tools = [original];
|
||||
defaultClaudeToolType(tools);
|
||||
assert.equal(original.type, undefined, "original tool not mutated");
|
||||
assert.ok(!("type" in original), "type property not added to original");
|
||||
});
|
||||
|
||||
// 11. Array with null entry → defaults to { type: "custom" } (optional chaining guard)
|
||||
// tool?.type returns undefined for null, and { ...null, type: "custom" } === { type: "custom" }
|
||||
run("Array with null entry defaults to custom", () => {
|
||||
const tools = [null];
|
||||
const out = defaultClaudeToolType(tools);
|
||||
assert.equal(out[0].type, "custom", "null tool gets type custom");
|
||||
assert.equal(Object.keys(out[0]).length, 1, "no other keys from spread of null");
|
||||
});
|
||||
|
||||
// Summary
|
||||
const passed = results.filter(r => r.ok).length;
|
||||
const total = results.length;
|
||||
for (const r of results) {
|
||||
console.log(`${r.ok ? "ok" : "FAIL"} - ${r.name}${r.ok ? "" : ` :: ${r.err}`}`);
|
||||
}
|
||||
console.log(`\n${passed}/${total} checks passed`);
|
||||
if (passed !== total) process.exit(1);
|
||||
20
open-sse/utils/modelMarkers.js
Normal file
20
open-sse/utils/modelMarkers.js
Normal file
@@ -0,0 +1,20 @@
|
||||
// Claude Code appends a bracketed context marker to the model name when the
|
||||
// 1M-context beta is toggled on: `claude-opus-5` becomes `claude-opus-5[1m]`.
|
||||
// The marker is a client-side annotation, not part of any model id: it never
|
||||
// matches a combo name, an alias or a `provider/model` pair, so a request that
|
||||
// carries it dies at model resolution with "Invalid model format".
|
||||
//
|
||||
// The capability itself travels in the `anthropic-beta: context-1m-2025-08-07`
|
||||
// header, which is forwarded untouched — stripping the marker is enough to let
|
||||
// the request route normally and still reach the upstream as a 1M request.
|
||||
|
||||
const CONTEXT_MARKER = /\[1m\]$/i;
|
||||
|
||||
// Returns { model, contextMarker } — contextMarker is null when there is none.
|
||||
export function stripModelContextMarker(modelStr) {
|
||||
if (typeof modelStr !== "string") return { model: modelStr, contextMarker: null };
|
||||
const trimmed = modelStr.trim();
|
||||
const match = trimmed.match(CONTEXT_MARKER);
|
||||
if (!match) return { model: modelStr, contextMarker: null };
|
||||
return { model: trimmed.slice(0, -match[0].length), contextMarker: match[0].slice(1, -1).toLowerCase() };
|
||||
}
|
||||
@@ -94,6 +94,7 @@ const MAX_CONTINUATION_SESSIONS = 5000;
|
||||
// Client headers/body fields that carry an upstream session id (priority order)
|
||||
const SESSION_HEADER_KEYS = ["x-session-id", "session-id", "session_id", "x-amp-thread-id"];
|
||||
const CLAUDE_CODE_SESSION_RE = /_session_([a-f0-9-]+)$/;
|
||||
const CLAUDE_CODE_SESSION_HEADER = "x-claude-code-session-id";
|
||||
|
||||
function sha16(text) {
|
||||
return crypto.createHash("sha256").update(text).digest("hex").slice(0, 16);
|
||||
@@ -135,7 +136,10 @@ function extractAntigravitySession(body) {
|
||||
}
|
||||
|
||||
function extractClientSessionId(headers, body, scope = "") {
|
||||
const claude = extractClaudeCodeSession(body?.metadata?.user_id);
|
||||
// Claude Code sends the session in a header AND in metadata.user_id; the header
|
||||
// survives translation to formats that drop metadata (e.g. Responses API).
|
||||
const claude = extractClaudeCodeSession(body?.metadata?.user_id)
|
||||
|| headerValue(headers, CLAUDE_CODE_SESSION_HEADER);
|
||||
if (claude) return `claude:${claude}`;
|
||||
const antigravity = extractAntigravitySession(body);
|
||||
if (antigravity) return `antigravity:${antigravity}`;
|
||||
|
||||
@@ -49,7 +49,8 @@ export function createSSEStream(options = {}) {
|
||||
connectionId = null,
|
||||
body = null,
|
||||
onStreamComplete = null,
|
||||
apiKey = null
|
||||
apiKey = null,
|
||||
credentials = null
|
||||
} = options;
|
||||
|
||||
let buffer = "";
|
||||
@@ -59,7 +60,7 @@ export function createSSEStream(options = {}) {
|
||||
const decoder = new TextDecoder("utf-8", { fatal: false });
|
||||
|
||||
const state = mode === STREAM_MODE.TRANSLATE
|
||||
? { ...initState(sourceFormat), provider, toolNameMap, customToolNames: new Set(customToolNames || []), model }
|
||||
? { ...initState(sourceFormat), provider, toolNameMap, customToolNames: new Set(customToolNames || []), model, sessionId: credentials?._clientSessionId || null }
|
||||
: null;
|
||||
|
||||
let totalContentLength = 0;
|
||||
@@ -75,6 +76,35 @@ export function createSSEStream(options = {}) {
|
||||
let openAIResponsesTerminalSeen = false;
|
||||
let openAIResponsesDoneSent = false;
|
||||
let streamDoneSent = false; // track duplicate [DONE] across transform + flush
|
||||
let finalized = false;
|
||||
|
||||
// Usage/logging tail, callable from transform() as well as flush(): a client that
|
||||
// closes right after the terminal event cancels the reader, and flush() never runs.
|
||||
const finalizeStream = () => {
|
||||
if (finalized) return;
|
||||
finalized = true;
|
||||
|
||||
const isPassthrough = mode === STREAM_MODE.PASSTHROUGH;
|
||||
let finalUsage = isPassthrough ? usage : state?.usage;
|
||||
|
||||
if (!hasValidUsage(finalUsage) && totalContentLength > 0) {
|
||||
finalUsage = estimateUsage(body, totalContentLength, isPassthrough ? FORMATS.OPENAI : sourceFormat);
|
||||
if (isPassthrough) usage = finalUsage; else state.usage = finalUsage;
|
||||
}
|
||||
|
||||
if (hasValidUsage(finalUsage)) {
|
||||
logUsage(isPassthrough ? provider : (state?.provider || targetFormat), finalUsage, model, connectionId, apiKey);
|
||||
} else {
|
||||
appendRequestLog({ model, provider, connectionId, tokens: null, status: "200 OK" }).catch(() => { });
|
||||
}
|
||||
|
||||
if (onStreamComplete) {
|
||||
onStreamComplete({
|
||||
content: accumulatedContent,
|
||||
thinking: accumulatedThinking
|
||||
}, finalUsage, ttftAt);
|
||||
}
|
||||
};
|
||||
|
||||
return new TransformStream({
|
||||
transform(chunk, controller) {
|
||||
@@ -105,6 +135,7 @@ export function createSSEStream(options = {}) {
|
||||
if (mode === STREAM_MODE.PASSTHROUGH) {
|
||||
let output;
|
||||
let injectedUsage = false;
|
||||
let responsesTerminal = false;
|
||||
|
||||
if (trimmed.startsWith("data:") && trimmed.slice(5).trim() !== "[DONE]") {
|
||||
try {
|
||||
@@ -168,6 +199,8 @@ export function createSSEStream(options = {}) {
|
||||
usage = mergeUsage(usage, extracted);
|
||||
}
|
||||
|
||||
responsesTerminal = isOpenAIResponsesTerminalEvent(currentOpenAIResponsesEvent, parsed);
|
||||
|
||||
const isFinishChunk = parsed.choices?.[0]?.finish_reason;
|
||||
if (isFinishChunk && !hasValidUsage(parsed.usage)) {
|
||||
const estimated = estimateUsage(body, totalContentLength, FORMATS.OPENAI);
|
||||
@@ -202,6 +235,8 @@ export function createSSEStream(options = {}) {
|
||||
|
||||
reqLogger?.appendConvertedChunk?.(output);
|
||||
controller.enqueue(sharedEncoder.encode(output));
|
||||
// Responses clients (codex CLI) close on response.completed instead of [DONE]
|
||||
if (responsesTerminal) finalizeStream();
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -292,6 +327,8 @@ export function createSSEStream(options = {}) {
|
||||
controller.enqueue(sharedEncoder.encode(output));
|
||||
currentOpenAIResponsesEvent = null;
|
||||
sseEmittedCount++;
|
||||
// Responses clients (codex) close on response.completed instead of [DONE]
|
||||
if (openAIResponsesTerminalSeen) finalizeStream();
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -355,13 +392,6 @@ export function createSSEStream(options = {}) {
|
||||
controller.enqueue(sharedEncoder.encode(output));
|
||||
}
|
||||
|
||||
if (!hasValidUsage(usage) && totalContentLength > 0) {
|
||||
usage = estimateUsage(body, totalContentLength, FORMATS.OPENAI);
|
||||
}
|
||||
|
||||
if (hasValidUsage(usage)) {
|
||||
logUsage(provider, usage, model, connectionId, apiKey);
|
||||
}
|
||||
// IMPORTANT: In passthrough mode we still must terminate the SSE stream.
|
||||
// Some clients (e.g. OpenClaw) expect the OpenAI-style sentinel:
|
||||
// data: [DONE]\n\n
|
||||
@@ -374,18 +404,26 @@ export function createSSEStream(options = {}) {
|
||||
controller.enqueue(sharedEncoder.encode(doneOutput));
|
||||
}
|
||||
|
||||
if (onStreamComplete) {
|
||||
onStreamComplete({
|
||||
content: accumulatedContent,
|
||||
thinking: accumulatedThinking
|
||||
}, usage, ttftAt);
|
||||
}
|
||||
finalizeStream();
|
||||
return;
|
||||
}
|
||||
|
||||
if (buffer.trim()) {
|
||||
const parsed = parseSSELine(buffer.trim());
|
||||
if (parsed && !parsed.done) {
|
||||
// Same parse as the transform loop: without targetFormat this only
|
||||
// accepts "data: " lines, so an NDJSON provider (Ollama) lost whatever
|
||||
// arrived without its closing newline.
|
||||
const parsed = parseSSELine(buffer.trim(), targetFormat);
|
||||
// parseSSELine turns the SSE sentinel "data: [DONE]" into { done: true },
|
||||
// which must not be translated. An Ollama chunk also carries done:true,
|
||||
// but it is the real final chunk — it holds finish_reason and the token
|
||||
// counts — so it has to go through.
|
||||
const isDoneSentinel = parsed?.done && targetFormat !== FORMATS.OLLAMA;
|
||||
if (parsed && !isDoneSentinel) {
|
||||
// Same accumulation the transform loop does, so finalizeStream() can
|
||||
// log a tail chunk's tokens instead of falling back to null.
|
||||
const extracted = extractUsage(parsed);
|
||||
if (extracted) state.usage = mergeUsage(state.usage, extracted);
|
||||
|
||||
const translated = translateResponse(targetFormat, sourceFormat, parsed, state);
|
||||
|
||||
if (translated?._openaiIntermediate) {
|
||||
@@ -441,26 +479,16 @@ export function createSSEStream(options = {}) {
|
||||
streamDoneSent = true;
|
||||
}
|
||||
|
||||
if (!hasValidUsage(state?.usage) && totalContentLength > 0) {
|
||||
state.usage = estimateUsage(body, totalContentLength, sourceFormat);
|
||||
}
|
||||
if (hasValidUsage(state?.usage)) {
|
||||
logUsage(state.provider || targetFormat, state.usage, model, connectionId, apiKey);
|
||||
}
|
||||
if (onStreamComplete) {
|
||||
onStreamComplete({
|
||||
content: accumulatedContent,
|
||||
thinking: accumulatedThinking
|
||||
}, state?.usage, ttftAt);
|
||||
}
|
||||
finalizeStream();
|
||||
} catch (error) {
|
||||
console.log("Error in flush:", error);
|
||||
finalizeStream();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider = null, reqLogger = null, toolNameMap = null, model = null, connectionId = null, body = null, onStreamComplete = null, apiKey = null, customToolNames = null) {
|
||||
export function createSSETransformStreamWithLogger(targetFormat, sourceFormat, provider = null, reqLogger = null, toolNameMap = null, model = null, connectionId = null, body = null, onStreamComplete = null, apiKey = null, customToolNames = null, credentials = null) {
|
||||
return createSSEStream({
|
||||
mode: STREAM_MODE.TRANSLATE,
|
||||
targetFormat,
|
||||
@@ -473,7 +501,8 @@ export function createSSETransformStreamWithLogger(targetFormat, sourceFormat, p
|
||||
connectionId,
|
||||
body,
|
||||
onStreamComplete,
|
||||
apiKey
|
||||
apiKey,
|
||||
credentials
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -41,7 +41,8 @@ export function createStreamController({ onDisconnect, onError, log, provider, m
|
||||
if (disconnected) return;
|
||||
disconnected = true;
|
||||
|
||||
logStream("⚡", `DISCONNECT: ${reason}`);
|
||||
// Debug-only: Responses API has no [DONE] sentinel, so codex/droid close the
|
||||
// socket on every completed request. "📊 done" is the authoritative outcome line.
|
||||
dbg("CTRL", `${provider}/${model} | disconnect=${reason} | dur=${Date.now() - startTime}ms`);
|
||||
|
||||
// Delay abort to allow cleanup
|
||||
|
||||
@@ -190,7 +190,10 @@ export function canonicalizeUsage(usage) {
|
||||
prompt = prompt + cached + cacheCreation;
|
||||
} else {
|
||||
// OpenAI/Gemini path (or already-canonical input): prompt already includes cached_tokens.
|
||||
cached = num(usage.cached_tokens);
|
||||
// Mirror the cacheCreation fallback above: buildUsage() only ever emits the
|
||||
// nested prompt_tokens_details.cached_tokens shape, so without this the
|
||||
// cache-read count is silently dropped on every buildUsage()-derived usage.
|
||||
cached = num(usage.cached_tokens ?? usage.prompt_tokens_details?.cached_tokens);
|
||||
}
|
||||
|
||||
const result = {
|
||||
|
||||
Reference in New Issue
Block a user