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:
kwanLeeFrmVi 2026-03-30 02:53:57 +07:00 committed by decolua
parent 1c160cc8d9
commit 054facb08b
5 changed files with 193 additions and 216 deletions

View file

@ -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" },

View file

@ -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) {

View file

@ -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: {

View file

@ -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;

View file

@ -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 ============
@ -651,7 +652,10 @@ export async function createProviderConnection(data) {
} }
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 ============