LibreChat/librechat.example.yaml
Danny Avila c5276fc63d
⏱️ feat: Run Scheduled Chats Through Durable Agent Triggers (#14939)
* feat: Scheduled Chats — agent-centric scheduled runs creating real conversations

Squash of the full review-hardened branch (PR 14540, supersedes 14373) onto
latest dev, preserving the exact verified tree. History prior to this commit
lived on the pre-squash branch; every invariant below survived 25 Codex review
rounds plus two external audit rounds (R26) with regression tests that fail
without their fixes.

Feature:
- Schedules CRUD + side-panel UI (cadence dialog, run cards, Run Now), roles/
  permissions (SCHEDULES:USE), interface.schedules availability, per-user limits
  and capacity slots, timezone-aware cadence with DST-conservative floors and
  misfire grace.
- Engine: single-process claim/fire loop with leases, loopback POST dispatch
  (signed schedule-fire JWT claims, per-occurrence idempotency key inside the
  route's clientRequestId charset), overlap/balance/capacity/duplicate skip
  policies with auto-disable streaks (too_many_failures, insufficient_balance),
  reconciliation from retained terminal-job evidence, erasure sweep.
- Scheduled runs create real conversations through the resumable agents chat
  path: HITL pauses surface on the card (requires_action), resumes re-apply the
  fire boundary's admission policy (revision fence, enabled, global kill switch,
  SCHEDULES:USE, availability) before continuing a billed generation.

Correctness invariants (the audit surface):
- Settlement discipline: every persistence-producing write happens-before a
  run's terminal outcome write; Stop/complete/pause race through single-winner
  terminal CAS claims (dev's TerminalJobClaim substrate) with retained,
  completedAt-less evidence for scheduled fires plus an owner-intended outcome
  stamp (scheduleOutcome) the reconciler prefers over re-derived success —
  round-tripped through the Redis hash mapper.
- Swallowed generation failures (client error content parts) classify to
  error/skipped_balance instead of success on both initial and resumed paths;
  stale stamps are refreshed evidence-first when persistence plus the Mongo
  outcome write both fail.
- Abort honesty: delivery judged by generation ownership and the CAS's actual
  from-status; republication escalates to the transport's acknowledged variant;
  the Stop route settles only genuinely paused runs.
- Account deletion: one-way barrier (deletionRequestedAt) with auth-cache
  tombstone-before-stamp, boundary rechecks across all auth strategies, quiesce
  of scheduled + interactive work with durable per-stream abort fences
  (positive-evidence acknowledgement only), owner-side finalization markers for
  post-terminal billed writes, deferred-deletion sweep (explicitly ensured
  partial index) that completes cascades autonomously. Remote OpenAI-compatible/
  Responses requests are documented as outside the quiesce and tracked in issue
  14594.
- Store compatibility: finalization markers optional on the legacy IJobStore
  contract with coherent degradation (registration fails -> synchronous title
  fallback; count reads 0).

* fix: fence terminal response persistence from deletion; retain stale-pause evidence

Two blockers from the third external review round.

Terminal persistence visible to account deletion:

- The finalization marker was registered only when post-terminal TITLE work was
  possible, but every persistence-owning terminal CAS opens the same window: the
  claim drops the job out of the active set BEFORE the response save (and the
  background user-message/convo saves), so a deletion quiesce landing there saw
  neither an active job nor a marker and could cascade while the admitted request
  could still recreate messages. Both controllers now register the marker before
  every persistence-owning claim — the fresh-turn path and the HITL resume — and
  release it only after their pending saves (and any post-terminal title) have
  landed, on success and failure paths alike. The TTL bounds a crash.
- settleAbortFence no longer clears a complete/error fence while
  `terminalPersistencePending` is set: terminal at the CAS is not settled while
  the owner is still persisting. The job facade now surfaces the flag.
- The marker trio is REQUIRED by the runtime store contract (assertJobStoreV2
  refuses a store without it at configure time, keeping the failure loud and
  deterministic) while remaining optional on the legacy public IJobStore type for
  source compatibility. The silent degrade path from the previous round is gone —
  it was not deletion-safe.

Stale-pause recovery retains scheduled evidence:

- Three crash/timeout recovery paths — ApprovalLifecycle.failStalePausePersistence
  and the InMemory/Redis stale-pause cleanups — unconditionally stamped
  `completedAt`, putting a scheduled fire's failed-pause error terminal on the
  short completed TTL. A Mongo outage longer than that TTL erased the evidence and
  the reconciler recovered the run as `interrupted` instead of `error`. All three
  now follow the controller-observed path from the previous round: scheduled jobs
  omit `completedAt` (retained-evidence TTL) and stamp the error outcome.

The PR description now explicitly narrows the deletion guarantee for the remote
OpenAI-compatible/Responses paths (tracked in issue 14594).

* fix: deletion-fence marker protocol — generation-scoped, atomic, fail-closed, all terminal paths

One consolidated pass over the finalization-marker mechanism, per the fourth
external review round. The invariant it establishes: NO persistence-owning
terminal CAS runs without a durable, generation-qualified marker covering the
window it opens, and every consumer treats a pending terminal as unsettled.

- Generation-scoped markers. Entries were keyed (userId, streamId), so a
  Stop-superseded generation finishing late could clear the marker its
  replacement registered on the same conversation. Marker fields are now
  qualified by the generation's createdAt; clears must present the same
  identity, and an unqualified legacy clear cannot drop a qualified entry.
- Atomic Redis registration. HSET-then-EXPIRE loses the fresh marker when the
  user's existing hash expires between the two commands (or the process dies
  there); registration is now a single Lua script carrying both.
- Fail closed everywhere. Registration failure (after one retry) now REFUSES
  the terminal CAS instead of proceeding uncovered: the completion claim throws
  into the error path, the error path skips completeJob and leaves the job
  ACTIVE — deletion-visible by itself, recovered by the stale-running reaper —
  and abortJob returns a new retryable `fence_unavailable` failure the Stop
  route answers with 503 and the deletion quiesce treats as an unacknowledged
  stop (fence kept). The previous round's log-and-proceed is gone.
- Every terminal path enrolled. abortJob now owns its window (register before
  the abort CAS, clear in its finally — the Stop route's checkpoint prune and
  partial save run inside beforePublish, between CAS and publication); the
  interactive and background generation-error paths register before their
  completeJob; the resume controller's error finalization registers before its
  completeJob. A lost or thrown claim releases the marker after pending saves
  flush instead of holding the user's deletion behind the TTL.
- Every pending terminal unsettled. settleAbortFence defers on
  terminalPersistencePending for ALL statuses — including `aborted`, whose
  route-side persistence the previous guard missed.

Also: a direct Redis regression for scheduled stale-pause retention (the P2
test gap), and the PR description no longer claims to carry every commit.

* fix: lease-token lifecycle fences — same-generation isolation, admission fence, undelivered-Stop retention, legacy abort enrollment

Fifth external review round; four P1s, handled as the requested consolidated
lifecycle-fence pass.

- Lease tokens. Marker fields were (streamId, createdAt), shared by every
  contender on the same generation — completion and Stop, or two racing Stops —
  so a losing contender's cleanup erased the winner's still-live marker. Every
  registrant now carries a unique lease token in the field and may only ever
  clear its own lease; unqualified legacy clears cannot touch qualified entries.

- Admission fence. Authentication can pass before the deletion barrier goes up,
  and the durable createJob is several async steps later — a deletion quiesce in
  that window saw neither an active job nor a marker and could cascade before
  the admitted request created its job. The controller now registers an
  admission lease and THEN rereads the deletion barrier: the ordering guarantees
  either this request observes the barrier (403, lease released, slot/claim
  cleanup) or the quiesce observes the lease and defers. Held until createJob is
  durable; released on every refusal and initialization-error path. Fail closed
  when the lease itself cannot be registered (503 retryable).

- Undelivered-Stop retention. abortJob released its lease in a finally even when
  delivery AND publication had provably failed — the job reads terminal
  (invisible to active-set scans), a user Stop writes no durable abort fence,
  and the remote owner keeps generating and will persist its abort-catch writes
  whenever the signal finally lands. The lease is now retained in exactly that
  case, and each resignal attempt heartbeats a fresh lease so the fence outlives
  the TTL for as long as delivery is still being driven. The abort-winning
  turn's own loser-side pending saves are additionally fenced in the controller
  catch (best-effort — those writes are already in flight).

- Legacy abort enrollment. abortMiddleware (assistants abort route fallback for
  non-assistants endpoints) awaited abortJob and then spent usage and saved the
  stopped response AFTER the abort's lease was released. Both writes now run
  inside `beforePublish`, between the abort CAS and publication, covered by the
  same lease as every other abort.

Barrier tests, each verified to fail without its fix: same-generation lease
isolation (store), racing two-Stop loser cleanup (manager, stale-read forced
CAS race), undelivered-Stop lease retention, resignal heartbeat, admission
refusal with lease-before-reread ordering plus release-on-durable-create, and
legacy-abort persistence inside beforePublish.

* fix: heartbeat-backed owner-lifecycle leases close the settlement handoff races

Sixth external review round: the remaining P1 interleavings were one structural
problem — lease handoffs that were not atomic — resolved as the requested
consolidated lifecycle-lease pass.

- Quiesce reads leases BEFORE the active-job scan. The admission-lease -> durable
  -job handoff is only atomic against a reader in the OPPOSITE order of the
  writer: writers hold the lease strictly until the job is active-set visible,
  so leases-first shows every interleaving either the lease or the job.
  Jobs-first allowed a request to create its job after the scan and release its
  lease before the count — hiding both, cascading, and letting the new
  generation persist into a deleted account.

- The abort acknowledgement is fenced by the owner-lifecycle lease. Redis ACKed
  the moment the owner's AbortController tripped; the stopping side released its
  lease on that ACK while the owner's asynchronous abort-catch persistence was
  still ahead. The transport now awaits a manager-installed pre-ACK hook that
  registers a DETERMINISTIC owner lease (exactly one owner exists per
  generation, and determinism is what lets the signal-time registrant and the
  owner's catch-side release agree across processes) before the acknowledgement
  is persisted or published; an owned same-replica abort bridges to the same
  lease before tripping its local controller. Both generation-owner catches
  (fresh turn, resume) release it once their writes land.

- A failed replacement handoff no longer orphans the predecessor. The atomic
  replacement removes it from active storage, and an unconfirmed handoff
  terminalizes the replacement too — leaving nothing a quiesce could discover
  while the predecessor's provider may still be generating. Its owner lease is
  now retained at the point the receipt fails delivery; the owner replica renews
  it through the pre-ACK fence when the signal finally lands.

- Leases HEARTBEAT while held. The five-minute store TTL only bounds a crashed
  holder; live persistence — a stalled save, a long deferred title — must never
  outlive its own fence. holdUserFinalization registers and renews every minute
  until released; the controllers' completion/error/admission leases all hold.
  The undelivered-Stop retention moved to a deterministic `stop` lease that
  every resignal attempt renews and the first successful one clears (no more
  opaque leases accumulating to TTL), and a THROWN abort transition releases the
  contender lease instead of leaking it.

- The user-document abort-fence mutations now invalidate the auth user-doc
  cache, matching every other user-doc write.

Barrier tests, each verified to fail without its fix: quiesce lease-scan
ordering (plus the observed-lease defer), pre-ACK fence ordering at the
transport, owned-abort owner-lease bridging, replacement-handoff predecessor
retention, held-lease heartbeat past the TTL, and failed-then-successful
resignal reaping the retained stop lease.

* fix: one manager-owned owner-lease span across every abort delivery path

Seventh external review round; four lifecycle-fence gaps, closed by making the
owner-lifecycle lease a single manager-owned, heartbeat-held span.

- Fail-closed acknowledgements. The pre-ACK hook registered a one-shot lease and
  the transport ACKed even when it failed; a same-replica owned abort likewise
  swallowed registration failure. The hook now acquires a HELD owner lease
  (heartbeat until the owner's catch releases it via releaseOwnerLease) and a
  rejection SUPPRESSES the acknowledgement — the stopping side stays retryable
  behind its retention lease, and every resignal re-drives the handler. The hook
  also stopped gating on `job.createdAt === generationId`: during a replacement
  handoff the store holds the replacement while the abort targets the
  predecessor, and that gate silently skipped exactly the generation being
  acknowledged (the store job is owner identity, never a generation gate). A
  local owned abort acquires the same held lease before tripping its provider;
  post-CAS the trip cannot be withheld, so acquisition failure downgrades
  delivery and the retention handoff keeps the user fenced.

- Committed-but-lost-reply disambiguation. A thrown abort transition released
  the contender lease as if nothing had happened, but a Lua CAS can commit and
  lose its reply — an aborted job invisible to active-set scans whose provider
  was never signalled, with no fence left. The throw path now re-reads the exact
  generation: only a job still live under the caller's identity proves no
  commit; committed or ambiguous outcomes hand the fence to the deterministic
  stop lease (kept on the contender lease if even that fails) before rethrowing.

- Replacement handoff covered end to end. A LOCAL replacement abort acquires the
  predecessor's held owner lease before the trip (failure reports the receipt
  undelivered, engaging retention). Failed-handoff retention is no longer a
  swallowed one-shot: it heartbeats with the durable acknowledgement proof as
  its renewal predicate — acquisition failures keep retrying for as long as the
  fence is needed, and the retainer stands down (without clearing the shared
  field) once the remote owner ACKs and thereby holds its own lease.

- A LOCAL resignal delivery hands off to the owner lease BEFORE clearing the
  retained stop lease, and keeps the stop lease when that handoff fails.

Barrier tests: hook rejection suppressing the ACK, commit-then-lost-reply
retention with its provably-uncommitted counterpart, local-resignal owner
handoff ordering, pre-ACK owner lease held past the store TTL until release
(and provably stopped after), and failed-handoff retention retrying on its
heartbeat — verified fail-before/pass-after by stashing the fixes.

* fix: finish scheduled chat lifecycle hardening

* fix: close scheduled chat review follow-ups

* fix: generation-fence abort recovery evidence

* test: wait for settled approval tool output

* test: preserve scheduled init reconciliation option

* refactor: rebuild scheduled chats on durable agent triggers

* test: reset MCP cache mock between cases

* test: isolate scheduler startup in server specs

* fix: harden scheduled run lifecycle

* fix: normalize schedule capacity conflicts

* test: type schedule collision fixture

* fix: re-fence scheduled resume and expiry

* fix: fence scheduled resume handoffs

* fix: release superseded manual schedule leases

* fix: release failed run-now claims

* fix: release superseded engine claims

* fix: repair schedule dialog interaction and rework its form

The agent picker was unusable: ControlCombobox portals its popover to the
body by default, which lands it outside the dialog's Radix focus trap. Clicks
passed through it, it could not be tabbed into, and the trap fighting Ariakit
for focus locked the page up on selection. The prop is documented for exactly
this case — pass `portal={false}` and give the dialog `overflow-visible`, as
ProjectButton already does. The time and day dropdowns defaulted the same way.

Alongside that:

- Extract the agent builder's instructions editor (special-variable menu plus
  expand-to-fullscreen) into a controlled `VariableEditor` and use it for the
  schedule prompt. Insertions now route through `onChange`, so react-hook-form
  sees them — the schedule PATCH is built from `dirtyFields`, and a `setValue`
  that skipped dirty tracking would have dropped an inserted variable silently.
- Replace the hand-rolled frequency buttons with the shared `Radio`. They marked
  the selection with `bg-surface-hover` on an outline button whose hover is the
  same token, so the selected option was indistinguishable from a hovered one;
  `Radio` is also a real radiogroup rather than four `aria-pressed` toggles.
- Wrap the fields in a real `<form>` and associate the footer button by id, so
  Enter submits. Group the frequency, day and time controls in fieldsets.
- Add placeholders for name and prompt, match the textarea fill to the other
  fields, and label the hourly case as minutes past the hour.
- Widen the dialog to `md:max-w-3xl` and pair name with agent so the form fits
  without scrolling on desktop.
- Move scheduled chats below skills and above prompts in the side nav.

The new dialog spec fails when the portal fix is reverted.

* test: cover scheduled and subagent deletion drains

* refactor: own the form-control appearance in the client primitives

Addresses the codex finding on ScheduleDialog: a feature-local `FIELD_CLASS`
restated the `Input` primitive's border, radius, height and background so it
could be pasted onto the schedule dropdowns, leaving those controls with no
connection to the primitive they were imitating.

Move that appearance into `packages/client/src/components/Field.ts` as the
single source `Input` and `Textarea` now compose, and give `Dropdown` and
`ControlCombobox` a `variant="field"` that applies it. The schedule dialog
passes the variant and carries no class strings of its own.

This also repairs a break the dev merge would otherwise have introduced: the
newer `Dropdown` splits `className` (wrapper) from `triggerClassName`, so the
old pasted classes would have landed on the wrapper and left the triggers
unstyled.

The semantic-token guard now watches the shared module and asserts each
primitive still composes it, which covers more than the two files it read
before.

* fix: keep an explicit schedules disable from becoming an opt-in

`use` is two things at once for a dual-purpose runtime interface field: a
permission bit, which DB overrides strip, and the runtime disable signal that
`getLimits` reads. Stripping it from `{ use: false, maxPerUser: 2 }` leaves an
object, and `getLimits` treats any object without `use: false` as enabled — so
an admin override written to stop scheduled billing for a role or user started
it instead.

Collapse an explicit disable to the boolean form before the strip, on both
paths that accept it: the `interface.schedules` field patch, which admitted the
object wholesale because bare runtime paths deliberately bypass the permission
gate, and the overrides merge, which reached the composite-field branch and
kept `maxPerUser`. Objects that only narrow limits are untouched, so a
principal can still be given a smaller cap.

Both regressions fail without the normalizer.

* fix(schedules): Wave A — null-balance CAS, atomic paused-card clear, clustered erasure sweep

Slice 1 (thread r3804518381): route the existing-null balance initialization
through a { user, tokenCredits: null } compare-and-set instead of a blind $set,
so a concurrent initializer/charge landing between the preflight read and the
write is never handed back its spent starting balance. On a CAS miss the
preflight re-reads the winner. The absent-record $setOnInsert path and the
credited-record refill-config sync are unchanged. Adds the initializeNullBalance
adapter (no upsert) and regression coverage for winner/miss/sync cases.

Slice 2 (thread r3804518388): updateScheduleById now drops a `requires_action`
lastRun projection atomically with the configRevision bump. Any pause present at
edit time was projected under the pre-edit revision and can never be replaced by
its own revision-fenced terminal outcome — a disabling edit would strand the
card on "Needs approval" forever. Implemented as classic-operator CAS branches
(DocumentDB rules out a conditional pipeline $unset), fenced on the card STILL
being the pause so a terminal outcome or newer occurrence that races in is
preserved. Terminal history survives untouched.

Slice 4 (thread r3803826204): expose initializeScheduleErasureSweep from the
schedule runtime facade and start it in every clustered (experimental) worker
after Mongo is up. It re-drives eraseScheduleIfDrained for soft-deleted rows so
a hidden prompt cannot outlive its drain when the delete/erase-on-settle
attempts miss. It arms nothing else and never infers owner death from a
process-local missing job (isTopologySafeToArm gates that).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZwZPDBgxy4mCWWg6zQSjq

* fix(schedules): Wave B.1 — reversible account-deletion schedule suspension

Thread r3804518383. Account-deletion quiesce marked every schedule `deleting`,
disabled it, and cleared nextRunAt destructively. When a later cascade step (or
the drain itself) failed, the controller cancelled the user-deletion fence —
restoring the user — but the schedules stayed `deleting` and were erased by the
sweep, silently losing all of a live user's scheduled prompts.

Replace the destructive marking with a REVERSIBLE, token-fenced suspension:

- suspendUserSchedulesForDeletion(userId, token) snapshots each schedule's prior
  enabled/nextRunAt under a per-attempt token, then fences firing (disable, clear
  nextRunAt, rotate claimToken). It never sets `deleting`, so a suspended row is
  not erasure-eligible. Snapshotting reads then bulkWrites (a classic update
  cannot copy field values under DocumentDB), fenced per row so an already-
  suspended/soft-deleted/edited row is left alone; idempotent per token.
- restoreUserSchedulesFromDeletion(userId, token) reverses it, re-enabling and
  re-arming only rows still carrying the exact attempt token and not independently
  deleted — so an owner-deleted or newer-attempt-suspended schedule is never
  resurrected.
- deleteUserController generates the attempt token, passes it to quiesce, and on
  any failure that cancels the user-deletion fence restores the suspended rows. A
  successful deletion hard-deletes them (and their snapshots) via the existing
  cascade and never restores.

Adds a `deletionSuspension` embedded field (excluded from the wire schedule),
data-method regression tests (suspend/restore/fence/idempotency/no-resurrect),
and controller tests (restore on drain-false and post-quiesce cascade failure,
no restore on success).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZwZPDBgxy4mCWWg6zQSjq

* fix(schedules): Wave B.2 — wire the interactive Stop persistence protocol

Thread r3804255932. The durable Stop primitives (requestRunAbort 'stop',
getScheduleRunAbortState, markRunAbortPersisted) already existed but production
only used requestRunAbort(..., 'deletion'). An interactive Stop flipped the job
to `aborted` and then persisted its partial message + checkpoint inside
`beforePublish`, without ever stamping the schedule Stop or acknowledging it —
so reconciliation, the generation owner, or a concurrent schedule/account
deletion could terminalize the run and release its capacity (and erase data)
mid-write.

Wire the request -> persist -> acknowledge -> settle barrier through the
schedule runtime (the route never touches raw Mongo):

- Expose beginScheduledStop / acknowledgeScheduledStopPersistence on the service.
- The abort route stamps the Stop BEFORE signalling abortJob (a serialized
  'in_progress' loser returns 409 STOP_IN_PROGRESS without a second abort),
  acknowledges only after beforePublish persistence succeeds, and on a
  persistence failure leaves the barrier unresolved so the run stays preserved
  (client retries; stale-owner timeout is the bounded recovery). A failed abort
  releases the stamp it placed so a replacement/retry is never blocked through
  its predecessor.
- recordScheduleOutcome (the owner settlement path) now waits, bounded, for the
  Stop acknowledgement before terminalizing; a resolved/non-stop/stale marker
  proceeds immediately. The paused Stop settles only after its own ack.

Adds service-layer barrier tests and route-level ordering/persistence-failure/
in-progress tests; the data-layer serialization is already covered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZwZPDBgxy4mCWWg6zQSjq

* fix(schedules): Wave C.1 — reconcile durable trigger delivery with the reservation

Threads r3803826192 (manual limiter) and r3804255924 (PII/moderation), plus the
independently-found long-Retry-After race. fireSchedule reserves a `started` run
and a global capacity slot BEFORE the durable trigger delivery reaches the chat
route, where an interactive limiter (manual Run Now), PII, or moderation can
reject it before any generation job exists — dead-lettering the delivery while
the run sat `started` until the 30-minute orphan sweep mislabeled it interrupted.
And a valid delivery deferred by Retry-After (up to 24h) could be orphan-settled
and have its capacity released, then fire anyway.

Translate durable delivery state into the schedule outcome:

- Store the deterministic trigger deliveryKey on the ScheduleRun reservation
  (computed from the envelope BEFORE enqueue, so an ambiguous commit still has it).
- Add a getTriggerDelivery engine dep (wired to the merged trigger service's
  getDelivery) that reads the durable delivery by key.
- Schedule reconciliation, for a jobless `started` run: staging/pending/leased →
  admission is live, never orphan; dead → record `error` from the durable
  lastError and release capacity promptly (no 30-minute wait), through the
  ordinary outcome/auto-disable path; succeeded or no record → the existing
  legacy orphan policy (interrupted only past the cutoff); a delivery lookup
  failure defers rather than orphaning a possibly-live delivery. Limiter/PII/
  moderation middleware writes no schedule state.

Adds reconcile state-mapping tests (dead/pending/leased/staging/succeeded/none/
lookup-failure) and a fire test that the reservation's deliveryKey equals the
enqueued delivery's idempotency key.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZwZPDBgxy4mCWWg6zQSjq

* fix(stream): Wave C.2 — durable retry/ack for terminal host lifecycle actions

Thread r3804518375. Approval expiry won the `requires_action → aborted` CAS and
then invoked the host hook best-effort: `runApprovalExpiredHandler` swallowed a
failure, and because later sweeps enumerate only `requires_action` jobs, the now-
aborted job was never offered again. In the clustered entrypoint (no schedule
reconciler) the ScheduleRun stayed `requires_action` and its retained job
persisted indefinitely.

Make the host lifecycle work durable rather than schedule-specific:

- Add a generic `terminalHostActionPending` marker, set ATOMICALLY in the same
  terminal transition (ApprovalLifecycle.expireWithIdentity), only when a host
  adapter is installed.
- Retain and index such jobs: both stores keep them out of terminal reaping and
  expose getTerminalHostActionJobs(); Redis adds a set + extended (24h-bounded)
  TTL, in-memory a bounded 24h retention so a permanently-failing hook cannot leak.
- The manager clears the marker only after the adapter acknowledges success,
  fenced by generation identity (clearTerminalHostAction), so a replacement
  generation can neither clear nor execute its predecessor's action.
- cleanup()/expireStaleApprovals() enumerates unacknowledged terminal host actions
  across restarts and replicas and retries the idempotent hook; the relay only
  re-invokes while the marker is unacknowledged, so a successful ack prevents
  duplicate work. Store-won expiry marks it too, so a loser-replica relay still
  crosses the hook.
- Terminal SSE notification continues regardless of host-hook outcome.

Covers in-memory behavior (retry after failure, restart/other-replica retry, ack
prevents duplicates, identity fence, terminal notification on failure, no marker
accumulation for non-scheduled jobs) and updates the Redis cluster-membership
contract test for the new index.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZwZPDBgxy4mCWWg6zQSjq

* style(schedules): satisfy import sorting in fire.ts and fire.spec.ts

CI "Static checks" failed on IMPORT_SORT for the two files Wave C.1 added imports
to (the AgentTriggerEnvelope type import and getAgentTriggerIdempotencyKey).
Applied scripts/sort-imports.mts to exactly those files — imports-only reordering,
no behavior change. Other files reported by a repo-wide check are pre-existing on
dev and deliberately left untouched so this PR is not widened.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZwZPDBgxy4mCWWg6zQSjq

* fix(schedules): repair the deletion CLI and order restore before the fence release

Addresses three findings from the fresh Codex review.

P1 — config/delete-user.js called methods.disableUserSchedulesForDeletion, which
Wave B removed in favor of suspendUserSchedulesForDeletion. The file is
@ts-nocheck and its spec mocked the removed name, so neither typecheck nor tests
caught it; the real CLI would throw a TypeError before deleting anything and then
only unwind the fence. The CLI now uses the tokenized protocol: it mints a
suspension token, suspends with it, and restores that exact attempt's rows in its
finally block when the deletion does not commit. Its spec mocks the real methods,
so the breakage can no longer hide.

P2 — both the HTTP controller and the CLI released the user-deletion fence BEFORE
restoring schedules. That fence is what refuses new schedule writes/claims, so the
gap let an owner PATCH edit a still-suspended row and have its enabled/next-run
state overwritten by the older snapshot, and let a second deletion attempt
re-suspend under a new token — making the first restore a no-op and stranding the
disabled snapshot permanently. Restore now runs first, while writes are still
fenced.

Hardening for the same defect class across a crash: suspendUserSchedulesForDeletion
now ADOPTS an existing suspension's snapshot when re-suspending a row abandoned by
an earlier attempt, instead of re-capturing the row's current (already-suspended)
state. Without this, an attempt that died before restoring would have its
successor snapshot "disabled, no next run" and permanently strand the schedule.

Tests: CLI restore-before-fence ordering and no-restore-on-success; the same
ordering assertion on both controller post-quiesce failure paths; a data-method
regression that a second attempt adopts the abandoned snapshot and restores the
original enabled/next-run state.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZwZPDBgxy4mCWWg6zQSjq

* fix(schedules): converge dead deliveries in topology-safe maintenance

Codex finding: the `dead` delivery mapping added in Wave C.1 lives only inside
startScheduleEngine's reconciler, but the clustered entrypoint arms no engine — it
runs erasure-only maintenance. A delivery queued before a restart into clustered
mode and then rejected before generation creation (interactive limiter, PII,
moderation) dead-letters while its ScheduleRun stays `started`, holding a global
capacity slot indefinitely for an ordinary non-deleting schedule.

Add a dead-delivery convergence pass to the erasure sweep, so every topology that
runs schedule maintenance settles it. The pass is POSITIVE-EVIDENCE-ONLY and is
therefore safe where absence-based reconciliation is not: a `dead` delivery is
durable shared state proving no generation owns the reservation. It settles only
when the job is confirmed absent or identity-mismatched (an identity-matched job
still owns the run), defers on an unknown job lookup, on an in-flight abort, and
on an in-flight resume hand-off, ignores legacy reservations with no deliveryKey,
and applies a short grace so an accepted delivery still creating its generation is
never settled mid-handoff. Auto-disable policy is deliberately left to the armed
engine; this path records the failure and frees the slot.

Deliberately does NOT touch api/server/experimental.js — the clustered entrypoint
already starts this sweep, so the convergence arrives through the existing
initializer and the shared-file footprint stays as-is.

Tests: settles a dead delivery as error under an explicitly UNSAFE topology,
leaves live deliveries alone, never settles under an identity-matched running
generation (delivery is not even consulted), defers an in-flight abort, and
ignores a reservation with no deliveryKey.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZwZPDBgxy4mCWWg6zQSjq

* fix(stream): refresh host-action retention on each retry attempt

Codex finding: unacknowledged terminal host-action evidence was capped at the
24h pause TTL, so a host dependency (Mongo) unreachable for longer than that let
the Redis key — and its pending marker — expire with no generation-fenced
acknowledgement, stranding the ScheduleRun where no reconciler is armed.

Measure retention from the LAST retry rather than from the terminal transition:
enumerating a pending host action IS the retry attempt, so both stores refresh
its retention as they hand it to the hook (Redis re-EXPIREs the job key; the
in-memory store stamps terminalHostActionRefreshedAt and bounds from it). Evidence
therefore survives as long as some replica is still actively retrying, while a
deployment that stops sweeping entirely still lets it age out — so this does not
reintroduce the unbounded leak the cap existed to prevent.

Test: after a failed hook, a later cleanup pass keeps the marker pending and moves
its retention basis forward.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZwZPDBgxy4mCWWg6zQSjq

* fix(schedules): defer settlement when the Stop barrier times out

Codex finding: waitForStopPersistence returned after its 5s poll budget even when
the Stop was still fresh and unacknowledged, and recordScheduleOutcome then went
straight on to terminalize the run — releasing its capacity, deletion, and erasure
barriers while beforePublish may still have been writing. Slow checkpoint cleanup
is indistinguishable from a dead route on that signal, so the timeout was being
treated as if the barrier had been satisfied.

The poll budget now means "undecided", not "clear". On timeout with a fresh,
unacknowledged Stop the barrier DEFERS: recordScheduleOutcome returns false
without recording, leaving the run active/preserved. Settlement then happens
either when the route acknowledges, or once the existing stale-owner cutoff
(ABORT_OWNER_PRESUMED_ALIVE_MS) authorizes a later attempt — which the loop
already treats as clear-to-settle. Callers with durable retry (the approval-expiry
host action, reconciliation) re-drive it, so a deferral converges rather than
stranding the run.

Test: a fresh Stop that never acknowledges within the budget reports not-settled
and records no outcome.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZwZPDBgxy4mCWWg6zQSjq

* fix(schedules): require definite delivery failure, extract its message, converge deferred Stops

Third Codex round. Three findings in code from this closeout, plus one pre-existing
P1 that is a one-line operator-safety fix.

P1 — dead-delivery settlement demanded too little evidence. `dead` does not prove a
request was rejected: the trigger host marks response timeouts and invalid success
responses `certainty: 'ambiguous'`, and the engine dead-letters those once retries
are exhausted. The erasure sweep treated every dead letter as positive evidence, so
an ambiguous one sitting over a generation a peer had accepted could terminalize the
run and release its capacity mid-flight. It now settles only on a DEFINITE rejection,
unless job absence is deployment-authoritative (safe topology), where the
confirmed-absent job is itself the evidence.

P1 — `lastError` is an `AgentTriggerDeliveryFailure` object, not a string. A
duplicated local interface declared it `string` (against CLAUDE.md's no-duplicate-
types rule), so both the sweep and the engine reconciler passed the object into the
String-typed run/schedule `error` fields; Mongoose would reject the cast, the per-row
catch would swallow it, and the run would keep its global capacity slot. The dep type
now reuses the canonical `AgentTriggerDeliveryFailure` and both call sites pass
`.message`. Re-typing immediately surfaced a stale test that had asserted a string.

P1 (pre-existing) — the base-config global stop is honored in `getLimits` via
`isRuntimeDisabled` rather than a literal `=== false`. The stop has two shapes, and
deepMerge turns base `{ use: false }` plus a principal override of `true` into
`{ use: true }`, so the literal check reported the feature enabled and Run Now
dispatched straight through fireSchedule, bypassing the operator's emergency stop.
Now the same predicate the engine gate already uses.

P2 — a Stop whose settlement DEFERRED past the poll budget had no convergence path
where no reconciler is armed. `acknowledgeScheduledStopPersistence` now optionally
re-drives the terminal outcome once the barrier clears; `recordRunOutcome` is
match-guarded and idempotent, so an owner that already settled makes it a no-op. The
abort route passes it for a running generation; a paused job still settles explicitly.

Tests: ambiguous dead letters refused under unsafe topology but settled when absence
is authoritative, definite rejections settled either way, the failure message carried
through, and the abort route's re-drive present for running / absent for paused.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZwZPDBgxy4mCWWg6zQSjq

* fix(schedules): converge terminal runs in clustered workers, retry suspension restore

Fourth Codex round on 74f7af8. Both findings are in code from this closeout.

P2 - a clustered worker never settled a run whose generation finished but whose
outcome write failed. `recordScheduleOutcome` retries three times and then returns
false; the owner honors that by PRESERVING the terminal job as the only surviving
evidence, and the armed engine's reconciler replays exactly that. The clustered
entrypoint arms no engine, and its sweep only covered deleting schedules
(settleAbandonedRuns) and dead deliveries - an identity-matched job was skipped
outright. So an ordinary live schedule's run stayed `started`, held its GLOBAL
capacity slot, and kept a preserved job that carries no `completedAt` and is
therefore invisible to the store's finished-job sweep: both leaked until store
expiry.

The live-schedule pass now also converges from a retained terminal job, mirroring
the reconciler branch it stands in for: honor the owner's stamped outcome over the
generic status (so a balance refusal still walks its streak rather than resetting
it), clear the reserved conversationId when the generation never emitted its
created event, and delete the retained job only AFTER the outcome write is durable.

This stays positive-evidence-only and safe in every topology. Presence, not
absence, is the evidence: an identity-matched job is authoritative wherever it is
observed - a shared store shows the real generation, a process-local store can
only be showing this process's own - which is why it needs no
canInferOwnerDeathFromMissingJob fence, unlike the absence-based paths. The
in-flight abort/resume fences still defer, so an `aborted` job cannot settle a run
whose owner may still be persisting. Both cases now share one pass over one window
rather than two, and `retainedOutcome` moved to types.ts so the sweep reuses the
engine's mapping instead of duplicating it (and stays independent of the engine).

P2 - a failed restore stranded a live account's schedules. Cancelling an account
deletion restores the suspended rows while the deletion fence is still armed and
then releases that fence; nothing re-drives the restore afterwards, so one
transient write failure left the user with silently disabled, next-run-less
schedules. The restore is now retried at the single choke point both the HTTP
controller and the CLI share. Retrying is safe because each attempt re-reads only
the rows STILL carrying the token: a partially-applied unordered write converges
on exactly the stragglers, and a fully-applied one finds nothing.

The fence is still released when every attempt fails, deliberately: retaining it
would refuse the live account's schedule writes AND make beginAgentTriggerUserDeletion
report `in_progress` forever, blocking the retry that is the convergence path (a
later attempt adopts this snapshot, so its cancel restores these exact rows). Both
callers now log the user id and suspension token so the state stays recoverable by
hand if that never happens.

Tests: retained terminal job settled and its evidence released, stamped outcome
preferred over the generic status, stamped failure reason carried, reserved
conversationId cleared for a never-created conversation, identity-mismatched
terminal job ignored, abort-in-flight deferred; restore retried past a transient
write failure and converging on a partially applied restore.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZwZPDBgxy4mCWWg6zQSjq

* fix(schedules): converge unprojected pauses and release replayed bookkeeping jobs

Two of the four findings I flagged as pre-existing to my closeout commits. Both are
in code this PR introduces, so merging would have shipped them; both are the same
capacity/evidence-leak class the last four rounds have been closing. The remaining
two (slotless rows escaping the per-user cap, and the non-rotating `started`
reconciliation bucket) are genuinely latent and deliberately left alone.

An UNPROJECTED PAUSE held a global capacity slot forever. The pause projection is
what moves a run row off `started`; `recordScheduleOutcome` already retries it, but
the request controller discarded the result, so three failed attempts were dropped
silently. The armed engine's reconciler replays that state, but the clustered sweep
did not: a paused job is not terminal, so the retained-job path returned without
settling, and the dead-delivery path never inspects an identity-matched job at all.
The row stayed `started` with no cutoff that would ever clear it.

The call site now surfaces the failure, and the sweep converges it, mirroring the
reconciler's pause branch: project `requires_action` (which frees the slot) but do
NOT release the job's evidence — unlike a terminal job it is still live, awaiting an
approval. The resume hand-off fence still defers, so re-projecting cannot release a
slot a continuation just claimed.

The BOOKKEEPING REPLAY pass leaked its retained job. A run reaches that pass only
because its owner crashed before bookkeeping — which is also before it could release
the job it retained for exactly this recovery. The active-run pass clears its own;
this one never did, and a preserved job is deliberately kept WITHOUT `completedAt`
so the store's finished-job sweep cannot reap it early, so nothing else ever would.
It now clears after `finalizeBookkeeping` succeeds — identity-guarded, a no-op when
no job is retained, and skipped entirely when the replay itself failed, since the
retained job is the only surviving evidence in that case.

Tests: pause projected and the slot freed while the live job's evidence is kept,
paused job deferred during a resume hand-off, retained job released once replayed
bookkeeping is durable, and retained job kept when the replay fails.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZwZPDBgxy4mCWWg6zQSjq

* fix(schedules): fence the clustered pause replay against a concurrent resume claim

Codex round 5, on code I added in f735341. The finding is correct and the exposure
is one I introduced.

The pause replay lives in a sweep that runs in EVERY clustered replica, so several
sweepers can observe the same unprojected pause. Its guards were all derived from an
in-memory row SNAPSHOT: hasResumeHandoffInFlight reads the snapshot's
resumeClaimedAt, and recordRunOutcome's pause branch matched any row currently in
`started`/`requires_action` with no fence of its own. The race: sweeper A projects
the pause and frees the slot; the owner's approval then claims a fresh one
(markRunResumeClaimed takes the row to `started` WITH resumeClaimedAt in one write);
sweeper B, still holding the pre-projection snapshot, passes its hand-off check and
replays — `$unset: { capacitySlot, resumeClaimedAt }` under a continuation that is
already running. The run reverts to `requires_action` while its generation proceeds
outside global capacity.

The engine's reconciler makes the same call and has the same snapshot-derived guard,
but v1 arms exactly one engine, so it has no concurrent racer; the sweep is the first
thing to run this transition in parallel. Left the engine alone rather than widening
the change: its `requires_action` re-affirmation is deliberate and single-writer.

Fixed where the race is, in the write itself. `recordRunOutcome` takes an optional
`requireNoResumeClaim`, which adds `resumeClaimedAt: { $exists: false }` to the pause
filter, and the sweep sets it. Because the stamp is written in the SAME update that
moves the row to `started`, its absence is atomic proof no resume owns the row. The
flag is deliberately NOT set by the generation owner: its own re-pause legitimately
clears the stamp as the hand-off's completion signal.

The fence cannot block the recovery it exists to enable: markRunResumeClaimed only
matches `requires_action`, so a genuinely stuck `started` row can carry no resume
claim — it is in fact blocking its own approval until this replay frees it.

Tests: a replay from a stale snapshot leaves a resume-claimed row's status, slot, and
claim stamp intact, while a stuck row with no claim still recovers; and the sweep is
asserted to send the fence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZwZPDBgxy4mCWWg6zQSjq

* fix(schedules): fence stale resume claims by age, not existence

Codex round 6, on the fence I added in ee00c60. Correct again, and the defect is
the mirror image of the one it fixed.

The fence was an EXISTENCE check (`resumeClaimedAt: { $exists: false }`) while its
caller's guard is a FRESHNESS check (hasResumeHandoffInFlight, bounded by
RESUME_HANDOFF_STALE_MS). They agree while a claim is fresh and disagree once it is
abandoned: a worker that dies after markRunResumeClaimed takes the row to `started`
and stamps resumeClaimedAt — but before the continuation resumes or
releaseRunResumeClaim rolls it back — leaves the stamp set forever. Past the bound
the sweep correctly stops deferring and tries to recover the row, but the write
rejected it purely because the field still existed. The row stayed `started` holding
its global capacity slot, and its approval was unresumable for good, since
markRunResumeClaimed only matches `requires_action`. That is exactly the stuck state
this replay exists to clear, so the fence had reintroduced it for the crashed-resume
case.

`requireNoResumeClaim: boolean` becomes `resumeClaimStaleBefore: Date`, and the
filter matches a row with no claim OR a claim older than that cutoff. The sweep
passes the SAME bound its in-flight check uses, so the two can no longer disagree.
A genuinely racing claim is by construction fresh — it is created after the sweeper's
snapshot — so the race from round 5 stays closed.

Tests: a row whose resume claim was abandoned by a dead worker now recovers (status,
slot and stamp all cleared), alongside the existing two — a fresh claim still repels
a stale-snapshot replay, and an unclaimed stuck row still recovers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CZwZPDBgxy4mCWWg6zQSjq

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-20 11:51:30 -04:00

1101 lines
55 KiB
YAML

# For more information, see the Configuration Guide:
# https://www.librechat.ai/docs/configuration/librechat_yaml
# Configuration version (required)
version: 1.3.14
# Cache settings: Set to true to enable caching
cache: true
# Langfuse connections are managed through Settings > Langfuse when available.
# That flow verifies the credentials and stores the secret key encrypted; do not
# place a plaintext langfuse.secretKey in this file. Environment-managed central
# credentials and optional fanout routing are documented in .env.example.
#
# Self-hosted Langfuse behind an authenticating proxy or gateway can be given
# custom request headers. They are sent on every outbound Langfuse request —
# trace and media export, feedback scores, and credential verification.
# Values support ${ENV_VAR} interpolation; a header whose variable is unset is
# dropped with a warning rather than sent as a literal placeholder.
#
# These are deployment-level: trace export batches spans from every user through
# a single exporter, so unlike endpoints.custom headers they cannot carry
# per-user placeholders such as {{LIBRECHAT_USER_ID}}.
#
# Values are masked in admin config reads and in the startup config log, but
# prefer ${ENV_VAR} references over literal credentials here regardless.
#
# Scope: these are sent only when the deployment configures exactly ONE Langfuse
# origin (a self-hosted base URL, or a single tenant destination URL), and only
# to that origin. The map has no way to say which endpoint it authenticates to,
# so a deployment configuring several origins — e.g. a fanout collector plus a
# separate central host — gets a warning and no headers, rather than having a
# gateway credential sent somewhere it was not meant for.
#
# Fanout deployments additionally need collector support: the gateway forwards
# only Authorization upstream, so a tenant Langfuse behind its own proxy is not
# covered even when the collector receives these.
#
# langfuse:
# headers:
# CF-Access-Client-Id: "${CF_ACCESS_CLIENT_ID}"
# CF-Access-Client-Secret: "${CF_ACCESS_CLIENT_SECRET}"
# File storage configuration
# Single strategy for all file types (legacy format, still supported)
# fileStrategy: "s3"
# Granular file storage strategies (new format - recommended)
# Allows different storage strategies for different file types
# fileStrategy:
# avatar: "s3" # Storage for user/agent avatar images
# image: "firebase" # Storage for uploaded images in chats
# document: "local" # Storage for document uploads (PDFs, text files, etc.)
# Available strategies: "local", "s3", "firebase", "azure_blob", "cloudfront"
# If not specified, defaults to "local" for all file types
# You can mix and match strategies based on your needs:
# - Use S3 for avatars for fast global access
# - Use Firebase for images with automatic optimization
# - Use local storage for documents for privacy/compliance
# - Use CloudFront for CDN-accelerated delivery (requires S3 + cloudfront config below)
# CloudFront CDN Configuration (optional)
# Use when fileStrategy: "cloudfront" or fileStrategies includes cloudfront
# Requires: AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_BUCKET_NAME
# For signed cookies and direct download URLs: CLOUDFRONT_KEY_PAIR_ID, CLOUDFRONT_PRIVATE_KEY
# cloudfront:
# domain: "https://cdn.example.com" # CloudFront domain (CNAME recommended for cookies)
# distributionId: "E1234ABCD" # Required if invalidateOnDelete is true
# invalidateOnDelete: false # Create cache invalidation on file delete
# imageSigning: "none" # "none" (public) | "cookies" (signed cookies)
# # When imageSigning: "cookies", API + CloudFront must share a parent domain.
# # Cookies are path-scoped to inline assets only:
# # /i/... private uploaded/generated images, user-scoped
# # /a/... tenant-visible avatars, tenant-scoped when tenantId is present
# # Downloads, documents, uploads, and code outputs stay outside /i and /a and
# # use backend-authorized signed URLs instead of signed cookies.
# # API: api.example.com, CloudFront CNAME: cdn.example.com, cookieDomain: ".example.com"
# cookieDomain: ".example.com" # Required for "cookies" - shared parent domain
# cookieExpiry: 1800 # Cookie lifetime in seconds (max: 604800 / 7 days, default: 1800 / 30 min)
# urlExpiry: 3600 # Signed CloudFront download URL lifetime in seconds
# # Optional multi-region S3/CloudFront layout. Default: false.
# # When enabled, new object keys include:
# # /i/r/{storageRegion}/t/{tenantId}/images/{userId}/{file} (private inline images)
# # /a/r/{storageRegion}/t/{tenantId}/avatars/{userId}/{file} (tenant-visible avatars)
# # /r/{storageRegion}/t/{tenantId}/{basePath}/{userId}/{file} (non-inline files)
# # storageRegion defaults to AWS_REGION when omitted, but only affects new keys
# # when includeRegionInPath is true. Existing files are not moved automatically.
# # LibreChat does not configure CloudFront origins, Route53, or regional routing.
# storageRegion: "us-east-2"
# includeRegionInPath: false
# # Direct-download filename/content-type overrides require the CloudFront cache/origin
# # request policy to forward and cache on response-content-disposition and
# # response-content-type query strings to S3.
# # Recommended for download paths: attach a CloudFront response headers policy
# # with X-Content-Type-Options: nosniff and CSP default-src 'none'.
# Skill sync configuration (optional)
# GitHub tokens are referenced from environment variables. Put the token in
# `.env`, then reference it here with `token: '${GITHUB_SKILLS_TOKEN}'`. Use a
# GitHub fine-grained personal access token scoped to the selected repository
# with read-only Contents and Metadata permissions.
# skillSync:
# github:
# enabled: false
# intervalMinutes: 60
# runOnStartup: true
# sources:
# - id: librechat-skills
# owner: your-org
# repo: your-skills-repo
# ref: main
# paths:
# - skills
# # Number of directory levels below each configured path to scan for
# # `SKILL.md`. Use 2 for repos shaped like `skills/<category>/<skill>`.
# skillDiscoveryDepth: 2
# token: '${GITHUB_SKILLS_TOKEN}'
# # Optional. Owns the mirrored skills under the given tenant so they are
# # created and shared within that tenant. Required for visibility when
# # tenant isolation is enabled. Omit for single-tenant deployments.
# # Treat as immutable per source id: changing (or adding/removing) the
# # tenantId later leaves previously mirrored skills in the old tenant,
# # where this source's sync can no longer see or clean them up. To move a
# # source between tenants, delete its mirrored skills in the old tenant
# # first, or use a new source id for the new tenant.
# # tenantId: your-tenant-id
# Custom interface configuration
interface:
customWelcome: 'Welcome to LibreChat! Enjoy your experience.'
# Enable/disable file search as a chatarea selection (default: true)
# Note: This setting does not disable the Agents File Search Capability.
# To disable the Agents Capability, see the Agents Endpoint configuration instead.
fileSearch: true
# Privacy policy settings
privacyPolicy:
externalUrl: 'https://librechat.ai/privacy-policy'
openNewTab: true
# Terms of service
termsOfService:
externalUrl: 'https://librechat.ai/tos'
openNewTab: true
modalAcceptance: true
modalTitle: 'Terms of Service for LibreChat'
modalContent: |
# Terms and Conditions for LibreChat
*Effective Date: February 18, 2024*
Welcome to LibreChat, the informational website for the open-source AI chat platform, available at https://librechat.ai. These Terms of Service ("Terms") govern your use of our website and the services we offer. By accessing or using the Website, you agree to be bound by these Terms and our Privacy Policy, accessible at https://librechat.ai//privacy.
## 1. Ownership
Upon purchasing a package from LibreChat, you are granted the right to download and use the code for accessing an admin panel for LibreChat. While you own the downloaded code, you are expressly prohibited from reselling, redistributing, or otherwise transferring the code to third parties without explicit permission from LibreChat.
## 2. User Data
We collect personal data, such as your name, email address, and payment information, as described in our Privacy Policy. This information is collected to provide and improve our services, process transactions, and communicate with you.
## 3. Non-Personal Data Collection
The Website uses cookies to enhance user experience, analyze site usage, and facilitate certain functionalities. By using the Website, you consent to the use of cookies in accordance with our Privacy Policy.
## 4. Use of the Website
You agree to use the Website only for lawful purposes and in a manner that does not infringe the rights of, restrict, or inhibit anyone else's use and enjoyment of the Website. Prohibited behavior includes harassing or causing distress or inconvenience to any person, transmitting obscene or offensive content, or disrupting the normal flow of dialogue within the Website.
## 5. Governing Law
These Terms shall be governed by and construed in accordance with the laws of the United States, without giving effect to any principles of conflicts of law.
## 6. Changes to the Terms
We reserve the right to modify these Terms at any time. We will notify users of any changes by email. Your continued use of the Website after such changes have been notified will constitute your consent to such changes.
## 7. Contact Information
If you have any questions about these Terms, please contact us at contact@librechat.ai.
By using the Website, you acknowledge that you have read these Terms of Service and agree to be bound by them.
modelSelect: true
parameters: true
presets: true
prompts:
use: true
create: true
share: false
public: false
bookmarks: true
multiConvo: true
agents:
use: true
create: true
share: false
public: false
# Scheduled chats are experimental and disabled unless explicitly enabled.
# A deployment with Redis-backed resumable streams can run this on every replica.
# A single-process deployment without Redis must also set
# `SCHEDULES_SINGLE_PROCESS=true`; unsafe multi-replica writes fail closed.
# schedules:
# use: true
# create: true
# maxPerUser: 10
# minIntervalMinutes: 60
# autoDisableAfterFailures: 5
# fireConcurrency: 5
peoplePicker:
users: true
groups: true
roles: true
marketplace:
use: false
fileCitations: true
# Tools pinned to the prompt bar by default for all users.
# Only seeds the initial state — once a user pins/unpins a tool, their choice is kept.
# Valid tool keys: artifacts, execute_code, web_search, file_search, skills.
# Use 'mcp' (or a specific MCP server name) to pin the MCP servers dropdown.
# When omitted, tools start unpinned and the MCP dropdown keeps its default (pinned).
# defaultPinnedTools:
# - 'artifacts'
# - 'execute_code'
# - 'mcp'
# Remote Agents configuration
# Controls user permissions for remote agents with external API support
# remoteAgents:
# use: false
# create: false
# share: false
# public: false
# MCP Servers configuration example
# Shared Links configuration
# Controls user permissions for shared links (e.g. sharing conversations via link)
# sharedLinks:
# create: false
# share: true
# public: true # Allows users to toggle "share with everyone" for their links. Whether anonymous access is permitted is controlled by ALLOW_SHARED_LINKS_PUBLIC.
# snapshotFiles: true # Snapshot files referenced by a shared chat so viewers can preview/download them via the link. Enabled by default; the SHARED_LINKS_SNAPSHOT_FILES env var overrides this.
# mcpServers:
# Controls user permissions for MCP (Model Context Protocol) server management
# - use: Allow users to use configured MCP servers
# - create: Allow users to create and manage new MCP servers
# - share: Allow users to share MCP servers with other users
# - public: Allow users to share MCP servers publicly (with everyone)
# Creation / edit MCP server config Dialog config example
# trustCheckbox:
# label:
# en: 'I understand and I want to continue'
# de: 'Ich verstehe und möchte fortfahren'
# de-DE: 'Ich verstehe und möchte fortfahren' # You can narrow translation to regions like (de-DE or de-CH)
# subLabel:
# en: |
# Librechat hasn't reviewed this MCP server. Attackers may attempt to steal your data or trick the model into taking unintended actions, including destroying data. <a href="https://google.de" target="_blank"><strong>Learn more.</strong></a>
# de: |
# LibreChat hat diesen MCP-Server nicht überprüft. Angreifer könnten versuchen, Ihre Daten zu stehlen oder das Modell zu unbeabsichtigten Aktionen zu verleiten, einschließlich der Zerstörung von Daten. <a href="https://google.de" target="_blank"><strong>Mehr erfahren.</strong></a>
# Temporary chat retention period in hours (default: 720, min: 1, max: 8760)
# temporaryChatRetention: 1
# Retention mode: "all" applies expiry to all data types, "temporary" (default) only to temporary chats
# Before switching from "all" back to "temporary", remove retention deadlines from non-temporary data
# that should stop expiring:
# db.conversations.updateMany({ isTemporary: false, expiredAt: { $ne: null } }, { $unset: { expiredAt: 1 } })
# db.messages.updateMany({ isTemporary: false, expiredAt: { $ne: null } }, { $unset: { expiredAt: 1 } })
# MongoDB does not drop superseded indexes automatically. After upgrading, old Meili indexes
# such as "_meiliIndex_1_expiredAt_1" can be dropped from conversations/messages once the new
# "_meiliIndex_1_isTemporary_1_expiredAt_1" indexes exist.
# retentionMode: "temporary"
# Set retainAgentFiles to true to keep persistent agent resource files from expiring under
# retentionMode: "all"; non-agent files still expire.
# retainAgentFiles: false
# Example Cloudflare turnstile (optional)
#turnstile:
# siteKey: "your-site-key-here"
# options:
# language: "auto" # "auto" or an ISO 639-1 language code (e.g. en)
# size: "normal" # Options: "normal", "compact", "flexible", or "invisible"
# Example Registration Object Structure (optional)
registration:
socialLogins: ['github', 'google', 'discord', 'openid', 'facebook', 'apple', 'saml']
# allowedDomains:
# - "gmail.com"
# Example Balance settings
# balance:
# enabled: false
# startBalance: 20000
# autoRefillEnabled: false
# refillIntervalValue: 30
# refillIntervalUnit: 'days'
# refillAmount: 10000
# Example Transactions settings
# Controls whether to save transaction records to the database
# Default is true (enabled)
#transactions:
# enabled: false
# Note: If balance.enabled is true, transactions will always be enabled
# regardless of this setting to ensure balance tracking works correctly
# Speech (STT/TTS) outbound requests to operator-provided target URLs are SSRF-guarded
# at connect time: private, loopback, link-local, and cloud-metadata targets are blocked
# by default. To point STT/TTS at a private or self-hosted service (LocalAI, a self-hosted
# Whisper server), add its host:port to `allowedAddresses` on the `stt` / `tts` section.
# SECURITY: `allowedAddresses` entries are trusted before the private-IP check. A listed
# host:port is permitted even when it resolves to a private IP, so list only hosts you fully
# control and that cannot be repointed by an attacker. Do not list attacker-controllable or
# DNS-rebindable hostnames, because doing so re-opens the private-address path this guard
# closes. Prefer a private IP literal over a hostname when exempting a private target. Entries
# must include a port (`host:port`, `private.ip:port`, or `[ipv6]:port`); do not use URLs, paths,
# CIDR ranges, bare hosts/IPs, or public IP literals.
# When a forward proxy is configured (PROXY / HTTP(S)_PROXY), it performs DNS and egress in its
# own network context, so these requests are delegated to it and it must be SSRF-enforcing; the
# connect-time guard only covers direct, non-proxied connections.
# speech:
# tts:
# openai:
# url: ''
# apiKey: '${TTS_API_KEY}'
# model: ''
# voices: ['']
# allowedAddresses:
# - 'localhost:8020'
# - '127.0.0.1:8020'
#
# stt:
# openai:
# url: ''
# apiKey: '${STT_API_KEY}'
# model: ''
# allowedAddresses:
# - 'localhost:8000'
# - '127.0.0.1:8000'
# OCR (Mistral / Mistral-compatible) outbound requests to `ocr.baseURL` are SSRF-guarded at
# connect time with the same default-deny for private targets. To point OCR at a private or
# self-hosted Mistral-compatible service, add its host:port to `allowedAddresses`. The same
# trust caveat as speech applies: a listed host:port is trusted before the private-IP check,
# so list only hosts you fully control and that cannot be repointed by an attacker. Entries
# must include a port and must not be URLs, paths, CIDR ranges, bare hosts/IPs, or public IP
# literals.
# ocr:
# baseURL: '${OCR_BASEURL}'
# apiKey: '${OCR_API_KEY}'
# allowedAddresses:
# - 'localhost:8080'
# - '127.0.0.1:8080'
# rateLimits:
# fileUploads:
# ipMax: 100
# ipWindowInMinutes: 60 # Rate limit window for file uploads per IP
# userMax: 50
# userWindowInMinutes: 60 # Rate limit window for file uploads per user
# conversationsImport:
# ipMax: 100
# ipWindowInMinutes: 60 # Rate limit window for conversation imports per IP
# userMax: 50
# userWindowInMinutes: 60 # Rate limit window for conversation imports per user
# Agent Actions domain restrictions (OpenAPI spec validation)
# SECURITY: If not configured, SSRF targets are blocked (localhost, private IPs, .internal/.local TLDs).
# Prefer `allowedAddresses` for permitting internal targets — adding a private IP to
# `allowedDomains` switches the field into strict-whitelist mode and blocks every
# public destination not also listed.
# Supports wildcards: '*.example.com' and protocol/port restrictions: 'https://api.example.com:8443'
actions:
allowedDomains:
- 'swapi.dev'
- 'librechat.ai'
- 'google.com'
# - 'http://10.225.26.25:7894' # Internal IP with protocol/port (uncomment if needed)
# `allowedAddresses` is an SSRF exemption list, NOT a strict whitelist.
# Hostname/IP + port pairs listed here bypass the default-deny block for that
# one private/loopback/link-local service. Public domains continue to work
# normally — listing private targets here does not restrict access to anything else.
#
# Entries must include a port: `host:port`, `private.ip:port`, or `[ipv6]:port`.
# Do not use URLs, paths, CIDR ranges, bare hosts/IPs, or public IP literals.
# Public IP literals are rejected at config load (the field is scoped to
# private IP space; public IPs aren't SSRF targets).
#
# NOTE on hostnames: a hostname entry trusts whatever IP that name resolves to
# on the listed port. If DNS for that name is hijacked or rotated to a different
# private IP, the exemption follows. Only list hostnames whose DNS you control.
# Prefer literal IPs when you can.
# allowedAddresses:
# - 'host.docker.internal:11434'
# - '127.0.0.1:11434'
# - '10.0.0.5:8080'
# Custom endpoint baseURL exemption list
# SECURITY: User-provided baseURLs (`baseURL: 'user_provided'`) are validated against
# the same SSRF block as Actions and MCP. If your users legitimately point at private
# services (self-hosted Ollama, internal LLM gateway, etc.), list those hostname/IP
# + port pairs here so the validator allows them through. Public destinations are
# unaffected.
#
# Entries must include a port: `host:port`, `private.ip:port`, or `[ipv6]:port`.
# Do not use URLs, paths, CIDR ranges, bare hosts/IPs, or public IP literals.
# Hostname entries trust whatever IP they resolve to on the listed port — only
# list names whose DNS you control.
# endpoints:
# allowedAddresses:
# - 'localhost:11434'
# - '127.0.0.1:11434'
# - 'ollama:11434'
# - '10.0.0.5:8080'
# MCP Server domain restrictions for remote transports (SSE, WebSocket, HTTP)
# SECURITY: If not configured, SSRF targets are blocked (localhost, private IPs, .internal/.local TLDs).
# Prefer `allowedAddresses` for permitting internal targets — adding a private host to
# `allowedDomains` switches the field into strict-whitelist mode and blocks every
# public destination not also listed.
# Supports wildcards: '*.example.com' matches 'api.example.com', 'staging.example.com', etc.
# Supports protocol/port restrictions: 'https://api.example.com:8443' restricts to specific protocol/port.
# mcpSettings:
# allowedDomains:
# - 'host.docker.internal' # Docker host access (required for Docker setups)
# - 'localhost' # Local development
# - '*.example.com' # Wildcard subdomain
# - 'https://secure.api.com' # Protocol-restricted
# - 'http://internal:8080' # Protocol and port restricted
# # allowedAddresses is an SSRF exemption list (private-IP-space only).
# # Hostname/IP + port pairs listed here bypass the default-deny block for that
# # one private service; public destinations remain reachable. Useful when you
# # want default SSRF protection AND specific internal MCP servers. Entries must
# # include a port (`host:port`, `private.ip:port`, or `[ipv6]:port`) and must
# # not be URLs, paths, CIDR ranges, bare hosts/IPs, or public IP literals.
# # Hostname entries trust whatever IP they resolve to on the listed port.
# allowedAddresses:
# - 'host.docker.internal:8080'
# - '127.0.0.1:8080'
# Example MCP Servers Object Structure
# mcpServers:
# everything:
# # type: sse # type can optionally be omitted
# url: http://localhost:3001/sse
# # proxy: "${MCP_PROXY_URL}" # optional outbound proxy (http/https/socks/socks5)
# timeout: 60000 # 1 minute timeout for this server, this is the default timeout for MCP servers.
# puppeteer:
# type: stdio
# command: npx
# args:
# - -y
# - "@modelcontextprotocol/server-puppeteer"
# timeout: 300000 # 5 minutes timeout for this server
# filesystem:
# # type: stdio
# command: npx
# args:
# - -y
# - "@modelcontextprotocol/server-filesystem"
# - /home/user/LibreChat/
# iconPath: /home/user/LibreChat/client/public/assets/logo.svg
# mcp-obsidian:
# command: npx
# args:
# - -y
# - "mcp-obsidian"
# - /path/to/obsidian/vault
# Definition of custom endpoints
endpoints:
# assistants:
# disableBuilder: false # Disable Assistants Builder Interface by setting to `true`
# pollIntervalMs: 3000 # Polling interval for checking assistant updates
# timeoutMs: 180000 # Timeout for assistant operations
# # Should only be one or the other, either `supportedIds` or `excludedIds`
# supportedIds: ["asst_supportedAssistantId1", "asst_supportedAssistantId2"]
# # excludedIds: ["asst_excludedAssistantId"]
# # Only show assistants that the user created or that were created externally (e.g. in Assistants playground).
# # privateAssistants: false # Does not work with `supportedIds` or `excludedIds`
# # (optional) Models that support retrieval, will default to latest known OpenAI models that support the feature
# retrievalModels: ["gpt-4-turbo-preview"]
# # (optional) Assistant Capabilities available to all users. Omit the ones you wish to exclude. Defaults to list below.
# capabilities: ["code_interpreter", "retrieval", "actions", "tools", "image_vision"]
# agents:
# # (optional) Default recursion depth for agents, defaults to 25
# recursionLimit: 50
# # (optional) Max recursion depth for agents, defaults to 25
# maxRecursionLimit: 100
# # (optional) Abort a run once a single streamed tool call's arguments exceed this many bytes.
# # Guards against runaway malformed tool-call generation. Defaults to 65536 (64 KiB); 0 disables.
# maxToolCallArgBytes: 65536
# # (optional) Abort a run once a single model generation emits more than this many stream events.
# # Defense in depth against looping provider streams. Disabled by default.
# maxDeltaEventsPerTurn: 100000
# # (optional) Per-tool overrides for maxToolCallArgBytes, keyed by tool name; 0 disables that
# # tool's guard. LibreChat ships { create_file: 131072 } so document-sized file writes are not
# # cut off; entries here merge over (and can replace) that default.
# maxToolCallArgBytesByTool:
# create_file: 131072
# # (optional) Disable the builder interface for agents
# disableBuilder: false
# # (optional) When conversation titles are generated:
# # immediate (default): generate as soon as the request is made, in parallel
# # with the response, from the user's first message (title appears within ~1-2s).
# # final: defer generation until the full response completes (legacy behavior).
# # Set under `endpoints.all` instead to apply as the global default for all endpoints.
# titleTiming: immediate
# # (optional) Generate one-line headers for blocks of Agent reasoning and tool calls.
# # Header generation is a separate model call whose usage and cost are recorded.
# activityLabel: true
# activityEndpoint: openAI
# activityModel: gpt-4.1-nano
# # activityPrompt: 'Write a short activity label...'
# # activityMaxPerRun: 20
# # activityCharLimit: 600
# # (optional) Replace the generic Thinking/Thoughts heading with a live
# # generated orientation as sufficiently long top-level reasoning evolves.
# # Enabling this sends a bounded snapshot (up to 4,000 characters) of the
# # visible reasoning to the resolved label endpoint, which may be a different provider.
# # With Langfuse tracing enabled, that snapshot is also recorded as generation input
# # unless the active redaction policy suppresses the label call.
# # reasoningLabel: true
# # reasoningLabelModel: gpt-4.1-nano # falls back to activity/title/run model
# # reasoningLabelEndpoint: openAI # falls back to activity/run endpoint
# # reasoningLabelPrompt: 'Describe the current reasoning direction...'
# # reasoningLabelMinChars: 500 # text required before the first label
# # reasoningLabelUpdateChars: 400 # new text between streaming revisions
# # reasoningLabelUpdateIntervalMs: 3000 # minimum time between streaming revisions
# # A final rewrite may run immediately after a meaningful 120-character tail.
# # reasoningLabelMaxPerRun: 8 # provider-call cap per response
# # (optional) Maximum total citations to include in agent responses, defaults to 30
# maxCitations: 30
# # (optional) Maximum citations per file to include in agent responses, defaults to 7
# maxCitationsPerFile: 7
# # (optional) Minimum relevance score for sources to be included in responses, defaults to 0.45 (45% relevance threshold)
# # Set to 0.0 to show all sources (no filtering), or higher like 0.7 for stricter filtering
# minRelevanceScore: 0.45
# # (optional) Maximum explicit subagents per agent, for both the flat list and
# # graph definitions. Defaults to 10; hard cap 50.
# maxSubagents: 20
# # (optional) Cap the number of active accessible skills shown in the model-visible catalog.
# # Useful for large organizations where many department-specific skills may be available.
# skills:
# maxCatalogSkills: 20
# # (optional) Agent Capabilities available to all users. Omit the ones you wish to exclude. Defaults to list below.
# capabilities: ["deferred_tools", "execute_code", "file_search", "web_search", "artifacts", "subagents", "actions", "context", "skills", "memory", "ask_user_question", "tools", "chain", "ocr"]
# # The following capabilities are opt-in and must be added explicitly:
# # "programmatic_tools", "stateful_code_sessions", "run_in_background", "tool_intents"
# # "stateful_code_sessions" is highly experimental and may change substantially.
# # (optional) Limit the workspace scopes users may select. Omit to allow all three.
# statefulCodeSessions:
# allowedEnvironments: ["user", "agent-user", "conversation"]
# # "run_in_background" makes Code Interpreter tools eligible by default and enables per-tool MCP opt-in.
# # "tool_intents" enables live model-written labels for native tools and opted-in MCP tools.
# # (optional) Require user approval before matching tool calls. Disabled by default.
# toolApproval:
# enabled: true
# mode: default # default, dontAsk, or bypass
# allow: ["mcp:trusted-server:read_*"]
# deny: ["mcp:*:delete_*"] # Deny rules always take precedence
# ask: ["mcp:*:*"]
# reason: "Review {tool} before it runs."
# # (optional) Persist Agent runs paused for approval or Ask User. MongoDB is the durable default.
# checkpointer:
# type: mongo # mongo (default) or memory (single-process development only)
# ttl: 86400 # Approval window in seconds; defaults to 24 hours
# (optional) Custom request headers for the built-in OpenAI / Google endpoints.
# Forwarded on every request to the provider (or an AI gateway / reverse proxy
# in front of it) while keeping provider-native request shaping intact. Values
# support env vars (${VAR}), user fields ({{LIBRECHAT_USER_*}}), and request-body
# fields ({{LIBRECHAT_BODY_CONVERSATIONID}}). Set the same `headers:` block under
# `endpoints.all` to apply globally across endpoints (endpoint values win on key
# collisions). NOTE: send metadata headers like these only behind a gateway that
# consumes them — native provider APIs ignore unknown headers.
# openAI:
# headers:
# cf-aig-metadata: '{"user_email":"{{LIBRECHAT_USER_EMAIL}}","app":"librechat"}'
# google:
# headers:
# cf-aig-metadata: '{"user_email":"{{LIBRECHAT_USER_EMAIL}}","app":"librechat"}'
# Anthropic endpoint configuration with Vertex AI support
# Use this to run Anthropic Claude models through Google Cloud Vertex AI
# anthropic:
# # (optional) Override the adaptive stream-smoothing cadence in milliseconds.
# # Agents SDK-backed providers smooth at 25ms by default; set 0 to disable
# # smoothing. (Legacy Assistants/Ollama paths sleep this long per chunk instead.)
# streamRate: 20
# # (optional) Title model for conversation titles
# titleModel: claude-3.5-haiku # Use the visible model name (key from models config)
# # (optional) Custom request headers, same placeholder resolution as above.
# # Useful for correlating reverse-proxied requests by conversation, since an
# # unknown header is simply ignored by the native Anthropic API.
# headers:
# cf-aig-metadata: '{"user_email":"{{LIBRECHAT_USER_EMAIL}}","app":"librechat"}'
# X-Conversation-Id: '{{LIBRECHAT_BODY_CONVERSATIONID}}'
#
# # Vertex AI Configuration - enables running Claude models via Google Cloud
# # This is similar to Azure OpenAI but for Anthropic models on Google Cloud
# # Vertex AI is automatically enabled when this config section is present
# vertex:
# # Vertex AI region (optional, defaults to 'us-east5')
# # Available regions: us-east5, us-central1, europe-west1, europe-west4, asia-southeast1
# # Multi-region endpoints: us, eu, global
# # IMPORTANT: specific regional endpoints only serve Claude Sonnet 4.6 and earlier. Newer
# # models (Opus 4.7+, Opus 5, Sonnet 5, Fable 5) require "global" or a multi-region value
# # ("us"/"eu") and will 404 on a specific region. "global" also avoids the 10% regional premium.
# region: "us-east5"
# # Path to Google service account key file (optional)
# # If not specified, uses GOOGLE_SERVICE_KEY_FILE env var or default path (api/data/auth.json)
# # The project_id is automatically extracted from the service key file
# # serviceKeyFile: "/path/to/service-account.json"
# # Google Cloud Project ID (optional) - auto-detected from service key file
# # Only specify if you need to override the project_id in your service key
# # projectId: "${VERTEX_PROJECT_ID}"
#
# # ============================================================================
# # Model Configuration - Set Visible Model Names and Deployment Mappings
# # Similar to Azure OpenAI model naming pattern
# # ============================================================================
#
# # Option 1: Simple array (legacy format - model name = deployment name)
# # Use this if you want the technical model IDs to show in the UI
# # models:
# # - "claude-fable-5"
# # - "claude-opus-5"
# # - "claude-opus-4-8"
# # - "claude-sonnet-4-6"
# # - "claude-3-7-sonnet-20250219"
# # - "claude-3-5-sonnet-v2@20241022"
# # - "claude-3-5-haiku@20241022"
#
# # Option 2: Object format with custom visible names (RECOMMENDED)
# # The key is the visible model name shown in the UI (can be any name you want)
# # The deploymentName is the actual Vertex AI model ID used for API calls
# # You can use friendly names (avoid spaces for cleaner YAML) or technical IDs as keys
# models:
# claude-fable-5:
# deploymentName: claude-fable-5
# claude-opus-5:
# deploymentName: claude-opus-5
# claude-opus-4.8:
# deploymentName: claude-opus-4-8
# claude-opus-4.5:
# deploymentName: claude-opus-4-5@20251101
# claude-sonnet-4:
# deploymentName: claude-sonnet-4-6
# claude-3.7-sonnet:
# deploymentName: claude-3-7-sonnet-20250219
# claude-3.5-sonnet:
# deploymentName: claude-3-5-sonnet-v2@20241022
# claude-3.5-haiku:
# deploymentName: claude-3-5-haiku@20241022
#
# # Option 3: Mixed format with default deploymentName
# # Set a default deploymentName and use boolean values for models
# # deploymentName: claude-sonnet-4-6
# # models:
# # claude-sonnet-4: true # Will use the default deploymentName
# # claude-3.5-haiku:
# # deploymentName: claude-3-5-haiku@20241022 # Override for this model
custom:
# Anthropic-compatible Example (native `/v1/messages` API)
# Set `provider: anthropic` to use the native Anthropic client instead of the
# default OpenAI-compatible one — for Anthropic itself or Anthropic-compatible
# gateways (e.g. AI gateways, OpenCode Zen). The `baseURL` must be the API root
# the Anthropic SDK appends `/v1/messages` to. List models explicitly: model
# auto-fetch uses the OpenAI `/models` convention and is not used for this provider.
- name: 'Claude-Compatible'
provider: 'anthropic'
apiKey: '${ANTHROPIC_API_KEY}'
baseURL: 'https://api.anthropic.com'
# (optional) headers forwarded on every request (e.g. for a reverse proxy);
# values support the same placeholders as built-in endpoints.
headers:
anthropic-version: '2023-06-01'
models:
default:
- 'claude-sonnet-4-5'
- 'claude-opus-4-5'
fetch: false
titleConvo: true
titleModel: 'claude-sonnet-4-5'
# Agent activity groups: collapse each block of reasoning + tool calls
# under a generated one-line header. Same shape as the title options.
activityLabel: true
activityModel: 'claude-3-5-haiku'
# activityEndpoint: 'anthropic' # run labels on another endpoint's credentials
# activityPrompt: 'Write a 5-9 word past-tense label...'
# activityMaxPerRun: 20 # cost cap per response
# activityCharLimit: 600 # per-entry prompt truncation
# Parent phase summaries are an independent opt-in. They collapse 2+
# logical activities before the answer into one run-level summary.
# activityPhaseLabel: true
# activityPhaseModel: 'claude-3-5-haiku' # falls back to activity/title/run model
# activityPhaseEndpoint: 'anthropic' # falls back to activity/run endpoint
# activityPhasePrompt: 'Summarize the completed agent phase...'
# activityPhaseMaxPerRun: 5 # cost cap per response
# Live reasoning labels are also independent. They update one top-level
# THINK heading in place and never create or shift message content parts.
# Enabling this sends a bounded snapshot (up to 4,000 characters) of the
# visible reasoning to the resolved label endpoint, which may be a different provider.
# With Langfuse tracing enabled, that snapshot is also recorded as generation input
# unless the active redaction policy suppresses the label call.
# reasoningLabel: true
# reasoningLabelModel: 'claude-3-5-haiku' # falls back to activity/title/run model
# reasoningLabelEndpoint: 'anthropic' # falls back to activity/run endpoint
# reasoningLabelPrompt: 'Describe the current reasoning direction...'
# reasoningLabelMinChars: 500
# reasoningLabelUpdateChars: 400
# reasoningLabelUpdateIntervalMs: 3000
# A final rewrite may run immediately after a meaningful 120-character tail.
# reasoningLabelMaxPerRun: 8
modelDisplayLabel: 'Claude (Compatible)'
# Groq Example
- name: 'groq'
apiKey: '${GROQ_API_KEY}'
baseURL: 'https://api.groq.com/openai/v1/'
models:
default:
- 'llama3-70b-8192'
- 'llama3-8b-8192'
- 'llama2-70b-4096'
- 'mixtral-8x7b-32768'
- 'gemma-7b-it'
fetch: false
titleConvo: true
titleModel: 'mixtral-8x7b-32768'
modelDisplayLabel: 'groq'
# Mistral AI Example
- name: 'Mistral' # Unique name for the endpoint
# For `apiKey` and `baseURL`, you can use environment variables that you define.
# recommended environment variables:
apiKey: '${MISTRAL_API_KEY}'
baseURL: 'https://api.mistral.ai/v1'
# Models configuration
models:
# List of default models to use. At least one value is required.
default: ['mistral-tiny', 'mistral-small', 'mistral-medium']
# Fetch option: Set to true to fetch models from API.
fetch: true # Defaults to false.
# Optional configurations
# Title Conversation setting
titleConvo: true # Set to true to enable title conversation
# Title Method: Choose between "completion" or "functions".
# titleMethod: "completion" # Defaults to "completion" if omitted.
# Title Model: Specify the model to use for titles.
titleModel: 'mistral-tiny' # Defaults to "gpt-3.5-turbo" if omitted.
# Summarize setting: Set to true to enable summarization.
# summarize: false
# Summary Model: Specify the model to use if summarization is enabled.
# summaryModel: "mistral-tiny" # Defaults to "gpt-3.5-turbo" if omitted.
# The label displayed for the AI model in messages.
modelDisplayLabel: 'Mistral' # Default is "AI" when not set.
# Add additional parameters to the request. Default params will be overwritten.
# addParams:
# safe_prompt: true # This field is specific to Mistral AI: https://docs.mistral.ai/api/
# Drop Default params parameters from the request. See default params in guide linked below.
# NOTE: For Mistral, it is necessary to drop the following parameters or you will encounter a 422 Error:
dropParams: ['stop', 'user', 'frequency_penalty', 'presence_penalty']
# OpenRouter Example
- name: 'OpenRouter'
# For `apiKey` and `baseURL`, you can use environment variables that you define.
# recommended environment variables:
apiKey: '${OPENROUTER_KEY}'
baseURL: 'https://openrouter.ai/api/v1'
headers:
x-librechat-body-parentmessageid: '{{LIBRECHAT_BODY_PARENTMESSAGEID}}'
models:
default: ['meta-llama/llama-3-70b-instruct']
fetch: true
titleConvo: true
titleModel: 'meta-llama/llama-3-70b-instruct'
# Recommended: Drop the stop parameter from the request as Openrouter models use a variety of stop tokens.
dropParams: ['stop']
modelDisplayLabel: 'OpenRouter'
# Helicone Example
- name: 'Helicone'
# For `apiKey` and `baseURL`, you can use environment variables that you define.
# recommended environment variables:
apiKey: '${HELICONE_KEY}'
baseURL: 'https://ai-gateway.helicone.ai'
headers:
x-librechat-body-parentmessageid: '{{LIBRECHAT_BODY_PARENTMESSAGEID}}'
models:
default:
['gpt-4o-mini', 'claude-4.5-sonnet', 'llama-3.1-8b-instruct', 'gemini-2.5-flash-lite']
fetch: true
titleConvo: true
titleModel: 'gpt-4o-mini'
modelDisplayLabel: 'Helicone'
iconURL: https://marketing-assets-helicone.s3.us-west-2.amazonaws.com/helicone.png
# Portkey AI Example
- name: 'Portkey'
apiKey: 'dummy'
baseURL: 'https://api.portkey.ai/v1'
headers:
x-portkey-api-key: '${PORTKEY_API_KEY}'
x-portkey-virtual-key: '${PORTKEY_OPENAI_VIRTUAL_KEY}'
models:
default: ['gpt-4o-mini', 'gpt-4o', 'chatgpt-4o-latest']
fetch: true
titleConvo: true
titleModel: 'current_model'
summarize: false
summaryModel: 'current_model'
modelDisplayLabel: 'Portkey'
iconURL: https://images.crunchbase.com/image/upload/c_pad,f_auto,q_auto:eco,dpr_1/rjqy7ghvjoiu4cd1xjbf
# AWS Bedrock Example
# Note: Bedrock endpoint is configured via environment variables
# bedrock:
# # Models Configuration
# # Specify which models are available (equivalent to BEDROCK_AWS_MODELS env variable)
# models:
# - "anthropic.claude-3-7-sonnet-20250219-v1:0"
# - "anthropic.claude-3-5-sonnet-20241022-v2:0"
#
# # Inference Profiles Configuration
# # Maps model IDs to their inference profile ARNs
# # IMPORTANT: The model ID (key) MUST be a valid AWS Bedrock model ID that you've added to the models list above
# # The ARN (value) is the inference profile you wish to map to for that model
# # Both the model ID and ARN are sent to AWS - the model ID for validation/metadata, the ARN for routing
# inferenceProfiles:
# "us.anthropic.claude-sonnet-4-6": "${BEDROCK_INFERENCE_PROFILE_CLAUDE_SONNET}"
# "anthropic.claude-3-7-sonnet-20250219-v1:0": "arn:aws:bedrock:us-west-2:123456789012:application-inference-profile/abc123"
#
# # Guardrail Configuration
# guardrailConfig:
# guardrailIdentifier: "your-guardrail-id"
# guardrailVersion: "1"
#
# # Trace behavior for debugging (optional)
# # - "enabled": Include basic trace information about guardrail assessments
# # - "enabled_full": Include comprehensive trace details (recommended for debugging)
# # - "disabled": No trace information (default)
# # Trace output is logged to application log files for compliance auditing
# trace: "enabled"
# Example modelSpecs configuration showing grouping options
# The 'group' field organizes model specs in the UI selector:
# - If 'group' matches an endpoint name (e.g., "openAI", "groq"), the spec appears nested under that endpoint
# - If 'group' is a custom name (doesn't match any endpoint), it creates a separate collapsible section
# - If 'group' is omitted, the spec appears as a standalone item at the top level
#
# The 'groupIcon' field sets an icon for custom groups:
# - Only needs to be set on one spec per group (first one is used)
# - Can be a URL or a built-in endpoint key (e.g., "openAI", "anthropic", "groq")
# modelSpecs:
# list:
# # Example 1: Nested under an endpoint (grouped with openAI endpoint)
# - name: "gpt-4o"
# label: "GPT-4 Optimized"
# description: "Most capable GPT-4 model with multimodal support"
# # default: true # Hard admin default; takes precedence over prior user choices
# # softDefault: true # First-time default only; skipped after a user selects a model/spec/agent
# # showInMenu: false # Hide from the model selector while keeping explicit `spec` access
# group: "openAI" # String value matching the endpoint name
# preset:
# endpoint: "openAI"
# model: "gpt-4o"
# # Skills can be enabled per model spec. Use true for the user's active
# # accessible skill catalog, false to force skills off, or a name list as
# # a strict allowlist for catalog, manual, and always-apply resolution.
# # skills: ["brand-guidelines", "code-review"]
# # Subagents can be enabled per model spec. `allowSelf: true` lets the
# # ephemeral agent spawn a fresh isolated copy of itself for focused work.
# # subagents:
# # enabled: true
# # allowSelf: true
# # agent_ids: []
#
# # Example 2: Nested under a custom endpoint (grouped with groq endpoint)
# - name: "llama3-70b-8192"
# label: "Llama 3 70B"
# description: "Fastest inference available - great for quick responses"
# group: "groq" # String value matching your custom endpoint name from endpoints.custom
# preset:
# endpoint: "groq"
# model: "llama3-70b-8192"
#
# # Example 3: Custom group with icon (creates a separate collapsible section)
# - name: "coding-assistant"
# label: "Coding Assistant"
# description: "Specialized for coding tasks"
# group: "my-assistants" # Custom string - doesn't match any endpoint, so creates its own group
# groupIcon: "https://example.com/icons/assistants.png" # Icon URL for the group
# preset:
# endpoint: "openAI"
# model: "gpt-4o"
# instructions: "You are an expert coding assistant..."
# temperature: 0.3
#
# - name: "writing-assistant"
# label: "Writing Assistant"
# description: "Specialized for creative writing"
# group: "my-assistants" # Same custom group name - both specs appear in same section
# # No need to set groupIcon again - the first spec's icon is used
# preset:
# endpoint: "anthropic"
# model: "claude-sonnet-4"
# instructions: "You are a creative writing expert..."
#
# # Example 4: Custom group using built-in icon key
# - name: "fast-models"
# label: "Fast Response Model"
# group: "Fast Models"
# groupIcon: "groq" # Uses the built-in Groq icon
# preset:
# endpoint: "groq"
# model: "llama3-8b-8192"
#
# # Example 5: Standalone (no group - appears at top level)
# - name: "general-assistant"
# label: "General Assistant"
# description: "General purpose assistant"
# # No 'group' field - appears as standalone item at top level (not nested)
# # hideBadgeRow: true # Optional: hides the tool badge row for this spec
# preset:
# endpoint: "openAI"
# model: "gpt-4o-mini"
# Automatic conversation summarization (optional)
# summarization:
# enabled: true
# provider: "openAI"
# model: "gpt-4o-mini"
# retainRecent:
# turns: 2 # Keep the newest complete user/assistant turns outside the summary
# tokens: 2000 # Also preserve up to this many recent tokens
# fileConfig:
# endpoints:
# assistants:
# fileLimit: 5
# fileSizeLimit: 10 # Maximum size for an individual file in MB
# totalSizeLimit: 50 # Maximum total size for all files in a single request in MB
# supportedMimeTypes:
# - "image/.*"
# - "application/pdf"
# openAI:
# disabled: true # Disables file uploading to the OpenAI endpoint
# default:
# totalSizeLimit: 20
# YourCustomEndpointName:
# fileLimit: 2
# fileSizeLimit: 5
# serverFileSizeLimit: 100 # Global server file size limit in MB
# avatarSizeLimit: 2 # Limit for user avatar image size in MB
# imageGeneration: # Image Gen settings, either percentage or px
# percentage: 100
# px: 1024
# # Client-side image resizing to prevent upload errors
# # Users can toggle this in Settings > Chat. Setting `enabled` here overrides
# # that choice for everyone and locks the toggle; omit it to leave users in control.
# clientImageResize:
# enabled: false # Enable/disable client-side image resizing (default: false)
# maxWidth: 1900 # Maximum width for resized images (default: 1900)
# maxHeight: 1900 # Maximum height for resized images (default: 1900)
# quality: 0.92 # JPEG quality for compression (0.0-1.0, default: 0.92)
# # See the Custom Configuration Guide for more information on Assistants Config:
# # https://www.librechat.ai/docs/configuration/librechat_yaml/object_structure/assistants_endpoint
# Web Search Configuration (optional)
# webSearch:
# # Jina Reranking Configuration
# jinaApiKey: '${JINA_API_KEY}' # Your Jina API key
# jinaApiUrl: '${JINA_API_URL}' # Custom Jina API URL (optional, defaults to https://api.jina.ai/v1/rerank)
# # Other rerankers
# cohereApiKey: '${COHERE_API_KEY}'
# # Search providers
# serperApiKey: '${SERPER_API_KEY}'
# searxngInstanceUrl: '${SEARXNG_INSTANCE_URL}'
# searxngApiKey: '${SEARXNG_API_KEY}'
# # Tavily (search provider and/or scraper)
# tavilyApiKey: '${TAVILY_API_KEY}'
# # Content scrapers
# firecrawlApiKey: '${FIRECRAWL_API_KEY}'
# firecrawlApiUrl: '${FIRECRAWL_API_URL}'
# # Outbound search and scrape requests are validated at connect time against
# # their resolved IP and blocked from reaching private, loopback, link-local,
# # or cloud-metadata space. `allowedAddresses` is an SSRF exemption list, NOT a
# # strict whitelist: hostname/IP + port pairs listed here bypass that block for
# # one deliberately-private endpoint (for example a self-hosted SearXNG
# # instance); public destinations continue to work normally.
# #
# # Entries must include a port: `host:port`, `private.ip:port`, or `[ipv6]:port`.
# # Do not use URLs, paths, CIDR ranges, bare hosts/IPs, or public IP literals.
# # A hostname entry trusts whatever IP that name resolves to on the listed port,
# # so only list hosts you fully control and whose DNS cannot be repointed by an
# # attacker. Listing an attacker-controllable or DNS-rebindable host re-opens the
# # private-address path this guard closes. Prefer literal IPs where you can.
# #
# # Self-hosted endpoints need an entry. A private destination such as
# # `http://searxng:8080`, `http://firecrawl:3002`, or `http://127.0.0.1:8080` is
# # blocked once this guard is active, so list it here or those requests will fail.
# #
# # A proxy from `HTTP_PROXY`, `HTTPS_PROXY`, or `ALL_PROXY` (either case) is
# # exempted automatically and needs no entry. That exemption is applied to the
# # whole tool rather than per destination, so a host `NO_PROXY` sends direct also
# # carries it. Note that when a proxy carries the request the proxy resolves the
# # destination, so destination egress policy is the proxy's to enforce, and for
# # https targets the proxy's own tunnel replaces this guard entirely.
# # allowedAddresses:
# # - 'searxng:8080'
# # - '127.0.0.1:8080'
#
# Tavily as both search and scraper provider example:
# webSearch:
# searchProvider: tavily
# scraperProvider: tavily
# tavilyApiKey: '${TAVILY_API_KEY}'
# # Optional: custom API URLs (defaults to https://api.tavily.com/search and https://api.tavily.com/extract)
# # tavilySearchUrl: '${TAVILY_SEARCH_URL}'
# # tavilyExtractUrl: '${TAVILY_EXTRACT_URL}'
# tavilySearchOptions:
# searchDepth: basic # 'basic', 'advanced', 'fast', or 'ultra-fast' (default: basic)
# maxResults: 5 # 1-20 results per search (default: 5)
# topic: general # 'general', 'news', or 'finance'
# # includeAnswer: basic # Include answer summary: true, 'basic', or 'advanced'
# # includeRawContent: markdown # Include raw content: true, 'markdown', or 'text'
# # includeImages: true # Include images in results
# # includeFavicon: true # Include favicon URL for each result
# # chunksPerSource: 3 # Chunks per source, only with 'advanced' depth (1-3)
# # safeSearch: false # Override Tavily safe_search filtering (true is enterprise-only)
# # includeDomains: # Restrict search to specific domains (max 300)
# # - 'example.com'
# # - 'docs.example.com'
# # excludeDomains: # Exclude specific domains from results (max 150)
# # - 'spam.com'
# # timeRange: week # 'day', 'week', 'month', or 'year'
# # timeout: 15000 # HTTP request timeout in milliseconds (max 120000)
# tavilyScraperOptions:
# extractDepth: basic # 'basic' (1 credit/5 URLs) or 'advanced' (2 credits/5 URLs, more thorough)
# # includeImages: false # Include images extracted from URLs
# # includeFavicon: false # Include favicon URL for each result
# # format: markdown # 'markdown' (default) or 'text' (plain text, may increase latency)
# # timeout: 15000 # HTTP request timeout in milliseconds (max 120000); Tavily Extract receives seconds clamped to 1-60
# Memory configuration for user memories
# memory:
# # (optional) Disable memory functionality
# disabled: false
# # (optional) Restrict memory keys to specific values to limit memory storage and improve consistency
# validKeys: ["preferences", "work_info", "personal_info", "skills", "interests", "context"]
# # (optional) Maximum token limit for stored memory values
# tokenLimit: 10000
# # (optional) Maximum tokens from recent chat sent to the memory agent before truncation
# maxInputTokens: 12000
# # (optional) Enable personalization features (defaults to true if memory is configured)
# # When false, users will not see the Personalization tab in settings
# personalize: true
# # (optional) Memory agent configuration for automatic memory updates from chat messages.
# # If omitted, users can still create, edit, delete, and reference memories manually.
# agent:
# # Explicitly enables automatic memory updates from chat messages.
# enabled: true
# # Option 1: Use existing agent by ID
# id: "your-memory-agent-id"
# # Option 2: Define agent inline
# # provider: "openai"
# # model: "gpt-4o-mini"
# # instructions: "You are a memory management assistant. Store and manage user information accurately."
# # model_parameters:
# # temperature: 0.1
# Reject chat messages whose text matches credential-shaped patterns
# before they reach moderation, the model, or persistence. Filter
# types live under `messageFilter.<type>`; today only `pii` ships, but
# the namespace is structured so future filter types can plug in.
# Omit the whole section to disable.
# messageFilter:
# pii:
# # (optional) Pick a subset of the starter catalog by id; omit to
# # enable all starters (sk_prefix, bearer_header, api_key_header).
# starterPatterns: [sk_prefix, bearer_header, api_key_header]
# # (optional) Operator-defined patterns. Each entry needs id, label,
# # and a regex in RE2 syntax and semantics (RE2 is a linear-time engine
# # with no catastrophic backtracking; a few escapes such as \p, \A, and
# # \s differ from JavaScript). Backreferences and lookaround are not
# # supported; the regex is validated against the RE2 engine at config
# # load time and a pattern it cannot compile is rejected.
# customPatterns:
# - id: anthropic_api_key
# label: Anthropic API key
# regex: "sk-ant-[A-Za-z0-9_-]{20,}"