`revokeCapability` is typed `Promise<{ deletedCount: number }>` so the
`?.` on `revokeResult?.deletedCount` only obscured that the value cannot
be nullish.
`CSV_BOM` was a literal U+FEFF character invisible in most editors. Now
spelled as the Unicode escape so readers can see the constant; the test
that asserts on the first emitted chunk uses the same escape.
Introduces MAX_AUDIT_EXPORT_ROWS (100k) and threads a `maxRows` option
through streamAuditLogEntries. The handler now passes the cap into the
stream so a careless admin script or a hostile auditor cannot pin a
Node worker and a Mongo cursor by exporting unbounded result sets.
Beyond 100k rows, callers should slice exports by from / to date.
Adds a methods-layer spec for the cap behavior, a handler-layer spec
that asserts the option is plumbed through, and a handler-layer spec
that exercises the streamAuditLogEntries-throws-after-headers-sent path
(catch block falls through to res.end instead of attempting JSON).
Documents on buildFilter that case-insensitive substring regex filters
(actorName, targetName, capability, search) cannot use a B-tree index
and degrade to a tenant-scoped partition scan, so deployments with
hundreds of thousands of audit rows per tenant should constrain those
queries with a date window.
A `?to=2025-01-15` filter previously stopped at midnight UTC of that
day, silently excluding everything that happened on January 15. The
`parseIsoDate` helper now widens a bare `YYYY-MM-DD` to 23:59:59.999Z
when called with the `end` boundary. Full ISO timestamps are honored
exactly, so callers that want minute-precision can still get it.
Also rejects inverted ranges (`from` later than `to`) with a 400 so
operators see a clear error instead of a silent empty result.
Fixes a misleading test that claimed to verify "idempotent even if the grant
does not exist" while mocking deletedCount: 1 (the grant DID exist). Replaces
it with the actual no-op scenario (deletedCount: 0) and adds an assertion
that recordAuditEntry is NOT called, since the whole point of the
deletedCount > 0 gate is to avoid fictitious revocation rows.
Adds a dedicated audit emission describe block covering: grant_assigned
emission with the actor name resolved from req.user, grant_removed
emission when deletedCount is positive, and the no-emission fallback when
recordAuditEntry is not configured. The actor-name assertions exercise the
name / username / email fallback chain in resolveUser.
The previous commit also added a `resolveTargetName` dep and an
emitAudit branch for USER/GROUP targets. The grants surface is ROLE-only
today (MANAGE_CAPABILITY_BY_TYPE has only PrincipalType.ROLE), so that
code path is unreachable from the handler. Removed the dep and the
branch; the audit row uses principalId as the target name, which is the
human-readable role name for ROLE principals. A comment in emitAudit
flags where to plumb resolveTargetName back in once USER and GROUP
grants are enabled.
For the actor name, JWT-authenticated `req.user` already carries `name`,
`username`, and `email`. `resolveUser` now derives the actor display name
from `req.user` directly and threads it through the caller context, so
every grant assign and revoke no longer triggers a separate `getUserById`
lookup.
For the target name, replaces the previous always-store-the-principalId
behavior (which buried opaque ObjectId strings in immutable audit rows
for USER and GROUP targets) with a `resolveTargetName` dep. ROLE
principals continue to use `principalId` directly because the SystemGrant
model stores role names there. USER and GROUP principals route through
the new dep, which in `api/server/routes/admin/grants.js` calls
`db.getUserById` or `db.findGroupById` respectively and falls back to
the principalId on miss or error so the audit row stays intelligible.
Drops the misleading "display name lookup happens in a later iteration"
comment.
Exports a runtime AUDIT_ACTIONS array from packages/data-schemas alongside the
AuditAction type so the Mongoose schema enum and the HTTP handler's whitelist
consume one source of truth instead of duplicating the literal pair.
Switches the grants handler's recordAuditEntry dep typing from a duplicated
inline object literal returning Promise<unknown> to the published
RecordAuditEntryInput type returning Promise<void>, and tightens the local
emitAudit args to AuditAction. Replaces the local ParsedFilters interface in
the audit-log handler with Omit<AuditLogFilters, 'offset' | 'limit'> to drop
the duplicate definition.
Drops the optional marker on AuditLog.createdAt. Mongoose always sets it at
insert time, so callers treating it as nullable were guarding against a state
the schema does not produce.
Adds a comment on api/server/routes/admin/audit.js noting that /export.csv
must precede /:id so a future contributor does not accidentally reorder them
into a 404 trap.
The audit-log routes were gated on `ACCESS_ADMIN`, which conflates "can log
into the admin panel" with "can see who granted what to whom." Anyone with
`ACCESS_ADMIN + READ_CONFIGS` (a config reviewer with no people-management
authority) could read the grant history of every user, group, and role —
information they have no need to know.
`READ_AUDIT_LOG` ('read:audit_log') is now an explicit, separately grantable
read capability with no MANAGE counterpart, matching the append-only nature
of the collection. `seedSystemGrants` iterates `Object.values(SystemCapabilities)`
so existing ADMIN-role seeds pick it up automatically on next startup.
This also makes an "auditor" persona possible: hold `ACCESS_ADMIN + READ_AUDIT_LOG`
without any MANAGE_* grants and you can review history without modifying anything.
Renamed the methods-layer constant from the generic `MAX_LIMIT` to
`MAX_AUDIT_LOG_LIMIT`, exported it through `@librechat/data-schemas`,
and consumed it from the handler instead of duplicating `500` there.
Now the limit is single-sourced; bumping it once updates both the
clamp inside `listAuditLogPage` and the 400-error boundary the
handler returns to clients.
Every field is now marked `immutable: true`, and pre-hooks on the
schema reject `updateOne`, `updateMany`, `findOneAndUpdate`,
`findOneAndReplace`, `replaceOne`, `deleteOne`, `deleteMany`,
`findOneAndDelete`, plus any `save()` against an existing document.
`timestamps` is reduced to `{ createdAt: true, updatedAt: false }`
since a mutable timestamp would imply mutation is allowed, and
`updatedAt` is dropped from `AuditLog` / `IAuditLog`. The methods
spec resets state between tests via the raw driver (`AuditLog.collection.deleteMany`),
which bypasses the pre-hooks; new specs assert that the model-level
update / delete / re-save paths reject with the append-only error and
that `updatedAt` is not stamped on new documents.
The sibling admin handlers (grants, groups, roles, users) all have
handler specs; this one was missing. The new suite covers 401 on a
missing `req.user`, 400 on malformed ISO `from` / `to`, 400 on
limit > 500, 400 on negative offset, 400 on an unknown action or
`targetPrincipalType`, 400 on a non-ObjectId `:id`, 404 when the
methods layer returns null, that the caller's `tenantId` (not a
forged query-string `tenantId`) is the one passed to the methods
layer, that `actorQuery` / `targetQuery` round-trip, that the
deprecated `actorId` / `targetPrincipalId` aliases still map through,
that the CSV stream emits the BOM as the first chunk with CRLF line
endings and the expected header labels, that quotes, commas, and
newlines are properly escaped, that the formula-injection prefixes
(`=` `+` `-` `@` tab CR) are defanged, that an `isCancelled` callback
reaches the methods layer and flips to true on client `close`, and
that `res.end` is skipped when the client disconnected mid-stream.
The CSV stream handler kept draining Mongo batches after the client
disconnected because the `for await` loop only honored its abort flag
inside `onEntry`. Thread an `isCancelled` callback into
`streamAuditLogEntries` so the methods layer closes the cursor as soon
as the handler sees `close`/`aborted`; a `finally` block guarantees
release on throw. The drain promise in `writeChunk` now races against
the response's `close` event so a destroyed socket cannot strand the
handler on a `drain` that will never fire.
The HTTP filter keys `actorId` and `targetPrincipalId` always did
case-insensitive substring matches on the denormalized `actorName` /
`targetName` columns, never on ObjectIds — a client passing a real id
silently got zero rows. Renamed the wire-level keys to `actorQuery` /
`targetQuery` (matching what the matcher actually does) and kept the
old names as deprecated aliases for one release so the sibling
admin-panel PR can migrate without breaking; each legacy use logs a
deprecation warning. Renamed the corresponding fields in
`AuditLogFilters` too.
`AdminAuditLogEntryWire` duplicated `AdminAuditLogEntry` from
`types/admin.ts` field-for-field, violating the no-duplicate-types
rule. Deleted the duplicate, hoisted `AuditLogPage`,
`RecordAuditEntryInput`, and `AuditLogFilters` from
`methods/auditLog.ts` into `types/auditLog.ts`, and updated the
handler, method factory, and re-exports accordingly.
`tenantFilter` treated `''` as a valid tenant scope, producing a
`{ tenantId: '' }` query that silently returned nothing while the
schema validator rejected `''` on writes. Switched to a strict
`typeof tenantId === 'string' && tenantId.trim().length > 0` check so
reads agree with writes, with new spec coverage for empty and
whitespace-only inputs.
Audit-write failures now log the full forensic payload (action,
capability, tenantId, actorId, target metadata) inside a single meta
object so winston's standard signature surfaces it correctly; a comment
on the catch block explains why the failure mode stays silent (it must
never block a privileged operation).
Stronger filter parsing: invalid `action` values and unknown
`targetPrincipalType` now return 400 instead of silently dropping.
Extracted `MAX_LIMIT` to a constant. Replaced the
`Record<string, Date>` cast in `buildFilter` with a typed local.
Switched the stream cursor to `lean<IAuditLog[]>()` and removed the
`as IAuditLog` cast inside the loop.
Resolve the duplicate AuditAction export that broke the data-schemas TypeScript check by importing the canonical declaration from types/admin instead of re-declaring it in types/auditLog.
Accept timezone-offset ISO 8601 timestamps such as 2026-05-01T09:30:00+02:00 in the from and to filter params and reject local-time strings without a zone so every request resolves to an unambiguous instant.
Skip the audit emission on no-op revokes: revokeCapability now returns deletedCount so the admin handler can omit the grant_removed entry when the target grant did not exist, keeping the audit trail factually accurate. Mocks in the existing grants.spec.ts updated to the new return shape.
Drop the required recordAuditEntry from AdminAuditLogDeps since the audit-log handler factory never consumes it; the grants handler factory keeps its optional dep for the write path.
Tighten the tenantId validator on the audit log schema to require a non-empty trimmed string, and rewrite the listing-index comment to describe deterministic offset sort instead of keyset pagination.
Stream the CSV export with explicit backpressure (await drain when res.write returns false) and abort on client disconnect so a cancelled download no longer pins a Mongo cursor or buffers unbounded data in memory.
Add packages/data-schemas/src/methods/auditLog.spec.ts covering tenant and platform scoping, single and multi action filtering, partial-name filtering for actor and capability, the createdAt window, offset pagination with total, ObjectId and date stringification on the wire, regex-metacharacter escape, and streaming completeness.
Switch listAuditLogPage from cursor-based to offset-based pagination with skip().limit() + parallel countDocuments, returning { entries, total } instead of { entries, nextCursor }; the cursor encode and decode helpers are no longer needed and have been removed.
Interpret the actorId and targetPrincipalId filter parameters as case-insensitive partial regex against the denormalized actorName and targetName fields rather than exact-match against the underlying ObjectId. Admin panel users naturally filter by human name, not by Mongo identifier.
Replace the broad Record<string, unknown> casts on req.query with a typed AuditLogQuery shape, drop two unused exported types and the now-unused mongoose Types import, and fix the streamAuditLogEntries Omit literal to match the interface and the offset-based design.
Add an AuditLog Mongoose collection that records every grant assign/revoke as an append-only entry capturing the actor, target principal, capability, timestamp, and tenant scope. Wire the entry-write into the existing admin assignGrant and revokeGrant handlers so the admin panel's audit-log tab populates as grants happen.
The data-schemas package gains the IAuditLog type, a Mongoose schema with tenant + target compound indexes for keyset pagination, a model factory wired through createModels, and an AuditLog methods factory exposing recordAuditEntry, listAuditLogPage (cursor-paginated, faceted, search-aware), findAuditLogEntry, and streamAuditLogEntries.
The packages/api admin layer adds createAdminAuditLogHandlers with three handlers backing the routes the admin panel already consumes: GET /api/admin/audit-log returns paginated entries, GET /api/admin/audit-log/:id returns a single entry for the permalink drawer, and GET /api/admin/audit-log/export.csv streams CSV with formula-injection defang plus UTF-8 BOM.
The Express layer mounts the new router at /api/admin/audit-log behind requireJwtAuth and the ACCESS_ADMIN capability, matching the existing admin route pattern. The audit emission failure is logged via logger.error but never rolls back the grant.
Server-side resolution of {{current_date}} and {{current_datetime}} for
agent instructions used the server's timezone, so agents received UTC
instead of the user's local time the variables are documented to provide.
The browser's IANA timezone is now sent with each request and threaded
through replaceSpecialVars, anchoring those variables to the user's local
wall clock. {{iso_datetime}} stays UTC. Invalid or missing zones fall back
to the previous behavior.
* 🔖 fix: Decrement Bookmark Counts When Deleting Conversations
Deleting a bookmarked/tagged conversation removed the conversation but never decremented the affected ConversationTag counts, leaving stale bookmark counts in the UI.
- Add decrementTagCounts helper that atomically decrements tag counts (clamped at 0, deduped per conversation) in deleteConvos, covering single delete, clear-all, and account deletion.
- Invalidate the conversationTags query in the single-delete and clear-all client mutations so counts refetch.
- Add deleteConvos tag-count tests.
* 🔒 fix: Guard tag-count decrement on actual deletion and message-failure
Addresses Codex review findings:
- Guard the decrement on deleteConvoResult.deletedCount > 0 so a losing concurrent delete (double-click/two-tab) does not decrement counts for a conversation it did not actually remove.
- Move the count adjustment to run immediately after the conversation deletion, before message cleanup, so a deleteMessages failure cannot leave bookmark counts permanently stale.
- Add regression tests for both cases.
* 🔀 fix: Refresh project stats after message cleanup in deleteConvos
Addresses Codex finding: bundling refreshChatProjectStatsForUser into a Promise.all before deleteMessages let a stats-refresh error abort the function and orphan the deleted conversations' messages. Split the steps so the (best-effort) tag-count decrement still runs before message cleanup (counts reconciled even if messages fail), while project-stats refresh runs after, matching the original ordering.
* ✅ test: Add e2e coverage for bookmark counts on conversation delete
Two mock-harness specs for the deleteConvos bookmark-count behavior:
- Deleting the only conversation carrying a bookmark drops its count to 0.
- Deleting one of two conversations that share a bookmark leaves the count at 1.
Both assert the persisted server count via GET /api/tags after the real delete round-trip.
* chore: import order
* 🔧 chore: Update `@librechat/agents` to v3.2.38 and bump related dependencies in package-lock.json and package.json files
* 🔧 chore: Upgrade `multer` dependency to version 2.2.0 in package-lock.json and package.json
* 🔧 chore: Upgrade `nodemailer` dependency to version 9.0.1 in package-lock.json and package.json
* 🔧 chore: Upgrade `@aws-sdk/client-bedrock-agent-runtime` and `@aws-sdk/client-bedrock-runtime` to versions 3.1071.0, update related dependencies in package-lock.json and package.json
* 🔧 chore: Upgrade `form-data` to version 4.0.6 and `hono` to version 4.12.25, update related dependencies in package-lock.json and package.json
* 🔧 chore: npm audit fix
* 🔧 chore: Remove unused Babel dependencies from package-lock.json and package.json
* 🔧 chore: Add '@mistralai/mistralai' to esModules in Jest configuration files
* feat: add `convo.pinned`
We want to be able to pin convos (so users can easily find them), thus we
added a new field to the DB schema: `pinned`.
We also had to add an API method for pinning a convo. It's got thorough tests.
It's structured just like how /api/convos/archive works, only for pinning.
* feat: add 'pinned' section to conversation list
If there are any pinned conversations, they will appear above the normal
"chats" list, with a pinned icon next to them.
* feat: added pin/unpin to convo options
ConvoOptions now has a pin/unpin button which lets you change the
pin status of any given conversation.
* fix: adjust ellipsizing gradient on ConvoLink
Because it went across the whole ConvoLink, it would cover up any
children (i.e. icons) that appear after the title. However, the point
of the gradient is just to gradually make the title disappear, not
the icons.
This change places the gradient on the title only, so it achieves
the same ellipsizing effect without interfering with the display of
the child icons.
* Fixed import sorting
* 🔐 fix: Honor Admin-Panel MCP Allowlist Overrides Without Restart
MCPServersRegistry was built once at boot from getAppConfig({ baseOnly:
true }), freezing allowedDomains/allowedAddresses to YAML. Admin-panel
mcpSettings overrides were ignored by both inspection (addServer/
reinspectServer/updateServer/lazyInitConfigServer) and runtime connection
enforcement (assertResolvedRuntimeConfigAllowed), so a domain allowed only
via the panel failed inspection and never connected.
Make the registry's effective allowlists mutable and refresh them from the
merged admin-panel config: seed at boot, and re-apply on every config
mutation via invalidateConfigCaches -> clearMcpConfigCache. Both inspection
and connection paths read the same getters, so both honor overrides without
a restart. Fail-safe: current allowlists are preserved when the merged read
fails.
* 🛡️ fix: Scope MCP allowlist refresh to global config, fail-safe on DB error
Address Codex P1 review findings on the allowlist-refresh path:
- Tenant-scoped config mutations no longer push one tenant's merged
mcpSettings into the process-wide registry singleton (read by all MCP
connection paths), which would leak allowlists across tenants. Only
global (non-tenant) mutations refresh the registry; tenant mutations
still evict the config-server cache.
- The refresh read now uses strictOverrides:true so a transient DB error
throws instead of silently returning YAML base config — preserving the
last-known allowlists rather than overwriting them with fallback values.
Adds the strictOverrides option to getAppConfig (default off, no behavior
change for existing callers).
* ♻️ refactor: Resolve MCP allowlists per-request (tenant-scoped) instead of a global singleton
Supersedes the prior global-mutation approach. MCP allowlists live in
mcpSettings, which is tenant/principal-scoped admin config, so a process-wide
singleton value is the wrong model — it caused cross-tenant bleed and stale
reads.
Instead, inject a resolver (from the app layer, where the merged config lives)
that the registry calls per inspection and per connection. It reads the ALS
tenant context via getAppConfig and accepts the acting user so user/role-scoped
overrides resolve; config-source inspection (no user) resolves at tenant scope.
Falls back to the YAML base allowlists when no resolver is set or the lookup
fails, so a transient error fails to the operator baseline rather than
disabling the allowlist.
Removes the now-unnecessary setAllowlists / boot-seed / invalidateConfigCaches
refresh / getAppConfig.strictOverrides machinery.
* 🔒 fix: Scope config-source cache by allowlist; resolve OAuth allowlists per-request
Address Codex review of the per-request resolver:
- Config-source cache key now folds in the resolved allowlists, not just the
raw-config hash. Inspection results became allowlist-dependent, so without
this a tenant whose allowlist rejects a URL could poison the shared key with
an inspectionFailed stub for a tenant that allows it (and vice versa). The
tenant-scoped allowlist is resolved once per ensureConfigServers pass and
threaded through the cache key + inspection.
- The two remaining request-time OAuth allowlist reads now use the merged
config instead of the YAML base getters: the fallback OAuth-initiate path
(routes/mcp.js) via resolveAllowlists, and OAuth revocation
(UserController.maybeUninstallOAuthMCP) via the request's already-merged
appConfig.mcpSettings. Without this, an OAuth endpoint allowed only by an
admin-panel override was rejected while inspection/connection allowed it.
* ✅ test: Update MCP OAuth registry/config mocks for per-request allowlists
CI fix for the Finding-12 change. The OAuth-initiate route now calls
registry.resolveAllowlists() and the revocation path reads the merged
appConfig.mcpSettings, so the affected specs' mocks were asserting the old
base-getter values:
- routes/__tests__/mcp.spec.js: add resolveAllowlists to the registry mock.
- UserController.mcpOAuth.spec.js: provide mcpSettings on the getAppConfig
mock so revokeOAuthToken still receives the expected allowlists.
* 🧪 test: e2e proof that admin-panel MCP allowlist override takes effect
Adds a Playwright mock-harness spec for #13809. A URL-based MCP fixture
(e2e-http, streamable-http SDK server) boots inspectionFailed because its
origin is omitted from the YAML mcpSettings.allowedDomains; the spec adds that
origin via an admin config override (PUT /api/admin/config/user/:id) and
asserts the server reinitializes — exercising the real resolver path through
the backend + DB. Before the fix, reinspection used the frozen YAML allowlist
and the server stayed unreachable.
- e2e/setup/fake-mcp-http-server.js: streamable-HTTP MCP fixture (health GET /).
- e2e/playwright.config.mock.ts: boot the fixture as a second webServer.
- e2e/config/librechat.e2e.yaml: mcpSettings.allowedDomains (excludes 127.0.0.1)
+ the e2e-http server.
- e2e/specs/mock/mcp-allowlist-override.spec.ts: login → baseline reinit fails →
apply override → reinit succeeds.
* 🛡️ fix: Bound object-traverse against DAG fan-out and shared refs
Detect cycles via the ancestor chain (so shared, non-circular references in sibling branches / DAGs are traversed correctly) and add defensive maxNodes (100k) / maxDepth (100) caps. The removed global visited set was implicitly bounding work at O(distinct nodes); ancestor-chain-only detection is O(root-to-node paths), exponential on DAGs (a depth-24 diamond went from 26 to 50M visits / 1.6s of synchronous work). The caps bound it to ~9ms while leaving normal traversal untouched. Adds a spec covering shared refs, cycles, DAGs, and both bounds. The lone consumer, debugTraverse, inherits the defaults with no change.
* 🪵 refactor: Remove legacy api/config logger duplicate
The api/config winston logger was a stale parallel implementation of the canonical @librechat/data-schemas logger, with unbounded redaction (regex-only redactFormat, npm traverse-based debugTraverse). Its winston instance and the logger export from api/config/index.js had zero consumers — every ~/config importer uses the MCP/flow-manager exports. The only live tie was ToolService's use of redactMessage.
Re-export redactMessage from @librechat/data-schemas (behaviorally identical, a superset of the regex set), point ToolService at it, delete api/config/winston.js and api/config/parsers.js, drop the dead logger export, and remove the orphaned ~/config/parsers mock from the global test setup.
* 🧹 chore: Drop orphaned traverse dep and stale legacy logger tests
Deleting api/config/{winston,parsers}.js left the npm 'traverse' package unused in api/package.json (flagged by the detect-unused-packages CI check) and orphaned two tests that imported the deleted modules. Remove the traverse dependency (sync package-lock), and delete api/config/__tests__/{parsers,logToFile}.spec.js — the canonical logger's behavior is covered by packages/data-schemas/src/config/parsers.spec.ts.
* 🩹 fix: Make object-traverse caps bound work and survive update()
Address Codex review: (1) break the child loops as soon as the node budget is spent and iterate objects via for...in instead of materializing Object.entries/Object.keys, so maxNodes actually bounds work for wide arrays/objects; (2) detect ancestor cycles against an immutable original-node stack rather than context.node, which a callback's update() can reassign (the debug formatter rewrites array nodes in place). Adds tests for the wide-array bound and the update()-cycle case.
* 🎚️ fix: Tighten object-traverse defaults to a ~1ms log budget
Lower maxNodes 100000 -> 2500 and maxDepth 100 -> 5. Measured cost is ~140ns/node with the debug formatter callback, so 2500 nodes keeps a single log under ~1ms even on slower prod hardware; real log objects are ~25-30 nodes at depth 3-4, leaving ample headroom. maxNodes is the fan-out/cost lever; maxDepth bounds recursion and output readability (depth-5 covers typical logs, deeper renders compactly).
* 🪙 feat: Context-usage projection — data-provider + client wiring
Consumer side of the SDK-aligned context projection (agents
`projectAgentContextUsage`). Adds the `/api/endpoints/context-projection`
data-provider plumbing (endpoint, service, query key, `TContextProjectionRequest`)
and a `useContextProjectionQuery` gated to fire only when no fresh snapshot
covers the viewed branch.
Wires `useTokenUsage` precedence to: live snapshot → fresh persisted snapshot
(window matches the resolved one) → server projection → per-message estimate.
A model/window switch marks the baked snapshot stale (its `maxContextTokens`
no longer matches) and falls to the projection — closing the gauge's
window-switch (G1) and snapshot-less-branch (G2) gaps. Snapshot and projection
share the render-relevant fields, so they render uniformly.
Backend endpoint + agents version bump land in follow-up commits. Includes the
design spec (CONTEXT_PROJECTION_SPEC.md).
* 🪙 feat: Context-projection backend endpoint
POST /api/endpoints/context-projection → resolveContextProjection (packages/api):
reconstructs the viewed branch (parent-chain walk from messageId), resolves the
agent config (instructions/provider/model/maxContextTokens), reuses LibreChat's
stored per-message tokenCounts as the index map (no re-tokenizing), and calls
the agents SDK projectAgentContextUsage — no model call. Thin controller injects
db.getMessages/db.getAgent; route mirrors /token-config.
First cut targets message-windowing accuracy; tool-schema tokens are deferred to
a follow-up that reuses the full initializeAgent path.
* 🩹 fix: Codex review on context projection (G1 guard, IDOR, recount, summary)
- Guard `currentActive` against a stale window: a model/window switch on the
current branch left the live snapshot outranking the projection (G1 didn't
fire). Now defers to the projection unless streaming or the window matches.
- Scope branch lookups to the authenticated user (`getMessages` filter +
injected `userId`) — was loading any conversation by id (IDOR).
- Recount messages with no stored `tokenCount` via the tokenizer instead of
charging 0, so snapshot-less/imported histories don't under-report.
- Fall back (null) for already-summarized branches rather than projecting from
the full raw parent chain (the next call would send summary + tail); the
client's summary-baseline-aware estimate handles them until a follow-up
replays the summary boundary.
* 🩹 fix: Codex round 2 — drop agent load, summary marker, edit-invalidation
- Stop loading agent/model-spec config server-side (closes the agent-access
IDOR and the spec-prompt special-casing). Provider/model/window now come from
the client-resolved request (`limits.endpoint`/model — the agent's real
provider, not the `agents` endpoint, so the tokenizer is right). Agent/spec/
promptPrefix instructions are uniformly deferred to the full-fidelity follow-up.
- Detect summarized branches via the live path's `metadata.summaryUsedTokens`
marker (was the wrong `summaryTokenCount` field) and fall back to the
summary-aware estimate.
- Invalidate the projection query on in-place message edits via a branch
content `revision` in the cache key (the tail id is unchanged on edit).
Deferred (valid, not a regression): same-window endpoint/model switch keeps a
window-matched snapshot — needs endpoint/model persisted on the snapshot, which
lands with the fidelity follow-up. Smoke-tested: fits / prunes / summarized→null
/ no-window→null.
* 🛡️ fix: make context projection strictly additive (no-regression)
Revert the G1 window-match guard on the live/branch snapshot. When no explicit
maxContextTokens is set (the common default), the SDK's snapshot window is
reserve-derived (~0.9·(modelContext − maxOutputTokens)) while useTokenLimits
resolves the raw model context — so `snapshot.maxContextTokens === resolvedMax`
is false for the SAME model, and the guard would wrongly drop a valid
current-branch snapshot to projection/estimate post-stream (a regression in the
default case, per initialize.ts:1240-1243).
The projection now activates ONLY for snapshot-less branches (G2): the
precedence is live snapshot → persisted branch snapshot → projection → estimate,
where the first two are byte-for-byte the prior behavior and the projection just
slots ahead of the estimate. Window/model-switch (G1) detection needs the
snapshot to carry its model/window and defers to the fidelity follow-up.
* 🩹 fix: surface projections as estimates, not authoritative snapshots
A first-cut projection carries the SDK's windowing but omits instruction/tool
overhead, so rendering it as `isEstimate: false` showed a confident under-count
for snapshot-less branches. Mark projection-sourced views `isEstimate: true` +
`snapshotActive: false` (and drop the snapshot field) so they present as a
better estimate than sumBranch — improved used/window number, estimate framing,
no misleading granular breakdown with ~0 tools. Real snapshots stay
authoritative. (Codex round 3, projection.ts:139.)
* 🧹 chore: drop CONTEXT_PROJECTION_SPEC.md from the PR
* 🎨 style: fix import-sort order in projection.ts (CI sort-imports check)
* 🔧 chore: update @librechat/agents dependency to version 3.2.36 in package-lock.json and related package.json files
* chore: npm audit fix
* 🎨 style: fix import-sort order in data-service.ts (CI sort-imports check)
* 🩹 fix: drop dead calibrationRatio in projectionParams (tsc never error)
Inside the ternary, branchSnapshot is narrowed to null (the gate is
), so accessed a
property on (frontend typecheck failure). It was also dead — there is
never a snapshot to seed from in this branch — so just remove it.
* Revert "chore: npm audit fix"
This reverts commit 4cdb862d0c.
The installed @librechat/agents folds cache_creation + cache_read into
Anthropic usage_metadata.input_tokens (cache-inclusive), but
cacheSubsetProviders omitted anthropic, so splitUsage() took the additive
branch and billed cache tokens twice — at the full input rate and again at
the cache write/read rate. Verified live: a cache-read-heavy Sonnet call was
overcharged 10.7x.
Add Providers.ANTHROPIC to cacheSubsetProviders (single source of truth for
backend billing and client usage normalization). Bedrock stays additive: its
Converse path passes AWS inputTokens through unmodified. Update the Anthropic
regression tests to production-accurate cache-inclusive fixtures.
Fixes#13795
* 🥽 fix: Redact Non-User-Sourced MCP Server URLs by ACL Edit Permission
GET /api/mcp/servers and GET /api/mcp/servers/:serverName return MCP server configs to any caller with MCP-use permission. For user-sourced configs (DB-stored, UI-submitted), the URL is the caller's own and is intentionally disclosed. For non-user-sourced configs (YAML or config-tier, operator-defined), the URL and OAuth flow endpoints (authorization_url, token_url) are operator-sensitive: they can encode internal infrastructure hostnames and are not editable through the API.
This change redacts those fields on non-user-sourced configs unless the caller has edit authority on the resource, using the same ACL check (PermissionBits.EDIT) that the PATCH and DELETE routes already enforce via canAccessMCPServerResource. Callers with broad MANAGE_MCP_SERVERS capability bypass the per-resource check, matching the existing capability bypass in canAccessResource. customUserVars is intentionally not redacted: its values are UI hint metadata (title, description, sensitive), not user-supplied secrets; blanking it would give non-editor callers a Configure form with no field labels.
* 🥽 fix: Correct getResourcePermissionsMap import path + tighten redact comments
The MCP server redaction commit imported getResourcePermissionsMap from ~/server/controllers/PermissionsController, but that controller is a consumer of the helper, not its exporter. The canonical export lives in ~/server/services/PermissionService (which controllers/agents/v1.js already imports from). Fixes the runtime getResourcePermissionsMap is not a function failure on GET /api/mcp/servers and the four downstream route-spec failures whose config mocks lacked a source field and were therefore wrongly treated as non-user-sourced; mocks now reflect the real registry behavior (addServer/updateServer tag DB-stored configs with source: 'user'). Trims narrating JSDoc on the redact helpers and resorts the librechat-data-provider destructure by length.
* chore: import order
* 🥽 fix: Redact OAuth Revocation Endpoint Alongside Authorization And Token URLs
The OAuth-URL strip path only dropped authorization_url and token_url. The UserOAuthOptionsSchema in packages/data-provider/src/mcp.ts (line 146) accepts revocation_endpoint as another operator-configurable URL, and the OAuth handler uses it to revoke tokens; it can hold the same internal IdP hostnames the existing strip is trying to hide. Adds revocation_endpoint to the destructure so a non-user-sourced YAML/config MCP server config no longer leaks the revocation URL to non-editor callers. The existing strip url and oauth flow URLs spec is extended with a revocation_endpoint value to lock in the new field.
* 🥽 fix: Gate Shared DB Server URL Disclosure On ACL Edit Permission
source-driven URL disclosure was incorrect for shared DB-backed MCP servers. ServerConfigsDB.mapDBServerToParsedConfig (packages/api/src/mcp/registry/db/ServerConfigsDB.ts:465) sets source: 'user' on every DB-stored config it returns, regardless of who is accessing it. A user with only VIEW share on a DB server, or with agent-mediated access, was therefore treated by the redaction layer as if they owned the URL, and GET /api/mcp/servers disclosed the owner's URL and OAuth flow URLs to viewers who could not edit the resource.
The redaction is now driven purely by ACL edit authority: computeCanEditByServer routes every dbId-bearing config through PermissionBits.EDIT regardless of source; redactServerSecrets strips on !canEdit regardless of source. POST and PATCH controllers explicitly pass canEdit: true since both endpoints establish edit authority (POST creates the resource, PATCH is gated on the EDIT middleware). Legacy/ephemeral configs without a dbId still fall back to the source heuristic.
* 📝 docs: correct redactServerSecrets URL-disclosure comment
---------
Co-authored-by: Danny Avila <danny@librechat.ai>
* 🎤 fix: Keep Microphone Icon Visible On Initial Chat Render
AudioRecorder returned null while the parent ChatForm's textAreaRef was still null on first paint, hiding the mic icon until an unrelated re-render. Render the button disabled instead so the icon is always present.
Closes#13786
* 🎤 refactor: Drop Unused textAreaRef Dependency From AudioRecorder
Per Codex review: deriving the button's disabled state from textAreaRef.current could leave the mic permanently disabled until an unrelated re-render, since assigning a ref does not trigger one. The handlers never read the ref, so remove the dependency entirely along with the now-unused prop.
* 🪙 fix: Reconcile Context Gauge to Actual Provider Tokens
The context gauge could read several× too high (e.g. 213K when the real prompt
was 56K) and stay there across reloads. Root cause: the SDK's calibrationRatio is
`cumulativeProviderReported / cumulativeRawSent`, but a provider's server-side
web search injects large fetched content into the prompt that the SDK never sent
or counted — pinning the ratio at its cap (5) and multiplying every later message
estimate, including post-summary ones. The gauge rendered (and persisted) that
inflated estimate, never the provider's actual token count.
Fix: reconcile the snapshot to the call's ACTUAL prompt tokens (input + cache),
which already arrive in on_token_usage. Only messageTokens is calibration-scaled
(instructions/summary are raw tiktoken), so keep those and set messageTokens to
the remainder, recomputing free space. Shared `promptTokensFromUsage` +
`reconcileContextUsage` in data-provider; applied server-side in
buildPersistedContextUsage (reload-stable) and client-side in useUsageHandler on
each primary usage (corrects at turn-end, no follow-up needed). Also drop the
summary double-count from the Breakdown Messages row.
Deferred (separate agents PR): the SDK over-calibration also fires summarization
prematurely; fixing it needs decoupling real-content estimation from server-side
injection headroom without weakening pruning-overflow safety.
* 🪙 fix: Harden Token Reconciliation for Provider-less + Resume Paths
Codex review on the reconciliation:
- promptTokensFromUsage: when the provider is absent (custom/OpenAI-compatible
payloads), fall back to the same magnitude heuristic normalizeUsageUnits uses
(cache ≤ input ⇒ already included) so cached events aren't re-inflated.
- Resume: backfillUsage restores a primary call's usage without replaying a live
on_token_usage (Redis mode), so the live reconcile never ran and a reconnected
session stayed on the inflated estimate. New reconcileBackfill reconciles the
restored snapshot from the final primary call after contextHandler installs it.
* 🪙 fix: Reconcile Resume Snapshot Server-Side, Not via Backfill
Codex: the client reconcileBackfill scanned the resumed run's collectedUsage and
applied the final primary to the latest snapshot — but on a mid-call resume that
usage belongs to an EARLIER call, corrupting the restored gauge.
Move the resume reconciliation server-side: GenerationJobManager.persistTokenUsage
reconciles the stored contextUsage to a primary usage's actual prompt tokens as it
arrives. That usage is the post-invoke truth for the call the latest stored
snapshot precedes (no snapshot is captured between a call's pre-invoke dispatch
and its usage), so it's correct by construction and run-matched. A mid-call resume
(no usage yet) keeps the raw snapshot instead of mis-applying an earlier call's
tokens; it reconciles once the call completes. Removed client reconcileBackfill;
the live-path reconcile (non-resume) stays.
* 🪙 fix: Guard Reconciliation Against Replays and Snapshot Races
Two Codex concurrency findings on the reconciliation:
- Client: reconcile only on a NEWLY folded primary usage. A replayed duplicate
(folded=false on resume) can be an earlier tool-loop call sharing the run id,
which would overwrite the latest snapshot with an earlier, smaller prompt. Moved
the reconcile after the folded guard.
- Server: serialize the context-usage write through the same per-stream queue as
the token-usage write. persistTokenUsage reconciles the stored snapshot
(read-modify-write); an unserialized trackContextUsage could store a newer
snapshot between the read and write — or a stale reconciled write could land
after a newer snapshot — clobbering the newer run's gauge when calls interleave.
FIFO keeps each call's snapshot ahead of its own usage and behind the next.
* chore: import order in GenerationJobManager.ts
* 🅰️ feat: Native Anthropic provider for Custom Endpoints
Let a custom endpoint declare `provider: anthropic` to use the native Anthropic
`/v1/messages` client (the agents SDK's ChatAnthropic) against its own
`baseURL`/`apiKey`/`headers`, instead of being forced through the
OpenAI-compatible client. Enables Anthropic itself and Anthropic-compatible
gateways (AI gateways, OpenCode Zen, etc.) as custom endpoints — including for
agents and role-scoped model access.
Closes#10655 (Option 1: explicit provider).
- Schema: add optional `provider` (currently `anthropic`) to the custom
`endpointSchema` in data-provider.
- Routing: `getProviderConfig` maps a custom endpoint with `provider: anthropic`
to `Providers.ANTHROPIC` (was always `Providers.OPENAI`).
- Config: `initializeCustom` builds the native Anthropic config via the Anthropic
`getLLMConfig` (custom baseURL/apiKey/headers) and returns `provider: anthropic`;
`useLegacyContent` is left unset to match the built-in Anthropic endpoint. The
OpenAI-compatible path is unchanged for endpoints without `provider`.
- Summarization: `resolveSummarizationProvider` builds an Anthropic config for a
cross-endpoint native-Anthropic summarization target (self-summarize already
reuses the agent's client options).
Title generation already resolves via `agent.endpoint`, and provider-specific
handling (tool conflicts, content/PDF validation, token counting, streamUsage)
already branches on `Providers.ANTHROPIC`, so it applies automatically.
Note: model auto-fetch (`models.fetch`) uses the OpenAI `/models` convention and
is not used for this provider — list models explicitly under `models.default`.
* 🅰️ fix: Anthropic custom-endpoint param parity (Codex review)
Address Codex P2 findings — the native Anthropic path must match the
OpenAI-compatible path's parameter handling:
- UI param set: `loadCustomEndpointsConfig` now surfaces `provider` as the
client `customParams.defaultParamsEndpoint`, so the Agents model panel shows
Anthropic fields (`maxOutputTokens`/`thinking`) instead of OpenAI `max_tokens`
(which the native initializer ignored). An explicit non-default
`defaultParamsEndpoint` still wins.
- Provider override: `getProviderConfig` re-applies `provider: anthropic` after
all `customEndpointConfig` resolution, so it also wins when the endpoint name
collides with a known custom provider (e.g. `openrouter`) — fixing the
token/context budget derived from `overrideProvider`.
- Default params: the native path (and cross-endpoint Anthropic summarization)
now apply `customParams.paramDefinitions` defaults via `extractDefaultParams`,
matching what `getOpenAIConfig` does for the OpenAI-compatible path.
Adds tests for each.
* 🪙 fix: Persist Context Snapshot + Summary Marker After Summarization
The post-summarization context is correctly compacted by the SDK, but the
breakdown wasn't reliably reaching the client, leaving the gauge on the
whole-history estimate (stuck at 100% forever once a conversation compacts).
Two server changes in buildResponseMetadata:
- Snapshot guard: persist the breakdown when a PRIMARY usage event follows the
latest snapshot (tracked via contextUsageSink.latestUsageIndex, recorded in
the on_context_usage handler) instead of a brittle snapshot-vs-primary count.
A summarization detour adds an extra snapshot whose only following usage is
tagged 'summarization', which the count guard could miscount and drop.
- Summary marker: whenever a turn compacts (summaryTokens > 0), persist a
lightweight metadata.summaryUsedTokens (the pre-invoke compacted context size)
UNCONDITIONALLY — so even when the full snapshot can't be saved (interrupted
final call) or never reaches the client, the per-message estimate has a signal
to cap the discarded history.
Tests: client.contextMetadata.spec (guard + marker, incl. marker-survives-drop)
and a real-pipeline summarization integration test.
* 🪙 fix: Cap the Context Estimate at the Summary Marker
When the gauge falls back to the per-message estimate (no usable snapshot on the
branch), sumBranch summed the ENTIRE branch history — after a summarization that
discarded most of it, this over-counts and pins the gauge at 100% in perpetuity.
sumBranch now stops at the deepest summarized response (metadata.summaryUsedTokens)
and records it as summaryBaseline; the walk counts only post-summary messages,
and useTokenUsage adds the baseline. So the estimate reflects the compacted
context (summary + recent turns), not the discarded history. USD/default
behavior unchanged when no marker is present.
Test: sumBranch caps a huge pre-summary history at the compacted baseline.
* 🪙 fix: Address Codex Review on the Summarization Marker
- Branch cost/usage is no longer truncated at the summary marker — sumBranch
caps only the CONTEXT-window count there and keeps accumulating provider
usage/cost to the root (cumulative spend isn't discarded by compaction).
- findBranchSnapshotAnchor stops at a summarized response with no snapshot of its
own, so it can't recover a stale PRE-summary snapshot and show discarded
history; the summary-baseline estimate is used instead.
- Abort path: buildAbortedResponseMetadata now persists the summaryUsedTokens
marker (pre-invoke, no completedOutputTokens ambiguity, so safe on abort) so a
STOPPED summarized turn isn't re-summed on reload.
- Marker baseline fallback now includes summaryTokens (a separate breakdown
field) so it doesn't under-report the compacted size. DRY'd into a shared
computeSummaryUsedTokens used by the completion and abort paths.
- Estimate popover surfaces the summary baseline as a row so the displayed rows
reconcile with the header total.
Tests: sumBranch cost-not-truncated + anchor-stops-at-marker (client);
computeSummaryUsedTokens fallback + abort marker (packages/api).
* 🪙 fix: Attribute Persisted Context Usage to the Snapshot Run
Match the post-snapshot primary usage to the latest snapshot's runId before
persisting metadata.contextUsage. Parallel/direct runs interleave snapshots and
usage (A snapshot → B snapshot → A usage → B no-usage); the prior index-only
guard persisted B's snapshot with A's output. finalCallOutputTokens now filters
completedOutputTokens to the snapshot's run. Untagged events (older lib/resume)
match any run for back-compat.
* 🪙 fix: Harden Summary Marker Against Tool-Loops, Stale Anchors, and Emit Races
Codex round on the summarization marker:
- Avoid double-counting earlier tool-loop outputs in the summary marker: those
outputs sit in BOTH the latest snapshot's pre-invoke baseline AND the response
message's tokenCount the client estimate adds on top. computeSummaryUsedTokens
now subtracts the run's prior primary outputs (priorRunOutputTokens) — the live
path bounds them by the snapshot's usage index, the abort path by all primaries
(an interrupted final call emits none). Single-call turns subtract 0.
- Stop treating pre-summary anchors as active: sumBranch no longer sets
containsAnchor once the context is capped at a summary marker, so a stale
pre-summary snapshot can't override the summary-baseline estimate.
- Capture latestUsageIndex BEFORE awaiting emitEvent: a yield (resumable SSE /
Redis) during parallel runs could let this call's own usage advance the index
past the event that proves the snapshot completed, dropping a valid breakdown.
* 🪙 fix: Subtract Summarization Output from the Summary Marker
recordCollectedUsage folds the summarization call's completion into the response
message's tokenCount, while the generated summary is also in the snapshot baseline
as summaryTokens. The client estimate (summaryBaseline + responseTokenCount) thus
counted the summary twice — inflating the gauge after compaction even on a
single-call turn whenever the full snapshot is unavailable. priorRunOutputTokens
now also counts summarization-tagged output (still excluding subagent/sequential,
which recordCollectedUsage keeps out of the reported total), so the marker
subtracts it. Updated unit + guard tests.
* 🪙 fix: Refine Marker Subtraction for Summarization RunId and Abort Boundary
Two Codex follow-ups on the marker-subtraction logic:
- Subtract summarization output regardless of runId: the summarize detour is its
own model-end call that may carry a distinct runId, but its output still lands
in this response's tokenCount AND the snapshot baseline (summaryTokens). It is
now counted unconditionally (still within the response's own usageEmitSink),
while primaries keep the parallel-run runId filter.
- Don't subtract primaries on the abort path: the job stores no snapshot/usage
boundary, so a primary that completed AFTER the latest snapshot is NOT in the
baseline; subtracting it would cancel real output and under-report. priorRun-
OutputTokens gains an includePrimary flag (false for abort) — abort subtracts
only the always-pre-snapshot summarization output.
* 🪙 fix: Run-Scope Summary Subtraction and Stop Subtracting on Abort
Two Codex follow-ups, resolved by reverting the round-4 detour:
- Run-scope the summarization subtraction: the summarize detour inherits the
graph run id (traceConfig spreads config.metadata.run_id), so its usage shares
the answer snapshot's runId — it is NOT a distinct run. priorRunOutputTokens now
filters summarization by runId like primaries, so a parallel sibling run's
summary (different runId, in the sibling's baseline) is no longer subtracted from
this branch's marker. Drops the includePrimary flag added last round.
- Stop subtracting on the abort path: abort tokenCount is countTokens(text)
(abortMiddleware) or absent (agents route) — it does not fold in summarization or
earlier-call output the way recordCollectedUsage does, so the marker must keep
the full baseline. buildAbortedResponseMetadata now subtracts nothing.
* 📨 feat: Custom Headers on Built-in Provider Endpoints
Add a `headers` config option to the built-in `openAI`, `anthropic`, and
`google` endpoints (incl. Anthropic/Google Vertex), mirroring the custom
endpoint header mechanism. Values support the same placeholder resolution
(env vars, `{{LIBRECHAT_USER_*}}`, `{{LIBRECHAT_BODY_CONVERSATIONID}}`) and
are resolved at request time so dynamic values like conversationId resolve
against the live request — without losing provider-native request shaping.
Closes#13082. Covers #13713: forwarding conversationId to a reverse proxy
is now `X-Conversation-Id: '{{LIBRECHAT_BODY_CONVERSATIONID}}'` — an unknown
header is ignored by the native Anthropic API, so no 400 and no metadata
gating needed.
- Schema: `headers` on `baseEndpointSchema` (openAI/google/anthropic/all).
- New `mergeHeaders`/`resolveConfigHeaders` utils centralize the per-provider
header locations (`configuration.defaultHeaders`, Anthropic
`clientOptions.defaultHeaders`, Google `customHeaders`); provider-managed
headers (auth, `anthropic-beta`) always win on collision.
- Each initializer threads configured headers (endpoint over `all`) into the
right place; request-time resolution runs across all locations in the main
and title flows.
* 🩹 fix: Cast endpoints.all to TEndpoint for headers DeepPartial widening
Adding `headers` (a Record) to `baseEndpointSchema` makes `DeepPartial<TCustomConfig>`
widen its value type to `string | undefined`, which is not assignable to the
concrete `TEndpoint['headers']: Record<string, string>` at the `loadedEndpoints.all`
assignment. Cast at the assignment site, mirroring the existing
`anthropicConfig as TAnthropicEndpoint` cast in the same function.
* 🛡️ fix: Harden built-in endpoint custom headers (Codex review)
Address Codex P2 findings on the custom-headers feature:
- Anthropic title requests: `omitTitleOptions` strips the `clientOptions`
carrier, which dropped its `defaultHeaders`. Preserve just the header carrier
so gateway/reverse-proxy metadata still reaches title generation.
- mergeHeaders: match header names case-insensitively so an override (e.g. a
provider-managed `Authorization`/`anthropic-beta`) replaces/uniones a
case-variant from the base instead of emitting two names a client may collapse.
- OpenAI: withhold admin-configured headers when the user supplies the base URL
(`user_provided`), since values may carry `${SECRET}`/token placeholders that
must not reach a user-controlled endpoint — mirrors the custom-endpoint guard.
- Azure: honor global `endpoints.all` headers (same OpenAI carrier) while keeping
Azure-managed `api-key`/version headers authoritative.
Adds tests for each.
* 🔐 fix: Resolve-once + provider-managed header safety (Codex review round 2)
Address Codex P2 findings:
- Azure: keep global `endpoints.all` headers unresolved at init and let
request-time `resolveConfigHeaders` resolve them once, avoiding a
second-order env expansion of already-substituted user values.
- Google: `resolveConfigHeaders` no longer template-resolves the
provider-managed `Authorization` header (built from a possibly user-provided
key), so a user key like `${ENV}` can't leak server environment values.
- Model fetches: thread configured headers (endpoint over `all`) + user object
through `getOpenAIModels`/`getAnthropicModels` → `fetchModels`, so a
gateway-fronted built-in provider receives the header on `/models` too. Fixed
`fetchModels` to merge custom headers for Anthropic instead of overwriting
them (managed `x-api-key`/version still win).
Adds/updates tests for each.
* 🧯 fix: Header provenance, memory/title coverage, idempotency (Codex round 3)
Address Codex P2 findings, including two regressions from the prior round:
- Google auth (findings 6 & 8): move native Google header resolution to init
(`initializeGoogle`), resolving admin templates BEFORE the key-derived auth
header is built. resolveConfigHeaders no longer touches Google `customHeaders`,
so admin `Authorization` templates resolve again (fixes the round-2 regression)
while the SDK auth header (possibly a user-provided key) is never env-expanded.
- Memory runs: memory extraction now calls `resolveConfigHeaders`, so native
Anthropic (and OpenAI) headers resolve for memory requests too.
- Vertex titles: restore the ORIGINAL `clientOptions` object reference (not a
copy) when preserving headers across `omitTitleOptions`, so the Vertex
`createClient` closure and the resolved headers stay on the same object.
- Reuse: `resolveConfigHeaders` is now idempotent (resolve-once per header map),
preventing a second pass from env-expanding values already substituted with
user/body data when an agent object flows through buildAgentInput twice.
Adds/updates tests for each.
* 🔢 fix: Prevent "approximately" tildes from rendering as markdown subscript
`remark-supersub` splits text nodes on every `~`; an even number of tildes
wraps the in-between text in `<sub>`. "Approximately" usage like
`~50% ... ~10%` pairs up and subscripts everything between the two tildes.
A backslash escape cannot fix this: micromark resolves `\~` back to a bare
`~` before supersub runs. Instead, `preprocessTilde` swaps approximation
tildes (a `~` prefixing a number, not attached to a word) for the Unicode
tilde operator `∼` (U+223C), which renders as a tilde but is not split by
supersub. Mirrors `preprocessLaTeX`: early return, single regex pass,
code-region skipping. Genuine subscripts (`H~2~O`, `a ~2~ b`), strikethrough,
escaped tildes, and home paths are preserved.
* 🔢 fix: Harden tilde preprocessing — escaped tildes, URLs, math, MarkdownLite
Addresses Codex review findings:
- Convert escaped approximation tildes too (`\~50%`): markdown decodes `\~`
to `~` before supersub, so the escape still pairs into a subscript.
- Anchor matches to a prose boundary (start / whitespace / open bracket) and
exclude `\(`/`\[`/`\{`, so URL path tildes (`/~50`) and math delimiters
(`$~10$`, `$$~10$$`, `\(~10\)`) are left untouched.
- Apply preprocessTilde in MarkdownLite (user messages + search/subagent/
code-analysis displays), which also enables remark-supersub.
* 🔧 refactor: Neutralize approximation tildes via remark plugin, not raw-text
Replaces the string-level preprocessTilde with `remarkApproxTilde`, a remark
plugin that rewrites "approximately" tildes (`~50%` → `∼`) on parsed text nodes
before remark-supersub runs.
Because it operates on the AST, code spans, fenced code blocks (backtick *and*
~~~), inline code with any backtick count, link destinations, and math spans are
structurally excluded — none are `text` nodes — resolving every raw-text edge
case Codex flagged without region-scanning heuristics. Escaped `\~` is covered
for free (markdown decodes it before the plugin runs).
- New client/src/utils/tilde.ts: `normalizeApproxTildes` (pure, per-text-node)
+ `remarkApproxTilde` plugin.
- Wired into both renderers (markdownConfig + MarkdownLite), before supersub.
- latex.ts / Markdown.tsx reverted to original; preprocessTilde removed.
- tilde.spec.ts: pure-function cases + a hand-built-tree test proving code,
math, and link URLs are untouched while text (incl. link text) is converted.
* 🔧 fix: Cover quoted approximations and the markdown error-boundary fallback
- Broaden the boundary to any non-word, non-tilde char (`(?<![\w~])`), which
now includes quotes — `"~50%" ... "~10%"` was still subscripting because `"`
was not a recognized prose boundary. Safe to widen because the plugin runs on
text nodes, so code / links / math / URLs are already excluded structurally
(the earlier allowlist only existed to dodge URL paths in raw text).
- Add remarkApproxTilde to MarkdownErrorBoundary's fallback remark pipeline so
the fix holds when a render error falls back to the minimal renderer.
* 🔧 fix: Preserve autolink URL labels when normalizing tildes
A GFM autolink renders the URL as its own label (a text node equal to the href),
so the broadened boundary was rewriting `~50` inside a displayed URL to `∼50`
even though the href stayed correct. Skip text nodes that are an autolink's label
(value matches the destination, allowing for the implied scheme on www/email
links), so the visible URL is preserved verbatim. Regular link labels (prose)
are still normalized.
Note: a single URL containing two `~<digit>` segments is still subscripted by
remark-supersub itself — that's pre-existing behavior (reproduces with no plugin)
and out of scope here.
* 🔧 refactor: Drop unist-util-visit runtime dep from tilde plugin
Replace the `unist-util-visit` import with a small self-contained recursive walk
over text nodes (tracking the parent for the autolink-label check). This removes
reliance on a transitively-hoisted runtime package — addressing the dependency-
hygiene concern without adding a dependency or churning the lockfile. The
type-only `unist` import remains (erased at build, no runtime resolution).
Behavior is unchanged; verified against nested emphasis and list/paragraph trees.
The streaming favicon stack was gated on `source.processed === true`, but the
agents scrape pipeline marks sources processed only after a `Promise.all`
barrier (the slowest page fetch). Raw SERP results — with everything needed to
render favicons — arrive in the first attachment well before that, so the UI sat
on "Searching the web" with no favicons for the entire scrape window.
Render favicons from the raw sources as soon as they land instead of waiting for
`processed`, filling the dead window and moving the label to "Processing
results" immediately. Completed-state, turn scoping, and finalizing behavior are
unchanged.
* 🪙 feat: Default Context Cost On + Configurable Display Currency
Flip interface.contextCost to default-on (schema default true, resolved per-field
in loadDefaultInterface so it applies unless an admin explicitly sets false).
Add interface.currency { code, rate }: an ISO-4217 code and a static USD→local
multiplier so non-USD communities (EUR, JPY, CNY, BRL, ZAR, …) can show costs in
their currency. Inner fields are required (no nested defaults) to keep zod
input/output identical; loadDefaultInterface passes it through. Display-only —
model prices stay USD server-side.
* 🪙 feat: Currency-Aware Context Cost Formatting
formatCost(usd, currency?) applies the static rate (usd × rate) and formats via
a cached Intl.NumberFormat keyed by currency code — locale-correct symbol and
per-currency decimals, falling back to USD on a malformed code. The USD default
(code USD, rate 1) is byte-identical to the prior output.
* 💄 feat: Gauge Hover Snapshot, Click-to-Open Breakdown, Hide Until Data
Replace the hover-only HoverCard with: a compact hover snapshot tooltip
("Context 341.7k / 1.0M (34%)" + cost when enabled) via the existing Tooltip
primitive, and a click-opened Ariakit popover for the full breakdown that
dismisses on outside-click/Escape/blur. Gate visibility on usedTokens > 0 so a
fresh, message-less chat shows nothing, with an animate-in fade as the first
tokens land. Thread the display currency into the breakdown + snapshot.
* 🧪 test: Gauge Interaction + Visibility E2E
Switch the breakdown specs from hover to click, and add a test that the gauge is
absent on a new chat, surfaces the snapshot tooltip on hover, opens the breakdown
on click, and dismisses on Escape and outside-click.
* 🪙 fix: Harden Currency Resolution + Layer Breakdown Above Tooltip
Address Codex review on the currency display:
- Unsupported currency code now falls back to USD AND rate 1, so a typo like
{ code: 'EURO', rate: 0.92 } no longer shows a converted amount under a $
symbol (was $9.20 for a $10 cost; now $10.00).
- A non-finite/negative rate (e.g. a partial admin override that set code before
rate) falls back to rate 1, so a cost never renders as NaN.
- Fraction digits derive from the currency's own defaults, so zero-decimal
currencies (JPY) render ¥5, not ¥5.00, and extra sub-unit precision applies
only to currencies that have minor units. USD output is unchanged.
- Raise the click breakdown popover to z-[200] so it always sits above the
z-150 hover tooltip when both briefly coexist.
* 🪙 fix: Validate ISO-4217 Codes + Derive Tiny Threshold from Minor Unit
Address Codex review on currency formatting:
- Intl.NumberFormat accepts any well-formed 3-letter code (EUU, RMB) without
throwing, so the previous construct-based check missed typos/non-ISO codes and
applied the rate under a bogus label. Validate against Intl.supportedValuesOf
('currency') (the ISO-4217 set); unsupported codes fall back to USD + rate 1.
Codes are normalized to upper-case; graceful fallback if the runtime lacks
supportedValuesOf.
- The tiny-amount threshold now derives from the currency's minor unit
(10^-fractionDigits): 0.01 for 2-decimal, 0.001 for 3-decimal (KWD/BHD/JOD),
1 for zero-decimal — instead of a hard-coded 0.01. Sub-unit precision trims to
each currency's own scale. USD output unchanged.
* 💸 feat: Per-Agent Endpoint Token Config in Multi-Endpoint Billing
Price each collected/emitted usage item with the producing agent's resolved
endpoint token config, instead of the primary agent's for the whole graph.
Previously AgentClient.recordCollectedUsage and the subagent usage emitter used
a single this.options.endpointTokenConfig (the primary's) for every usage item.
A connected agent or subagent on a different custom endpoint that shares a model
id with an entry in the primary's tokenConfig was therefore mis-priced (a model
absent from it already fell back to the built-in rate map — no regression).
- Tag each usage with its producing agent: ModelEndHandler stamps
usage.agentId = agentContext.agentId; createSubagentUsageSink stamps the
child's subagentAgentId (UsageMetadata gains an optional agentId).
- buildAgentToolContext retains endpointTokenConfig so initialize.js can build
an agentId -> endpointTokenConfig map from agentToolContexts (the one map that
holds every agent, including pure subagents pruned from agentConfigs).
- AgentClient.resolveAgentEndpointTokenConfig(usage) looks up that map by
agentId, falling back to the primary config; used by both the billing path
(new optional resolveEndpointTokenConfig on recordCollectedUsage) and the
subagent cost emitter.
- recordCollectedUsage's resolver is optional and falls back to the batch
endpointTokenConfig, so the shared responses.js/openai.js call sites are
unchanged.
- Tests: two-endpoint graph with a colliding model id prices per-agent; resolver
nullish falls back to batch; subagent sink tags the child agent id.
* fix: Align emit-path cost with per-agent billing; honor known-agent built-in pricing
Addresses Codex review on the per-agent endpoint token config:
- Emit path (callbacks.js) now prices each on_token_usage event with the
producing agent's config (resolved via usageCost.resolveEndpointTokenConfig),
so streamed/persisted metadata.usage.cost matches the per-agent balance
transaction. The agentId tag is resolved server-side and stripped from the
emitted/persisted payload.
- Resolver (resolveAgentTokenConfig) now treats a known agent's config as
authoritative, including undefined → built-in pricing, so a known non-custom
agent in a custom-primary graph is no longer charged the primary's rates.
Only untagged/unknown usage falls back to the primary config.
- endpointTokenConfigByAgentId records every known agent (value may be
undefined) so the resolver distinguishes known-no-rates from unknown.
* 💾 feat: Persist Context Breakdown & Branch/Total Usage Cost
Persist the granular context breakdown and per-response usage/cost on the
response message metadata, and re-derive branch + total usage/cost from a
per-message index so the popover survives reloads and is branch-aware live.
- Add aggregateEmittedUsage + buildPersistedContextUsage helpers in
packages/api; capture the latest visible snapshot and every emitted
on_token_usage payload via contextUsageSink/usageEmitSink.
- Attach metadata.contextUsage (Part A) and metadata.usage (Part B) on the
agents response message in sendCompletion.
- Carry per-message usage on the token index; add sumTotalUsage/setEntryUsage
and branch-scoped usage on sumBranch.
- Repurpose the session accumulator into a single in-flight pending holder;
flush it into the index at finalize; hydrate breakdowns on load.
- Render branch cost with a conditional all-branches total in the breakdown.
* 🧹 chore: Remove orphaned com_ui_session_cost i18n key
* 🩹 fix: Address Codex review — normalize usage server-side, fix reload deltas
- Persist per-event-normalized display units in metadata.usage (TResponseUsage)
so reloaded mixed-provider turns match the live session; client reads them
directly instead of re-normalizing with a single stamped provider (P2).
- Persist completedOutputTokens (final call output) on metadata.contextUsage so
a reloaded multi-call turn adds the post-snapshot delta, not the full
tokenCount the snapshot already counts (P2).
- buildIndex preserves a prior entry's immutable usage when a rebuilt cache
message lacks metadata.usage, so a mid-session rebuild (regenerate) keeps a
sibling branch's flushed cost (fixes the e2e regenerate failure).
- Track costKnown so turns saved with contextCost off don't render $0.00 when
cost display is later enabled (P3).
- Use an epsilon for the all-branches cost comparison to avoid a spurious total
row from float summation order (P3).
- Update unit/integration/e2e tests for the new shapes; regenerate e2e asserts
the all-branches total after reload (deterministic via persisted metadata).
* 🩹 fix: Address Codex round 2 — pending leak, cost coverage, reload delta
- Clear the in-flight pending usage on terminal abort/error (resetLive), so a
stopped generation's tokens no longer merge into the next response (P2).
- costKnown now means COMPLETE coverage (ANDed): a branch mixing cost-bearing
and cost-less turns is flagged incomplete and the cost row is hidden rather
than rendering an under-reported total (P2).
- Drop the tokenCount fallback for completedOutputTokens on reload: only the
persisted post-snapshot delta is used, so a multi-call turn whose provider
emitted no usage_metadata no longer double-counts earlier output (P2).
- Update tokens.spec for AND coverage semantics + incomplete-cost case.
* 🩹 fix: Address Codex round 3 — no-usage snapshots, total coverage, provider-less cache
- Skip persisting metadata.contextUsage when the response emitted no primary
usage event: without a known post-snapshot output the granular gauge would
undercount the reply on reload, so fall back to the coarse per-message
estimate instead (P2).
- Gate the all-branches cost row on totalUsage.costKnown so an incomplete total
(a sibling saved without cost) never renders an under-reported figure (P2).
- aggregateEmittedUsage/finalCallOutputTokens now normalize per-event with the
client's magnitude fallback (normalizeEventUnits) instead of billing
splitUsage, so provider-less cached events match live on reload (P2).
- Add backend test for the provider-less cached case.
* 🩹 fix: Address Codex round 4 — abort attribution, complete cost coverage
- aggregateEmittedUsage persists cost only when EVERY call was priced; a partial
pricing failure now omits cost so the client treats coverage as unknown rather
than reading an under-reported sum as authoritative (P2).
- finalizeUsage flushes pending into the response entry only when events were
folded this session (eventCount > 0), so a late/second resumable subscriber
carrying persisted metadata.usage keeps it instead of being overwritten with
an empty pending record (P2).
- On user stop, attribute the in-flight pending usage to the partial response
(new attributePending handler) instead of discarding it in resetLive — the
stopped reply's billed tokens are kept and still can't leak into the next
response; resetLive's discard remains for the error path (P2).
* 🐛 fix: Persist branch cost across branch switches via sticky usage history
Branch cost vanished on switching to a sibling branch (until a new turn) — the
cost analog of the granularity bug. buildIndex rebuilds the token index from the
messages cache; a sibling generated this session whose cache message lacks
metadata.usage (and is transiently dropped from the cache during regenerate)
lost its live-flushed usage, so sumBranch found none and the cost row hid.
Fix: a sticky per-response usage map (conversationId → messageId → usage),
written by setEntryUsage and never rebuilt from the cache — the usage counterpart
of snapshotsByAnchorFamily for the breakdown. buildIndex/upsertEntries restore an
entry's usage from it when the message carries none; cleared on convo switch and
migrated with the index. Add unit coverage for the drop-then-readd regression and
an e2e assertion that branch cost survives a branch switch.
* 🐛 fix: Re-index on branch switch so branch cost survives the switch
The sticky usage history alone didn't fix the reported branch-switch cost drop:
on a branch switch no cache `updated` event fires, so the index subscriber never
re-ran, and the post-regenerate rebuild was skipped while `isSubmitting` was
still true — leaving the index stale and missing the now-viewed branch's
response entirely (sticky can only restore entries present in a rebuild).
Re-index from the messages cache on every tail change (created/finalize AND
branch switch), not just while submitting. The cache holds the full message set
at switch time, so the viewed branch's response is re-added and its usage
restored from metadata.usage or the sticky history → sumBranch finds it and the
branch cost renders. Verified locally: the branch-switch e2e now passes (the
cost section shows both the branch row and the all-branches total). Also fixed
that e2e assertion to target a single cost value (strict-mode safe).
* 🩹 fix: Handle stopped-stream usage — reset pending + persist abort metadata
Codex round (stop/abort edges):
- Resumable explicit-stop (intentional SSE close) reset UI state but never
cleared pendingUsageFamily, so usage folded before the stop leaked into the
next response in the conversation. Discard pending on intentional close
(resetLive); a resume re-folds via backfillUsage, so nothing is lost.
- The abort save path (abortMiddleware) persisted the stopped response without
metadata.usage/contextUsage, so its cost + breakdown vanished on reload.
Rebuild both from the job's persisted tokenUsage (emitted payloads incl. cost)
and contextUsage snapshot — parity with the normal sendCompletion path;
breakdown gated on a primary usage event like buildResponseMetadata.
Deferred (per scope decision): mid-stream branch-switch transiently shows the
streaming branch's pending on the viewed sibling (cosmetic, until finalize).
* 🩹 fix: Persist abort metadata on the real agents route + tighten snapshot gate
Codex round (corrects last round's wrong-path fixes):
- Stopped AGENTS responses are saved by routes/agents/index.js (/chat/abort),
not abortMiddleware — so last round's metadata fix never ran for them. Moved
the rollup/snapshot builder into packages/api as buildAbortedResponseMetadata
(shared, unit-tested) and applied it in BOTH abort save paths, so a stopped
agent reply keeps its cost + breakdown on reload.
- Persist the breakdown only when the FINAL visible call emitted usage: track a
per-response snapshot count and require primaryUsageCount >= snapshotCount.
Previously any earlier primary usage event passed the gate, so a multi-call
turn whose final call emitted no usage_metadata used an earlier call's output
as completedOutputTokens (already counted by the latest snapshot) → reload
over-reported. Now it falls back to the coarse estimate.
Resumable stop pending-reset (prior round, 3cde6fe035) already flows through
clearAllSubmissions → SSE close → the intentional-close handler's resetLive.
Deferred per scope: mid-stream branch-switch pending attribution (tracked).
* 🩹 fix: Abort breakdown over-count + resume re-fold after pending discard
Codex round (on the re-applied abort/snapshot work):
- buildAbortedResponseMetadata now persists ONLY the usage/cost rollup, not the
context breakdown. The abort path can't tell whether the final call emitted
usage (the job stores only the latest snapshot, not a count), so persisting
the breakdown risked reusing an earlier call's output as completedOutputTokens
(already in the snapshot) → reload over-count. Stopped/incomplete responses
now fall back to the coarse gauge estimate, which is safe and apt.
- resetLive now also forgets the conversation's folded usage-event identities
(clearUsageFolded). Discarding pending on a terminal/intentional close left
the folded keys set, so a later resume's backfillUsage saw the persisted
events as duplicates and never rebuilt pending — leaving the response's usage
missing until a full reload. Clearing them lets the resume re-fold.
Apply the MessageTimestamp transform to all remaining hover-reveal
controls in the message UI: replace the md: breakpoint proxy with a
(hover: hover) media query so action buttons stay visible on touch and
other non-hover devices (e.g. tablets wider than md), while still
hiding until row hover/focus where a pointer supports it.
The group-hover:visible reveal trio keeps no hidden base state, so it
only drops the md: prefixes; the actual hide-until-hover mechanism is
the opacity variant, which stays focusable and in the accessibility
tree while hidden.