fix(gemini): preserve thoughtSignature via tool_call ID smuggling + fix ELOCKED mutex
- Encode thoughtSignature into tool_call.id using _TSIG_ delimiter and base64url - Decode _TSIG_ on request to restore thoughtSignature for Gemini multi-turn thinking - Track pendingThoughtSignature across parts for deferred signature attachment - Add LocalMutex (2-layer locking) to prevent ELOCKED on concurrent DB access - Increase lockfile retries from 5 to 15 for multi-process robustness - Restore db.json seed on first run to prevent ENOENT on lockfile.lock - Use process.env.BASE_URL fallback in models test route - Remove gemini-3-flash-lite-preview from provider models Co-authored-by: kwanLeeFrmVi <quanle96@outlook.com> Closes #450 Made-with: Cursor
This commit is contained in:
@@ -197,7 +197,6 @@ export const PROVIDER_MODELS = {
|
|||||||
{ id: "gemini-3.1-flash-image-preview", name: "Gemini 3.1 Flash Image Preview" },
|
{ id: "gemini-3.1-flash-image-preview", name: "Gemini 3.1 Flash Image Preview" },
|
||||||
// Gemini 3 series
|
// Gemini 3 series
|
||||||
{ id: "gemini-3-flash-preview", name: "Gemini 3 Flash Preview" },
|
{ id: "gemini-3-flash-preview", name: "Gemini 3 Flash Preview" },
|
||||||
{ id: "gemini-3-flash-lite-preview", name: "Gemini 3 Flash Lite Preview" },
|
|
||||||
// Gemini 2.5 series
|
// Gemini 2.5 series
|
||||||
{ id: "gemini-2.5-pro", name: "Gemini 2.5 Pro" },
|
{ id: "gemini-2.5-pro", name: "Gemini 2.5 Pro" },
|
||||||
{ id: "gemini-2.5-flash", name: "Gemini 2.5 Flash" },
|
{ id: "gemini-2.5-flash", name: "Gemini 2.5 Flash" },
|
||||||
@@ -215,11 +214,13 @@ export const PROVIDER_MODELS = {
|
|||||||
// { id: "openrouter/free", name: "Free Models (Auto)" },
|
// { id: "openrouter/free", name: "Free Models (Auto)" },
|
||||||
],
|
],
|
||||||
glm: [
|
glm: [
|
||||||
|
{ id: "glm-5.1", name: "GLM 5.1" },
|
||||||
{ id: "glm-5", name: "GLM 5" },
|
{ id: "glm-5", name: "GLM 5" },
|
||||||
{ id: "glm-4.7", name: "GLM 4.7" },
|
{ id: "glm-4.7", name: "GLM 4.7" },
|
||||||
{ id: "glm-4.6v", name: "GLM 4.6V (Vision)" },
|
{ id: "glm-4.6v", name: "GLM 4.6V (Vision)" },
|
||||||
],
|
],
|
||||||
"glm-cn": [
|
"glm-cn": [
|
||||||
|
{ id: "glm-5.1", name: "GLM 5.1" },
|
||||||
{ id: "glm-5", name: "GLM 5" },
|
{ id: "glm-5", name: "GLM 5" },
|
||||||
{ id: "glm-4.7", name: "GLM-4.7" },
|
{ id: "glm-4.7", name: "GLM-4.7" },
|
||||||
{ id: "glm-4.6", name: "GLM-4.6" },
|
{ id: "glm-4.6", name: "GLM-4.6" },
|
||||||
@@ -311,10 +312,6 @@ export const PROVIDER_MODELS = {
|
|||||||
nvidia: [
|
nvidia: [
|
||||||
{ id: "moonshotai/kimi-k2.5", name: "Kimi K2.5" },
|
{ id: "moonshotai/kimi-k2.5", name: "Kimi K2.5" },
|
||||||
{ id: "z-ai/glm4.7", name: "GLM 4.7" },
|
{ id: "z-ai/glm4.7", name: "GLM 4.7" },
|
||||||
{ id: "deepseek-ai/deepseek-v3.2", name: "DeepSeek V3.2" },
|
|
||||||
{ id: "nvidia/llama-3.3-70b-instruct", name: "Llama 3.3 70B" },
|
|
||||||
{ id: "meta/llama-4-maverick-17b-128e-instruct", name: "Llama 4 Maverick" },
|
|
||||||
{ id: "deepseek/deepseek-r1", name: "DeepSeek R1" },
|
|
||||||
],
|
],
|
||||||
nebius: [
|
nebius: [
|
||||||
{ id: "meta-llama/Llama-3.3-70B-Instruct", name: "Llama 3.3 70B Instruct" },
|
{ id: "meta-llama/Llama-3.3-70B-Instruct", name: "Llama 3.3 70B Instruct" },
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
import { register } from "../index.js";
|
import { register } from "../index.js";
|
||||||
import { FORMATS } from "../formats.js";
|
import { FORMATS } from "../formats.js";
|
||||||
// import { DEFAULT_THINKING_GEMINI_SIGNATURE } from "../../config/defaultThinkingSignature.js";
|
|
||||||
import { ANTIGRAVITY_DEFAULT_SYSTEM } from "../../config/appConstants.js";
|
import { ANTIGRAVITY_DEFAULT_SYSTEM } from "../../config/appConstants.js";
|
||||||
import { openaiToClaudeRequestForAntigravity } from "./openai-to-claude.js";
|
import { openaiToClaudeRequestForAntigravity } from "./openai-to-claude.js";
|
||||||
|
|
||||||
|
// Decode base64url → standard base64 (for restoring thoughtSignature)
|
||||||
|
function fromBase64Url(b64url) {
|
||||||
|
let b64 = b64url.replace(/-/g, "+").replace(/_/g, "/");
|
||||||
|
// Restore padding
|
||||||
|
while (b64.length % 4) b64 += "=";
|
||||||
|
return b64;
|
||||||
|
}
|
||||||
|
|
||||||
function generateUUID() {
|
function generateUUID() {
|
||||||
return crypto.randomUUID();
|
return crypto.randomUUID();
|
||||||
}
|
}
|
||||||
@@ -58,26 +65,32 @@ function openaiToGeminiBase(model, body, stream) {
|
|||||||
result.generationConfig.maxOutputTokens = body.max_tokens;
|
result.generationConfig.maxOutputTokens = body.max_tokens;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build tool_call_id -> name map
|
// Strip embedded thoughtSignature from a tool_call id ("rawId_TSIG_sig" → "rawId")
|
||||||
|
const stripSig = (id) => {
|
||||||
|
const sep = id ? id.indexOf("_TSIG_") : -1;
|
||||||
|
return sep !== -1 ? id.slice(0, sep) : id;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build tool_call_id -> name map (keyed by rawId)
|
||||||
const tcID2Name = {};
|
const tcID2Name = {};
|
||||||
if (body.messages && Array.isArray(body.messages)) {
|
if (body.messages && Array.isArray(body.messages)) {
|
||||||
for (const msg of body.messages) {
|
for (const msg of body.messages) {
|
||||||
if (msg.role === "assistant" && msg.tool_calls) {
|
if (msg.role === "assistant" && msg.tool_calls) {
|
||||||
for (const tc of msg.tool_calls) {
|
for (const tc of msg.tool_calls) {
|
||||||
if (tc.type === "function" && tc.id && tc.function?.name) {
|
if (tc.type === "function" && tc.id && tc.function?.name) {
|
||||||
tcID2Name[tc.id] = tc.function.name;
|
tcID2Name[stripSig(tc.id)] = tc.function.name;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build tool responses cache
|
// Build tool responses cache (keyed by rawId)
|
||||||
const toolResponses = {};
|
const toolResponses = {};
|
||||||
if (body.messages && Array.isArray(body.messages)) {
|
if (body.messages && Array.isArray(body.messages)) {
|
||||||
for (const msg of body.messages) {
|
for (const msg of body.messages) {
|
||||||
if (msg.role === "tool" && msg.tool_call_id) {
|
if (msg.role === "tool" && msg.tool_call_id) {
|
||||||
toolResponses[msg.tool_call_id] = msg.content;
|
toolResponses[stripSig(msg.tool_call_id)] = msg.content;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -102,18 +115,6 @@ function openaiToGeminiBase(model, body, stream) {
|
|||||||
} else if (role === "assistant") {
|
} else if (role === "assistant") {
|
||||||
const parts = [];
|
const parts = [];
|
||||||
|
|
||||||
// Thinking/reasoning → thought part
|
|
||||||
if (msg.reasoning_content) {
|
|
||||||
parts.push({
|
|
||||||
thought: true,
|
|
||||||
text: msg.reasoning_content
|
|
||||||
});
|
|
||||||
// parts.push({
|
|
||||||
// thoughtSignature: DEFAULT_THINKING_GEMINI_SIGNATURE,
|
|
||||||
// text: ""
|
|
||||||
// });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (content) {
|
if (content) {
|
||||||
const text = typeof content === "string" ? content : extractTextContent(content);
|
const text = typeof content === "string" ? content : extractTextContent(content);
|
||||||
if (text) {
|
if (text) {
|
||||||
@@ -127,15 +128,22 @@ function openaiToGeminiBase(model, body, stream) {
|
|||||||
if (tc.type !== "function") continue;
|
if (tc.type !== "function") continue;
|
||||||
|
|
||||||
const args = tryParseJSON(tc.function?.arguments || "{}");
|
const args = tryParseJSON(tc.function?.arguments || "{}");
|
||||||
parts.push({
|
// Extract thoughtSignature embedded in ID as "rawId_TSIG_base64urlsig"
|
||||||
// thoughtSignature: DEFAULT_THINKING_GEMINI_SIGNATURE,
|
const sepIdx = tc.id.indexOf("_TSIG_");
|
||||||
|
const rawId = sepIdx !== -1 ? tc.id.slice(0, sepIdx) : tc.id;
|
||||||
|
const encodedSig = sepIdx !== -1 ? tc.id.slice(sepIdx + 6) : "";
|
||||||
|
const fcPart = {
|
||||||
functionCall: {
|
functionCall: {
|
||||||
id: tc.id,
|
id: rawId,
|
||||||
name: sanitizeGeminiFunctionName(tc.function.name),
|
name: sanitizeGeminiFunctionName(tc.function.name),
|
||||||
args: args
|
args: args
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
toolCallIds.push(tc.id);
|
if (encodedSig) {
|
||||||
|
fcPart.thoughtSignature = fromBase64Url(encodedSig);
|
||||||
|
}
|
||||||
|
parts.push(fcPart);
|
||||||
|
toolCallIds.push(rawId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parts.length > 0) {
|
if (parts.length > 0) {
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
import { register } from "../index.js";
|
import { register } from "../index.js";
|
||||||
import { FORMATS } from "../formats.js";
|
import { FORMATS } from "../formats.js";
|
||||||
|
|
||||||
|
// Encode base64 → base64url (safe for tool_call IDs: only [a-zA-Z0-9_-])
|
||||||
|
function toBase64Url(b64) {
|
||||||
|
return b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
// Convert Gemini response chunk to OpenAI format
|
// Convert Gemini response chunk to OpenAI format
|
||||||
export function geminiToOpenAIResponse(chunk, state) {
|
export function geminiToOpenAIResponse(chunk, state) {
|
||||||
if (!chunk) return null;
|
if (!chunk) return null;
|
||||||
@@ -18,6 +23,7 @@ export function geminiToOpenAIResponse(chunk, state) {
|
|||||||
state.messageId = response.responseId || `msg_${Date.now()}`;
|
state.messageId = response.responseId || `msg_${Date.now()}`;
|
||||||
state.model = response.modelVersion || "gemini";
|
state.model = response.modelVersion || "gemini";
|
||||||
state.functionIndex = 0;
|
state.functionIndex = 0;
|
||||||
|
state.pendingThoughtSignature = null;
|
||||||
results.push({
|
results.push({
|
||||||
id: `chatcmpl-${state.messageId}`,
|
id: `chatcmpl-${state.messageId}`,
|
||||||
object: "chat.completion.chunk",
|
object: "chat.completion.chunk",
|
||||||
@@ -34,66 +40,19 @@ export function geminiToOpenAIResponse(chunk, state) {
|
|||||||
// Process parts
|
// Process parts
|
||||||
if (content?.parts) {
|
if (content?.parts) {
|
||||||
for (const part of content.parts) {
|
for (const part of content.parts) {
|
||||||
const hasThoughtSig = part.thoughtSignature || part.thought_signature;
|
const partThoughtSig = part.thoughtSignature || part.thought_signature || "";
|
||||||
const isThought = part.thought === true;
|
const isThought = part.thought === true;
|
||||||
|
|
||||||
// Handle thought signature (thinking mode)
|
// Accumulate thoughtSignature across parts: a sig-only part precedes the functionCall part
|
||||||
if (hasThoughtSig) {
|
if (partThoughtSig) {
|
||||||
const hasTextContent = part.text !== undefined && part.text !== "";
|
state.pendingThoughtSignature = partThoughtSig;
|
||||||
const hasFunctionCall = !!part.functionCall;
|
|
||||||
|
|
||||||
if (hasTextContent) {
|
|
||||||
results.push({
|
|
||||||
id: `chatcmpl-${state.messageId}`,
|
|
||||||
object: "chat.completion.chunk",
|
|
||||||
created: Math.floor(Date.now() / 1000),
|
|
||||||
model: state.model,
|
|
||||||
choices: [{
|
|
||||||
index: 0,
|
|
||||||
delta: isThought
|
|
||||||
? { reasoning_content: part.text }
|
|
||||||
: { content: part.text },
|
|
||||||
finish_reason: null
|
|
||||||
}]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasFunctionCall) {
|
|
||||||
const rawName = part.functionCall.name;
|
|
||||||
// Restore original tool name from mapping (AG cloaking)
|
|
||||||
const fcName = state.toolNameMap?.get(rawName) || rawName;
|
|
||||||
const fcArgs = part.functionCall.args || {};
|
|
||||||
const toolCallIndex = state.functionIndex++;
|
|
||||||
|
|
||||||
const toolCall = {
|
|
||||||
id: `${fcName}-${Date.now()}-${toolCallIndex}`,
|
|
||||||
index: toolCallIndex,
|
|
||||||
type: "function",
|
|
||||||
function: {
|
|
||||||
name: fcName,
|
|
||||||
arguments: JSON.stringify(fcArgs)
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
state.toolCalls.set(toolCallIndex, toolCall);
|
|
||||||
|
|
||||||
results.push({
|
|
||||||
id: `chatcmpl-${state.messageId}`,
|
|
||||||
object: "chat.completion.chunk",
|
|
||||||
created: Math.floor(Date.now() / 1000),
|
|
||||||
model: state.model,
|
|
||||||
choices: [{
|
|
||||||
index: 0,
|
|
||||||
delta: { tool_calls: [toolCall] },
|
|
||||||
finish_reason: null
|
|
||||||
}]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Text content (non-thinking)
|
const hasTextContent = part.text !== undefined && part.text !== "";
|
||||||
if (part.text !== undefined && part.text !== "") {
|
const hasFunctionCall = !!part.functionCall;
|
||||||
|
|
||||||
|
// Emit reasoning/thought text
|
||||||
|
if (hasTextContent) {
|
||||||
results.push({
|
results.push({
|
||||||
id: `chatcmpl-${state.messageId}`,
|
id: `chatcmpl-${state.messageId}`,
|
||||||
object: "chat.completion.chunk",
|
object: "chat.completion.chunk",
|
||||||
@@ -101,22 +60,29 @@ export function geminiToOpenAIResponse(chunk, state) {
|
|||||||
model: state.model,
|
model: state.model,
|
||||||
choices: [{
|
choices: [{
|
||||||
index: 0,
|
index: 0,
|
||||||
delta: { content: part.text },
|
delta: isThought
|
||||||
|
? { reasoning_content: part.text }
|
||||||
|
: { content: part.text },
|
||||||
finish_reason: null
|
finish_reason: null
|
||||||
}]
|
}]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Function call
|
// Emit function call, attaching the best available thoughtSignature
|
||||||
if (part.functionCall) {
|
if (hasFunctionCall) {
|
||||||
const rawName = part.functionCall.name;
|
const fcName = part.functionCall.name;
|
||||||
// Restore original tool name from mapping (AG cloaking)
|
|
||||||
const fcName = state.toolNameMap?.get(rawName) || rawName;
|
|
||||||
const fcArgs = part.functionCall.args || {};
|
const fcArgs = part.functionCall.args || {};
|
||||||
const toolCallIndex = state.functionIndex++;
|
const toolCallIndex = state.functionIndex++;
|
||||||
|
// Use signature from this part, or the one carried from a preceding part
|
||||||
|
const thoughtSig = partThoughtSig || state.pendingThoughtSignature || "";
|
||||||
|
if (thoughtSig) state.pendingThoughtSignature = null; // consumed
|
||||||
|
// Encode signature using _TSIG_ delimiter and base64url for safe tool_call ID
|
||||||
|
const toolCallId = thoughtSig
|
||||||
|
? `${fcName}-${Date.now()}-${toolCallIndex}_TSIG_${toBase64Url(thoughtSig)}`
|
||||||
|
: `${fcName}-${Date.now()}-${toolCallIndex}`;
|
||||||
|
|
||||||
const toolCall = {
|
const toolCall = {
|
||||||
id: `${fcName}-${Date.now()}-${toolCallIndex}`,
|
id: toolCallId,
|
||||||
index: toolCallIndex,
|
index: toolCallIndex,
|
||||||
type: "function",
|
type: "function",
|
||||||
function: {
|
function: {
|
||||||
@@ -124,9 +90,9 @@ export function geminiToOpenAIResponse(chunk, state) {
|
|||||||
arguments: JSON.stringify(fcArgs)
|
arguments: JSON.stringify(fcArgs)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
state.toolCalls.set(toolCallIndex, toolCall);
|
state.toolCalls.set(toolCallIndex, toolCall);
|
||||||
|
|
||||||
results.push({
|
results.push({
|
||||||
id: `chatcmpl-${state.messageId}`,
|
id: `chatcmpl-${state.messageId}`,
|
||||||
object: "chat.completion.chunk",
|
object: "chat.completion.chunk",
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ export async function POST(request) {
|
|||||||
const { model } = await request.json();
|
const { model } = await request.json();
|
||||||
if (!model) return NextResponse.json({ error: "Model required" }, { status: 400 });
|
if (!model) return NextResponse.json({ error: "Model required" }, { status: 400 });
|
||||||
|
|
||||||
const url = new URL(request.url);
|
const baseUrl = process.env.BASE_URL ||
|
||||||
const baseUrl = `${url.protocol}//${url.host}`;
|
(() => { const u = new URL(request.url); return `${u.protocol}//${u.host}`; })();
|
||||||
|
|
||||||
// Get an active internal API key for auth (if requireApiKey is enabled)
|
// Get an active internal API key for auth (if requireApiKey is enabled)
|
||||||
let apiKey = null;
|
let apiKey = null;
|
||||||
|
|||||||
@@ -40,11 +40,6 @@ if (!isCloud && !fs.existsSync(DATA_DIR)) {
|
|||||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Seed db.json with defaults on first run so proper-lockfile never hits ENOENT
|
|
||||||
if (!isCloud && DB_FILE && !fs.existsSync(DB_FILE)) {
|
|
||||||
fs.writeFileSync(DB_FILE, JSON.stringify(defaultData, null, 2));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default data structure
|
// Default data structure
|
||||||
const defaultData = {
|
const defaultData = {
|
||||||
providerConnections: [],
|
providerConnections: [],
|
||||||
@@ -63,7 +58,7 @@ const defaultData = {
|
|||||||
comboStrategy: "fallback",
|
comboStrategy: "fallback",
|
||||||
comboStrategies: {},
|
comboStrategies: {},
|
||||||
requireLogin: true,
|
requireLogin: true,
|
||||||
enableObservability: false,
|
observabilityEnabled: true,
|
||||||
observabilityMaxRecords: 1000,
|
observabilityMaxRecords: 1000,
|
||||||
observabilityBatchSize: 20,
|
observabilityBatchSize: 20,
|
||||||
observabilityFlushIntervalMs: 5000,
|
observabilityFlushIntervalMs: 5000,
|
||||||
@@ -75,6 +70,11 @@ const defaultData = {
|
|||||||
pricing: {} // NEW: pricing configuration
|
pricing: {} // NEW: pricing configuration
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Seed db.json with defaults on first run so proper-lockfile never hits ENOENT
|
||||||
|
if (!isCloud && DB_FILE && !fs.existsSync(DB_FILE)) {
|
||||||
|
fs.writeFileSync(DB_FILE, JSON.stringify(defaultData, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
function cloneDefaultData() {
|
function cloneDefaultData() {
|
||||||
return {
|
return {
|
||||||
providerConnections: [],
|
providerConnections: [],
|
||||||
@@ -93,7 +93,7 @@ function cloneDefaultData() {
|
|||||||
comboStrategy: "fallback",
|
comboStrategy: "fallback",
|
||||||
comboStrategies: {},
|
comboStrategies: {},
|
||||||
requireLogin: true,
|
requireLogin: true,
|
||||||
enableObservability: false,
|
observabilityEnabled: true,
|
||||||
observabilityMaxRecords: 1000,
|
observabilityMaxRecords: 1000,
|
||||||
observabilityBatchSize: 20,
|
observabilityBatchSize: 20,
|
||||||
observabilityFlushIntervalMs: 5000,
|
observabilityFlushIntervalMs: 5000,
|
||||||
@@ -167,31 +167,50 @@ function ensureDbShape(data) {
|
|||||||
// Singleton instance
|
// Singleton instance
|
||||||
let dbInstance = null;
|
let dbInstance = null;
|
||||||
|
|
||||||
// In-memory read cache to avoid redundant disk reads under high load
|
// Lock options for proper-lockfile (increased retries for multi-process robustness)
|
||||||
const DB_CACHE_TTL = 500; // ms
|
|
||||||
let dbCache = { data: null, ts: 0 };
|
|
||||||
|
|
||||||
// Serialize all DB operations (reads on cache-miss + writes) to prevent race conditions
|
|
||||||
let dbQueue = Promise.resolve();
|
|
||||||
|
|
||||||
function withDbLock(fn) {
|
|
||||||
const next = dbQueue.then(fn, fn);
|
|
||||||
dbQueue = next.catch(() => {});
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lock options for proper-lockfile
|
|
||||||
const LOCK_OPTIONS = {
|
const LOCK_OPTIONS = {
|
||||||
retries: {
|
retries: {
|
||||||
retries: 5,
|
retries: 15,
|
||||||
minTimeout: 100,
|
minTimeout: 50,
|
||||||
maxTimeout: 2000,
|
maxTimeout: 3000,
|
||||||
},
|
},
|
||||||
stale: 10000, // Consider lock stale after 10s
|
stale: 10000, // Consider lock stale after 10s
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// In-process mutex to serialize DB access within the same process
|
||||||
|
// This prevents ELOCKED when concurrent requests in the same process try to acquire the file lock
|
||||||
|
class LocalMutex {
|
||||||
|
constructor() {
|
||||||
|
this._queue = [];
|
||||||
|
this._locked = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async acquire() {
|
||||||
|
if (!this._locked) {
|
||||||
|
this._locked = true;
|
||||||
|
return () => this._release();
|
||||||
|
}
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
this._queue.push(resolve);
|
||||||
|
}).then(() => () => this._release());
|
||||||
|
}
|
||||||
|
|
||||||
|
_release() {
|
||||||
|
const next = this._queue.shift();
|
||||||
|
if (next) {
|
||||||
|
next();
|
||||||
|
} else {
|
||||||
|
this._locked = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton local mutex for in-process serialization
|
||||||
|
const localMutex = new LocalMutex();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Safely read database with file locking
|
* Safely read database with file locking
|
||||||
|
* Uses local mutex first to serialize within-process access, then file lock for cross-process
|
||||||
*/
|
*/
|
||||||
async function safeRead(db) {
|
async function safeRead(db) {
|
||||||
if (isCloud) {
|
if (isCloud) {
|
||||||
@@ -199,9 +218,11 @@ async function safeRead(db) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Acquire local mutex first (in-process serialization)
|
||||||
|
const releaseLocal = await localMutex.acquire();
|
||||||
let release = null;
|
let release = null;
|
||||||
try {
|
try {
|
||||||
// Acquire lock before reading
|
// Acquire file lock (cross-process serialization)
|
||||||
release = await lockfile.lock(DB_FILE, LOCK_OPTIONS);
|
release = await lockfile.lock(DB_FILE, LOCK_OPTIONS);
|
||||||
await db.read();
|
await db.read();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -218,12 +239,13 @@ async function safeRead(db) {
|
|||||||
// Ignore unlock errors
|
// Ignore unlock errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
releaseLocal();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Safely write database with file locking.
|
* Safely write database with file locking
|
||||||
* Always invalidates read cache so next read reflects the latest state.
|
* Uses local mutex first to serialize within-process access, then file lock for cross-process
|
||||||
*/
|
*/
|
||||||
async function safeWrite(db) {
|
async function safeWrite(db) {
|
||||||
if (isCloud) {
|
if (isCloud) {
|
||||||
@@ -231,12 +253,13 @@ async function safeWrite(db) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Acquire local mutex first (in-process serialization)
|
||||||
|
const releaseLocal = await localMutex.acquire();
|
||||||
let release = null;
|
let release = null;
|
||||||
try {
|
try {
|
||||||
|
// Acquire file lock (cross-process serialization)
|
||||||
release = await lockfile.lock(DB_FILE, LOCK_OPTIONS);
|
release = await lockfile.lock(DB_FILE, LOCK_OPTIONS);
|
||||||
await db.write();
|
await db.write();
|
||||||
// Invalidate cache immediately after a successful write
|
|
||||||
dbCache.ts = 0;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.code === "ELOCKED") {
|
if (error.code === "ELOCKED") {
|
||||||
console.warn("[DB] File is locked, retrying write...");
|
console.warn("[DB] File is locked, retrying write...");
|
||||||
@@ -251,77 +274,55 @@ async function safeWrite(db) {
|
|||||||
// Ignore unlock errors
|
// Ignore unlock errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
releaseLocal();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get database instance (singleton).
|
* Get database instance (singleton)
|
||||||
*
|
|
||||||
* Hot path: if cache is fresh, return immediately without any I/O or queuing.
|
|
||||||
* Cold path: serialize via withDbLock to prevent concurrent reads from racing
|
|
||||||
* against in-flight writes (eliminates lost-update race condition).
|
|
||||||
*/
|
*/
|
||||||
export async function getDb() {
|
export async function getDb() {
|
||||||
if (isCloud) {
|
if (isCloud) {
|
||||||
|
// Return in-memory DB for Workers
|
||||||
if (!dbInstance) {
|
if (!dbInstance) {
|
||||||
const data = cloneDefaultData();
|
const data = cloneDefaultData();
|
||||||
dbInstance = new Low({ read: async () => {}, write: async () => {} }, data);
|
dbInstance = new Low({ read: async () => { }, write: async () => { } }, data);
|
||||||
dbInstance.data = data;
|
dbInstance.data = data;
|
||||||
}
|
}
|
||||||
return dbInstance;
|
return dbInstance;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hot path: cache hit — no lock, no disk I/O
|
if (!dbInstance) {
|
||||||
if (dbCache.data && Date.now() - dbCache.ts < DB_CACHE_TTL) {
|
const adapter = new JSONFile(DB_FILE);
|
||||||
if (!dbInstance) {
|
dbInstance = new Low(adapter, cloneDefaultData());
|
||||||
const adapter = new JSONFile(DB_FILE);
|
|
||||||
dbInstance = new Low(adapter, dbCache.data);
|
|
||||||
}
|
|
||||||
dbInstance.data = dbCache.data;
|
|
||||||
return dbInstance;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cold path: serialize with writes to prevent race conditions
|
// Always read latest disk state to avoid stale singleton data across route workers.
|
||||||
return withDbLock(async () => {
|
try {
|
||||||
// Re-check cache inside lock — another queued task may have already loaded it
|
await safeRead(dbInstance);
|
||||||
if (dbCache.data && Date.now() - dbCache.ts < DB_CACHE_TTL) {
|
} catch (error) {
|
||||||
dbInstance.data = dbCache.data;
|
if (error instanceof SyntaxError) {
|
||||||
return dbInstance;
|
console.warn('[DB] Corrupt JSON detected, resetting to defaults...');
|
||||||
}
|
|
||||||
|
|
||||||
if (!dbInstance) {
|
|
||||||
const adapter = new JSONFile(DB_FILE);
|
|
||||||
dbInstance = new Low(adapter, cloneDefaultData());
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await safeRead(dbInstance);
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof SyntaxError) {
|
|
||||||
console.warn("[DB] Corrupt JSON detected, resetting to defaults...");
|
|
||||||
dbInstance.data = cloneDefaultData();
|
|
||||||
await safeWrite(dbInstance);
|
|
||||||
} else {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize/migrate missing keys for older DB schema versions
|
|
||||||
if (!dbInstance.data) {
|
|
||||||
dbInstance.data = cloneDefaultData();
|
dbInstance.data = cloneDefaultData();
|
||||||
await safeWrite(dbInstance);
|
await safeWrite(dbInstance);
|
||||||
} else {
|
} else {
|
||||||
const { data, changed } = ensureDbShape(dbInstance.data);
|
throw error;
|
||||||
dbInstance.data = data;
|
|
||||||
if (changed) await safeWrite(dbInstance);
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Update cache after successful read
|
// Initialize/migrate missing keys for older DB schema versions.
|
||||||
dbCache.data = dbInstance.data;
|
if (!dbInstance.data) {
|
||||||
dbCache.ts = Date.now();
|
dbInstance.data = cloneDefaultData();
|
||||||
|
await safeWrite(dbInstance);
|
||||||
|
} else {
|
||||||
|
const { data, changed } = ensureDbShape(dbInstance.data);
|
||||||
|
dbInstance.data = data;
|
||||||
|
if (changed) {
|
||||||
|
await safeWrite(dbInstance);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return dbInstance;
|
return dbInstance;
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ Provider Connections ============
|
// ============ Provider Connections ============
|
||||||
@@ -332,17 +333,17 @@ export async function getDb() {
|
|||||||
export async function getProviderConnections(filter = {}) {
|
export async function getProviderConnections(filter = {}) {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
let connections = db.data.providerConnections || [];
|
let connections = db.data.providerConnections || [];
|
||||||
|
|
||||||
if (filter.provider) {
|
if (filter.provider) {
|
||||||
connections = connections.filter(c => c.provider === filter.provider);
|
connections = connections.filter(c => c.provider === filter.provider);
|
||||||
}
|
}
|
||||||
if (filter.isActive !== undefined) {
|
if (filter.isActive !== undefined) {
|
||||||
connections = connections.filter(c => c.isActive === filter.isActive);
|
connections = connections.filter(c => c.isActive === filter.isActive);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort by priority (lower = higher priority)
|
// Sort by priority (lower = higher priority)
|
||||||
connections.sort((a, b) => (a.priority || 999) - (b.priority || 999));
|
connections.sort((a, b) => (a.priority || 999) - (b.priority || 999));
|
||||||
|
|
||||||
return connections;
|
return connections;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,12 +376,12 @@ export async function getProviderNodeById(id) {
|
|||||||
*/
|
*/
|
||||||
export async function createProviderNode(data) {
|
export async function createProviderNode(data) {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
|
|
||||||
// Initialize providerNodes if undefined (backward compatibility)
|
// Initialize providerNodes if undefined (backward compatibility)
|
||||||
if (!db.data.providerNodes) {
|
if (!db.data.providerNodes) {
|
||||||
db.data.providerNodes = [];
|
db.data.providerNodes = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
const node = {
|
const node = {
|
||||||
@@ -408,7 +409,7 @@ export async function updateProviderNode(id, data) {
|
|||||||
if (!db.data.providerNodes) {
|
if (!db.data.providerNodes) {
|
||||||
db.data.providerNodes = [];
|
db.data.providerNodes = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const index = db.data.providerNodes.findIndex((node) => node.id === id);
|
const index = db.data.providerNodes.findIndex((node) => node.id === id);
|
||||||
|
|
||||||
if (index === -1) return null;
|
if (index === -1) return null;
|
||||||
@@ -569,7 +570,7 @@ export async function getProviderConnectionById(id) {
|
|||||||
export async function createProviderConnection(data) {
|
export async function createProviderConnection(data) {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
// Check for existing connection with same provider and email (for OAuth)
|
// Check for existing connection with same provider and email (for OAuth)
|
||||||
// or same provider and name (for API key)
|
// or same provider and name (for API key)
|
||||||
let existingIndex = -1;
|
let existingIndex = -1;
|
||||||
@@ -582,7 +583,7 @@ export async function createProviderConnection(data) {
|
|||||||
c => c.provider === data.provider && c.authType === "apikey" && c.name === data.name
|
c => c.provider === data.provider && c.authType === "apikey" && c.name === data.name
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If exists, update instead of create
|
// If exists, update instead of create
|
||||||
if (existingIndex !== -1) {
|
if (existingIndex !== -1) {
|
||||||
db.data.providerConnections[existingIndex] = {
|
db.data.providerConnections[existingIndex] = {
|
||||||
@@ -593,7 +594,7 @@ export async function createProviderConnection(data) {
|
|||||||
await safeWrite(db);
|
await safeWrite(db);
|
||||||
return db.data.providerConnections[existingIndex];
|
return db.data.providerConnections[existingIndex];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate name for OAuth if not provided
|
// Generate name for OAuth if not provided
|
||||||
let connectionName = data.name || null;
|
let connectionName = data.name || null;
|
||||||
if (!connectionName && data.authType === "oauth") {
|
if (!connectionName && data.authType === "oauth") {
|
||||||
@@ -617,7 +618,7 @@ export async function createProviderConnection(data) {
|
|||||||
const maxPriority = providerConnections.reduce((max, c) => Math.max(max, c.priority || 0), 0);
|
const maxPriority = providerConnections.reduce((max, c) => Math.max(max, c.priority || 0), 0);
|
||||||
connectionPriority = maxPriority + 1;
|
connectionPriority = maxPriority + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new connection - only save fields with actual values
|
// Create new connection - only save fields with actual values
|
||||||
const connection = {
|
const connection = {
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
@@ -638,7 +639,7 @@ export async function createProviderConnection(data) {
|
|||||||
"lastTested", "lastError", "lastErrorAt", "rateLimitedUntil", "expiresIn", "errorCode",
|
"lastTested", "lastError", "lastErrorAt", "rateLimitedUntil", "expiresIn", "errorCode",
|
||||||
"consecutiveUseCount"
|
"consecutiveUseCount"
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const field of optionalFields) {
|
for (const field of optionalFields) {
|
||||||
if (data[field] !== undefined && data[field] !== null) {
|
if (data[field] !== undefined && data[field] !== null) {
|
||||||
connection[field] = data[field];
|
connection[field] = data[field];
|
||||||
@@ -649,9 +650,12 @@ export async function createProviderConnection(data) {
|
|||||||
if (data.providerSpecificData && Object.keys(data.providerSpecificData).length > 0) {
|
if (data.providerSpecificData && Object.keys(data.providerSpecificData).length > 0) {
|
||||||
connection.providerSpecificData = data.providerSpecificData;
|
connection.providerSpecificData = data.providerSpecificData;
|
||||||
}
|
}
|
||||||
|
|
||||||
db.data.providerConnections.push(connection);
|
db.data.providerConnections.push(connection);
|
||||||
await reorderProviderConnections(data.provider, db);
|
await safeWrite(db);
|
||||||
|
|
||||||
|
// Reorder to ensure consistency
|
||||||
|
await reorderProviderConnections(data.provider);
|
||||||
|
|
||||||
return connection;
|
return connection;
|
||||||
}
|
}
|
||||||
@@ -673,11 +677,11 @@ export async function updateProviderConnection(id, data) {
|
|||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Reorder if priority was changed, reuse same db instance to avoid double-read
|
await safeWrite(db);
|
||||||
|
|
||||||
|
// Reorder if priority was changed
|
||||||
if (data.priority !== undefined) {
|
if (data.priority !== undefined) {
|
||||||
await reorderProviderConnections(providerId, db);
|
await reorderProviderConnections(providerId);
|
||||||
} else {
|
|
||||||
await safeWrite(db);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return db.data.providerConnections[index];
|
return db.data.providerConnections[index];
|
||||||
@@ -695,35 +699,37 @@ export async function deleteProviderConnection(id) {
|
|||||||
const providerId = db.data.providerConnections[index].provider;
|
const providerId = db.data.providerConnections[index].provider;
|
||||||
|
|
||||||
db.data.providerConnections.splice(index, 1);
|
db.data.providerConnections.splice(index, 1);
|
||||||
// Reorder to fill gaps, reuse same db instance to avoid double-read
|
await safeWrite(db);
|
||||||
await reorderProviderConnections(providerId, db);
|
|
||||||
|
// Reorder to fill gaps
|
||||||
|
await reorderProviderConnections(providerId);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reorder provider connections to ensure unique, sequential priorities.
|
* Reorder provider connections to ensure unique, sequential priorities
|
||||||
* Accepts an existing db instance to avoid redundant getDb() calls and
|
|
||||||
* prevent double-read race conditions within the same write operation.
|
|
||||||
*/
|
*/
|
||||||
export async function reorderProviderConnections(providerId, db) {
|
export async function reorderProviderConnections(providerId) {
|
||||||
const instance = db || (await getDb());
|
const db = await getDb();
|
||||||
if (!instance.data.providerConnections) return;
|
if (!db.data.providerConnections) return;
|
||||||
|
|
||||||
const providerConnections = instance.data.providerConnections
|
const providerConnections = db.data.providerConnections
|
||||||
.filter(c => c.provider === providerId)
|
.filter(c => c.provider === providerId)
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
|
// Sort by priority first
|
||||||
const pDiff = (a.priority || 0) - (b.priority || 0);
|
const pDiff = (a.priority || 0) - (b.priority || 0);
|
||||||
if (pDiff !== 0) return pDiff;
|
if (pDiff !== 0) return pDiff;
|
||||||
// Use updatedAt as tie-breaker (newer first)
|
// Use updatedAt as tie-breaker (newer first)
|
||||||
return new Date(b.updatedAt || 0) - new Date(a.updatedAt || 0);
|
return new Date(b.updatedAt || 0) - new Date(a.updatedAt || 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Re-assign sequential priorities
|
||||||
providerConnections.forEach((conn, index) => {
|
providerConnections.forEach((conn, index) => {
|
||||||
conn.priority = index + 1;
|
conn.priority = index + 1;
|
||||||
});
|
});
|
||||||
|
|
||||||
await safeWrite(instance);
|
await safeWrite(db);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ Model Aliases ============
|
// ============ Model Aliases ============
|
||||||
@@ -802,7 +808,7 @@ export async function getComboByName(name) {
|
|||||||
export async function createCombo(data) {
|
export async function createCombo(data) {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
if (!db.data.combos) db.data.combos = [];
|
if (!db.data.combos) db.data.combos = [];
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const combo = {
|
const combo = {
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
@@ -811,7 +817,7 @@ export async function createCombo(data) {
|
|||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
};
|
};
|
||||||
|
|
||||||
db.data.combos.push(combo);
|
db.data.combos.push(combo);
|
||||||
await safeWrite(db);
|
await safeWrite(db);
|
||||||
return combo;
|
return combo;
|
||||||
@@ -823,16 +829,16 @@ export async function createCombo(data) {
|
|||||||
export async function updateCombo(id, data) {
|
export async function updateCombo(id, data) {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
if (!db.data.combos) db.data.combos = [];
|
if (!db.data.combos) db.data.combos = [];
|
||||||
|
|
||||||
const index = db.data.combos.findIndex(c => c.id === id);
|
const index = db.data.combos.findIndex(c => c.id === id);
|
||||||
if (index === -1) return null;
|
if (index === -1) return null;
|
||||||
|
|
||||||
db.data.combos[index] = {
|
db.data.combos[index] = {
|
||||||
...db.data.combos[index],
|
...db.data.combos[index],
|
||||||
...data,
|
...data,
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|
||||||
await safeWrite(db);
|
await safeWrite(db);
|
||||||
return db.data.combos[index];
|
return db.data.combos[index];
|
||||||
}
|
}
|
||||||
@@ -843,10 +849,10 @@ export async function updateCombo(id, data) {
|
|||||||
export async function deleteCombo(id) {
|
export async function deleteCombo(id) {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
if (!db.data.combos) return false;
|
if (!db.data.combos) return false;
|
||||||
|
|
||||||
const index = db.data.combos.findIndex(c => c.id === id);
|
const index = db.data.combos.findIndex(c => c.id === id);
|
||||||
if (index === -1) return false;
|
if (index === -1) return false;
|
||||||
|
|
||||||
db.data.combos.splice(index, 1);
|
db.data.combos.splice(index, 1);
|
||||||
await safeWrite(db);
|
await safeWrite(db);
|
||||||
return true;
|
return true;
|
||||||
@@ -883,14 +889,14 @@ export async function createApiKey(name, machineId) {
|
|||||||
if (!machineId) {
|
if (!machineId) {
|
||||||
throw new Error("machineId is required");
|
throw new Error("machineId is required");
|
||||||
}
|
}
|
||||||
|
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
// Always use new format: sk-{machineId}-{keyId}-{crc8}
|
// Always use new format: sk-{machineId}-{keyId}-{crc8}
|
||||||
const { generateApiKeyWithMachine } = await import("@/shared/utils/apiKey");
|
const { generateApiKeyWithMachine } = await import("@/shared/utils/apiKey");
|
||||||
const result = generateApiKeyWithMachine(machineId);
|
const result = generateApiKeyWithMachine(machineId);
|
||||||
|
|
||||||
const apiKey = {
|
const apiKey = {
|
||||||
id: uuidv4(),
|
id: uuidv4(),
|
||||||
name: name,
|
name: name,
|
||||||
@@ -899,10 +905,10 @@ export async function createApiKey(name, machineId) {
|
|||||||
isActive: true,
|
isActive: true,
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
};
|
};
|
||||||
|
|
||||||
db.data.apiKeys.push(apiKey);
|
db.data.apiKeys.push(apiKey);
|
||||||
await safeWrite(db);
|
await safeWrite(db);
|
||||||
|
|
||||||
return apiKey;
|
return apiKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -912,12 +918,12 @@ export async function createApiKey(name, machineId) {
|
|||||||
export async function deleteApiKey(id) {
|
export async function deleteApiKey(id) {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const index = db.data.apiKeys.findIndex(k => k.id === id);
|
const index = db.data.apiKeys.findIndex(k => k.id === id);
|
||||||
|
|
||||||
if (index === -1) return false;
|
if (index === -1) return false;
|
||||||
|
|
||||||
db.data.apiKeys.splice(index, 1);
|
db.data.apiKeys.splice(index, 1);
|
||||||
await safeWrite(db);
|
await safeWrite(db);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user