Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
424d559
🫂 fix: Scope People Picker Searches to Permitted Types (#15878)
danny-avila Sep 13, 2026
e43fed2
🚄 perf: Linearize Conversation Import Lineage (#15881)
danny-avila Sep 13, 2026
9bf51fc
🧷 style: Sidebar Row Controls, Pinned Unpin Badge and Drag Rules (#15…
berry-13 Sep 13, 2026
9cf33cc
🧿 fix: Bind Login Flows to the Browser That Started Them (#15880)
danny-avila Sep 13, 2026
d6f8b2e
🏷️ fix: Keep a Configured Sender Label From Revealing the Model on Ho…
aruj0 Sep 13, 2026
696bcb6
🏦 fix: Reserve Credits for In-Flight Requests (#15879)
danny-avila Sep 13, 2026
566e0a8
🪢 fix: Withhold the Editor's Rerun Where No User Turn Can Be Replayed…
berry-13 Sep 13, 2026
1453258
🩹 ci: Invoke ESLint and Prettier Binaries Directly in Static Checks (…
danny-avila Sep 13, 2026
6e3295e
⏱️ ci: Cut the ESLint Config Sweep to the Rules That Can Regress (#15…
danny-avila Sep 13, 2026
c640d80
🪟 fix: Opaque Header Controls Over the Chat Gradient (#15894)
berry-13 Sep 13, 2026
f6335cb
💰 fix: Bill Stopped Agent Turns From Provider Usage (#15889)
danny-avila Sep 14, 2026
91e00bb
🧷 fix: Mark a Failed Compaction So Its Rerun Controls Stay Withheld (…
berry-13 Sep 14, 2026
8e6e6f0
🪧 fix: Emit the Configured-Footer Answer With the HTML Shell (#15886)
berry-13 Sep 14, 2026
e95e696
🧾 fix: Count the Tool Results a Tool-Limit Stop Retains (#15893)
berry-13 Sep 14, 2026
f3991f2
🔡 style: Size Image Tool Labels Like Every Other Activity Row (#15898)
danny-avila Sep 14, 2026
c2f3d0c
🎨 style: Color Image Tool Labels With the Activity Rows' Secondary Ro…
danny-avila Sep 14, 2026
854645f
🔁 feat: Retry OpenID Discovery After Startup Failures (#15899)
danny-avila Sep 14, 2026
14c5e8d
🧪 test: Wait for the Durable Task Input Before Reserving a Child Cont…
danny-avila Sep 14, 2026
3c39ea5
📐 fix: Accept Custom WIDTHxHEIGHT Sizes in OpenAI Image Tools (#15904)
danny-avila Sep 14, 2026
28cd3db
💉 chore: Upgrade `@azure/msal-node` to v5.6.0 (#15903)
danny-avila Sep 14, 2026
fa3dff1
🎖️ chore: Promote Helm Chart to 2.0.11 (#15905)
danny-avila Sep 14, 2026
7fe9a45
🧵 ci: Keep a Floor of Two Jest Workers in CI (#15819)
jomspk Sep 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -987,6 +987,12 @@ OPENID_REUSE_TOKENS=
#is not rotated/revoked out from under downstream consumers (e.g. MCP servers that introspect the bearer).
#When OPENID_REUSE_TOKENS=true, the OpenID session cookie maxAge is extended to at least this value.
OPENID_REUSE_MAX_SESSION_AGE_MS=
# Discovery attempts during startup (0-100, default 1). Set to 0 to use background retries only.
# librechat.yaml `registration.openidDiscovery.startupAttempts` takes precedence.
OPENID_DISCOVERY_RETRY_ATTEMPTS=
# Delay in milliseconds between startup and background discovery retries (100-3600000, default 5000).
# librechat.yaml `registration.openidDiscovery.retryDelayMs` takes precedence.
OPENID_DISCOVERY_RETRY_DELAY_MS=
#Short recovery window for a rotated OpenID refresh token while LibreChat publishes the refreshed session. Default 60000 ms (1 min).
#Accepts arithmetic expressions. Increase only when slow session persistence or cross-replica publication needs more time.
OPENID_REFRESH_BRIDGE_GRACE_MS=
Expand Down
27 changes: 23 additions & 4 deletions .github/workflows/static-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,11 @@ jobs:
# Run ESLint
# --no-warn-ignored: changed files under config-ignored paths
# (e.g. packages/data-schemas/misc/**) must not fail --max-warnings=0
npx eslint --no-error-on-unmatched-pattern \
# Invoke the installed binary, not `npx`: npm exec joins the whole
# command into one shell string, and Linux rejects a single argv
# string over 128 KiB (MAX_ARG_STRLEN) — past ~2,200 changed files
# npx dies with exit 249 and no output.
node_modules/.bin/eslint --no-error-on-unmatched-pattern \
--config eslint.config.mjs \
--no-warn-ignored \
--max-warnings=0 \
Expand Down Expand Up @@ -162,7 +166,7 @@ jobs:
# `prettier --check` exits non-zero if any file would be reformatted.
# Suggest the local fix in the failure message so contributors aren't
# left guessing how to resolve.
if ! npx prettier --check --no-error-on-unmatched-pattern -- "${CHANGED_FILES[@]}"; then
if ! node_modules/.bin/prettier --check --no-error-on-unmatched-pattern -- "${CHANGED_FILES[@]}"; then
echo ""
echo "::error::Prettier formatting drift detected. Fix locally with:"
echo "::error:: npx prettier --write <files>"
Expand Down Expand Up @@ -235,7 +239,7 @@ jobs:
if: always() && steps.paths.outputs.eslint_config == 'true'
continue-on-error: true
run: |
npx eslint --config eslint.config.mjs \
node_modules/.bin/eslint --config eslint.config.mjs \
api/server/index.js client/src/main.jsx packages/api/src/index.ts

- name: Restore data-provider build cache
Expand Down Expand Up @@ -808,8 +812,16 @@ jobs:
run: |
run_sweep() {
set +e
# Two rules are switched off for the sweep only: `prettier/prettier`
# reformats every file (formatting drift is caught per changed file
# by the Static checks job and is not a config regression), and
# `import/no-cycle` walks the whole import graph from every file
# while config/circular-deps.mjs already owns cycle detection.
# Together they were ~85% of a full-tree lint.
timeout -k 15 "$ESLINT_SWEEP_BUDGET_SECONDS" \
npx eslint --config "$1" api client packages -f json -o "$2"
node_modules/.bin/eslint --config "$1" api client packages -f json -o "$2" \
--rule 'prettier/prettier: off' \
--rule 'import/no-cycle: off'
local status=$?
set -e
# 124 = timeout sent TERM; 137 = it escalated to KILL.
Expand Down Expand Up @@ -837,6 +849,13 @@ jobs:
# flat-config files/ignores patterns and plugin imports resolve
# relative to the config's own directory, so a temp-dir copy would
# scope to nothing and the comparison would pass vacuously.
# An unchanged config cannot regress: the head sweep above already
# proved this workflow still runs it, so a second identical sweep
# would only double the job's runtime.
if git diff --quiet "$BASE_SHA" HEAD -- eslint.config.mjs; then
echo "::notice title=ESLint sweep::eslint.config.mjs is unchanged from base; skipping the regression comparison."
exit 0
fi
trap 'rm -f eslint.config.base.mjs' EXIT
if ! git show "$BASE_SHA:eslint.config.mjs" > eslint.config.base.mjs 2>/dev/null; then
echo "::notice title=ESLint sweep::No eslint.config.mjs at base ref; skipping regression comparison."
Expand Down
23 changes: 19 additions & 4 deletions api/app/clients/BaseClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const {
collectModelBoundHistoricalFileIdState,
projectModelBoundSourceFiles,
isModelBoundAttachmentFile,
withBalanceReservations,
} = require('@librechat/api');
const {
Constants,
Expand Down Expand Up @@ -732,6 +733,18 @@ class BaseClient {
}

async sendMessage(message, opts = {}) {
return withBalanceReservations((balanceReservations) =>
this.sendReservedMessage(message, opts, balanceReservations),
);
}

/**
* @param {string} message
* @param {Record<string, unknown>} opts
* @param {BalanceReservations} balanceReservations - Holds the balance reservation admitting
* this message; released once its usage is recorded, and by `sendMessage` on any other exit.
*/
async sendReservedMessage(message, opts, balanceReservations) {
const appConfig = this.options.req?.config;
/** @type {Promise<TMessage>} */
let userMessagePromise;
Expand Down Expand Up @@ -974,7 +987,7 @@ class BaseClient {
balanceConfig?.enabled &&
supportsBalanceCheck[this.options.endpointType ?? this.options.endpoint]
) {
await checkBalance(
const balanceAdmission = checkBalance(
{
req: this.options.req,
res: this.options.res,
Expand All @@ -990,12 +1003,13 @@ class BaseClient {
{
logViolation,
getMultiplier: db.getMultiplier,
findBalanceByUser: db.findBalanceByUser,
createAutoRefillTransaction: db.createAutoRefillTransaction,
reserveBalance: db.reserveBalance,
renewBalanceReservation: db.renewBalanceReservation,
releaseBalanceReservation: db.releaseBalanceReservation,
balanceConfig,
upsertBalanceFields: db.upsertBalanceFields,
},
);
await balanceReservations.track(balanceAdmission);
}

completionResult = await this.sendCompletion(payload, opts);
Expand Down Expand Up @@ -1155,6 +1169,7 @@ class BaseClient {
completionTokens,
});
}
await balanceReservations.release();

if (userMessagePromise) {
await userMessagePromise;
Expand Down
100 changes: 99 additions & 1 deletion api/app/clients/specs/BaseClient.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,9 +48,22 @@ jest.mock('~/models', () => ({
deleteFiles: jest.fn(),
getFiles: jest.fn(),
updateFileUsage: jest.fn(),
getMultiplier: jest.fn(),
reserveBalance: jest.fn(),
renewBalanceReservation: jest.fn(),
releaseBalanceReservation: jest.fn(),
}));

const { getConvo, getFiles, getMessages, saveConvo, saveMessage } = require('~/models');
const {
releaseBalanceReservation,
reserveBalance,
getMultiplier,
saveMessage,
getMessages,
saveConvo,
getFiles,
getConvo,
} = require('~/models');

jest.mock('@librechat/agents', () => {
const actual = jest.requireActual('@librechat/agents');
Expand Down Expand Up @@ -2225,6 +2238,91 @@ describe('BaseClient', () => {
});
});

describe('balance reservation lifecycle', () => {
let priorEndpoint;
let priorEndpointType;
let events;

beforeEach(() => {
priorEndpoint = TestClient.options.endpoint;
priorEndpointType = TestClient.options.endpointType;
TestClient.options.endpoint = EModelEndpoint.openAI;
delete TestClient.options.endpointType;
TestClient.options.req = { config: { balance: { enabled: true } } };

events = [];
getMultiplier.mockReturnValue(1);
reserveBalance.mockImplementation(async () => {
events.push('reserve');
return { reserved: true, balance: 1000 };
});
releaseBalanceReservation.mockImplementation(async () => {
events.push('release');
});
TestClient.sendCompletion.mockImplementation(async () => {
events.push('completion');
return { completion: 'Mock response text', metadata: undefined };
});
TestClient.getTokenCountForResponse = jest.fn().mockReturnValue(50);
TestClient.recordTokenUsage = jest.fn(async () => {
events.push('usage');
});
TestClient.buildMessages.mockReturnValue({
prompt: [],
tokenCountMap: { res: 50 },
});
});

afterEach(() => {
delete TestClient.options.req;
TestClient.options.endpoint = priorEndpoint;
TestClient.options.endpointType = priorEndpointType;
});

test('releases the reservation once the response usage is recorded, before persistence', async () => {
const beforeResponsePersistence = jest.fn(async () => {
events.push('persist');
return true;
});

await TestClient.sendMessage('Hello', { beforeResponsePersistence });

expect(events).toEqual(['reserve', 'completion', 'usage', 'release', 'persist']);
const [{ reservationId, amount }] = reserveBalance.mock.calls[0];
expect(releaseBalanceReservation).toHaveBeenCalledTimes(1);
expect(releaseBalanceReservation).toHaveBeenCalledWith({
user: TestClient.user,
reservationId,
amount,
});
});

test('releases the reservation when the completion fails', async () => {
TestClient.sendCompletion.mockRejectedValue(new Error('provider unavailable'));

await expect(TestClient.sendMessage('Hello', {})).rejects.toThrow('provider unavailable');

expect(events).toEqual(['reserve', 'release']);
});

test('releases the reservation when work after the completion fails', async () => {
TestClient.recordTokenUsage.mockRejectedValue(new Error('usage write failed'));

await expect(TestClient.sendMessage('Hello', {})).rejects.toThrow('usage write failed');

expect(events).toEqual(['reserve', 'completion', 'release']);
});

test('takes no reservation when the balance check refuses the request', async () => {
reserveBalance.mockResolvedValue({ reserved: false, balance: 0 });

await expect(TestClient.sendMessage('Hello', {})).rejects.toThrow();

expect(TestClient.sendCompletion).not.toHaveBeenCalled();
expect(releaseBalanceReservation).not.toHaveBeenCalled();
});
});

describe('getMessagesWithinTokenLimit with instructions', () => {
test('should always include instructions when present', async () => {
TestClient.maxContextTokens = 50;
Expand Down
4 changes: 3 additions & 1 deletion api/jest.config.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
const { maxWorkers } = require('../config/jest.workers.cjs');

const esModules = [
'openid-client',
'oauth4webapi',
Expand All @@ -21,7 +23,7 @@ module.exports = {
clearMocks: true,
roots: ['<rootDir>'],
coverageDirectory: 'coverage',
maxWorkers: '50%',
maxWorkers,
testTimeout: 30000, // 30 seconds timeout for all tests
setupFiles: ['./test/jestSetup.js', './test/__mocks__/logger.js'],
moduleNameMapper: {
Expand Down
Loading
Loading