diff --git a/.claude/skills/codebase-design/DEEPENING.md b/.claude/skills/codebase-design/DEEPENING.md
new file mode 100644
index 00000000000..3938457b88d
--- /dev/null
+++ b/.claude/skills/codebase-design/DEEPENING.md
@@ -0,0 +1,37 @@
+# Deepening
+
+How to deepen a cluster of shallow modules safely, given its dependencies. Assumes the vocabulary in [SKILL.md](SKILL.md) — **module**, **interface**, **seam**, **adapter**.
+
+## Dependency categories
+
+When assessing a candidate for deepening, classify its dependencies. The category determines how the deepened module is tested across its seam.
+
+### 1. In-process
+
+Pure computation, in-memory state, no I/O. Always deepenable — merge the modules and test through the new interface directly. No adapter needed.
+
+### 2. Local-substitutable
+
+Dependencies that have local test stand-ins (PGLite for Postgres, in-memory filesystem). Deepenable if the stand-in exists. The deepened module is tested with the stand-in running in the test suite. The seam is internal; no port at the module's external interface.
+
+### 3. Remote but owned (Ports & Adapters)
+
+Your own services across a network boundary (microservices, internal APIs). Define a **port** (interface) at the seam. The deep module owns the logic; the transport is injected as an **adapter**. Tests use an in-memory adapter. Production uses an HTTP/gRPC/queue adapter.
+
+Recommendation shape: *"Define a port at the seam, implement an HTTP adapter for production and an in-memory adapter for testing, so the logic sits in one deep module even though it's deployed across a network."*
+
+### 4. True external (Mock)
+
+Third-party services (Stripe, Twilio, etc.) you don't control. The deepened module takes the external dependency as an injected port; tests provide a mock adapter.
+
+## Seam discipline
+
+- **One adapter means a hypothetical seam. Two adapters means a real one.** Don't introduce a port unless at least two adapters are justified (typically production + test). A single-adapter seam is just indirection.
+- **Internal seams vs external seams.** A deep module can have internal seams (private to its implementation, used by its own tests) as well as the external seam at its interface. Don't expose internal seams through the interface just because tests use them.
+
+## Testing strategy: replace, don't layer
+
+- Old unit tests on shallow modules become waste once tests at the deepened module's interface exist — delete them.
+- Write new tests at the deepened module's interface. The **interface is the test surface**.
+- Tests assert on observable outcomes through the interface, not internal state.
+- Tests should survive internal refactors — they describe behaviour, not implementation. If a test has to change when the implementation changes, it's testing past the interface.
diff --git a/.claude/skills/codebase-design/DESIGN-IT-TWICE.md b/.claude/skills/codebase-design/DESIGN-IT-TWICE.md
new file mode 100644
index 00000000000..8419ad6fa96
--- /dev/null
+++ b/.claude/skills/codebase-design/DESIGN-IT-TWICE.md
@@ -0,0 +1,44 @@
+# Design It Twice
+
+When the user wants to explore alternative interfaces for a chosen deepening candidate, use this parallel sub-agent pattern. Based on "Design It Twice" (Ousterhout) — your first idea is unlikely to be the best.
+
+Uses the vocabulary in [SKILL.md](SKILL.md) — **module**, **interface**, **seam**, **adapter**, **leverage**.
+
+## Process
+
+### 1. Frame the problem space
+
+Before spawning sub-agents, write a user-facing explanation of the problem space for the chosen candidate:
+
+- The constraints any new interface would need to satisfy
+- The dependencies it would rely on, and which category they fall into (see [DEEPENING.md](DEEPENING.md))
+- A rough illustrative code sketch to ground the constraints — not a proposal, just a way to make the constraints concrete
+
+Show this to the user, then immediately proceed to Step 2. The user reads and thinks while the sub-agents work in parallel.
+
+### 2. Spawn sub-agents
+
+Spawn 3+ sub-agents in parallel. Each must produce a **radically different** interface for the deepened module.
+
+Prompt each sub-agent with a separate technical brief (file paths, coupling details, dependency category from [DEEPENING.md](DEEPENING.md), what sits behind the seam). The brief is independent of the user-facing problem-space explanation in Step 1. Give each agent a different design constraint:
+
+- Agent 1: "Minimize the interface — aim for 1–3 entry points max. Maximise leverage per entry point."
+- Agent 2: "Maximise flexibility — support many use cases and extension."
+- Agent 3: "Optimise for the most common caller — make the default case trivial."
+- Agent 4 (if applicable): "Design around ports & adapters for cross-seam dependencies."
+
+Include both [SKILL.md](SKILL.md) vocabulary and CONTEXT.md vocabulary in the brief so each sub-agent names things consistently with the architecture language and the project's domain language.
+
+Each sub-agent outputs:
+
+1. Interface (types, methods, params — plus invariants, ordering, error modes)
+2. Usage example showing how callers use it
+3. What the implementation hides behind the seam
+4. Dependency strategy and adapters (see [DEEPENING.md](DEEPENING.md))
+5. Trade-offs — where leverage is high, where it's thin
+
+### 3. Present and compare
+
+Present designs sequentially so the user can absorb each one, then compare them in prose. Contrast by **depth** (leverage at the interface), **locality** (where change concentrates), and **seam placement**.
+
+After comparing, give your own recommendation: which design you think is strongest and why. If elements from different designs would combine well, propose a hybrid. Be opinionated — the user wants a strong read, not a menu.
diff --git a/.claude/skills/codebase-design/LICENSE b/.claude/skills/codebase-design/LICENSE
new file mode 100644
index 00000000000..f1dd2c09108
--- /dev/null
+++ b/.claude/skills/codebase-design/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Matt Pocock
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/.claude/skills/codebase-design/SKILL.md b/.claude/skills/codebase-design/SKILL.md
new file mode 100644
index 00000000000..16620c24528
--- /dev/null
+++ b/.claude/skills/codebase-design/SKILL.md
@@ -0,0 +1,114 @@
+---
+name: codebase-design
+description: Shared vocabulary for designing deep modules. Use when the user wants to design or improve a module's interface, find deepening opportunities, decide where a seam goes, make code more testable or AI-navigable, or when another skill needs the deep-module vocabulary.
+---
+
+# Codebase Design
+
+Design **deep modules**: a lot of behaviour behind a small interface, placed at a clean seam, testable through that interface. Use this language and these principles wherever code is being designed or restructured. The aim is leverage for callers, locality for maintainers, and testability for everyone.
+
+## Glossary
+
+Use these terms exactly — don't substitute "component," "service," "API," or "boundary." Consistent language is the whole point.
+
+**Module** — anything with an interface and an implementation. Deliberately scale-agnostic: a function, class, package, or tier-spanning slice. _Avoid_: unit, component, service.
+
+**Interface** — everything a caller must know to use the module correctly: the type signature, but also invariants, ordering constraints, error modes, required configuration, and performance characteristics. _Avoid_: API, signature (too narrow — they refer only to the type-level surface).
+
+**Implementation** — what's inside a module, its body of code. Distinct from **Adapter**: a thing can be a small adapter with a large implementation (a Postgres repo) or a large adapter with a small implementation (an in-memory fake). Reach for "adapter" when the seam is the topic; "implementation" otherwise.
+
+**Depth** — leverage at the interface: the amount of behaviour a caller (or test) can exercise per unit of interface they have to learn. A module is **deep** when a large amount of behaviour sits behind a small interface, **shallow** when the interface is nearly as complex as the implementation.
+
+**Seam** _(Michael Feathers)_ — a place where you can alter behaviour without editing in that place; the *location* at which a module's interface lives. Where to put the seam is its own design decision, distinct from what goes behind it. _Avoid_: boundary (overloaded with DDD's bounded context).
+
+**Adapter** — a concrete thing that satisfies an interface at a seam. Describes *role* (what slot it fills), not substance (what's inside).
+
+**Leverage** — what callers get from depth: more capability per unit of interface they learn. One implementation pays back across N call sites and M tests.
+
+**Locality** — what maintainers get from depth: change, bugs, knowledge, and verification concentrate in one place rather than spreading across callers. Fix once, fixed everywhere.
+
+## Deep vs shallow
+
+**Deep module** = small interface + lots of implementation:
+
+```
+┌─────────────────────┐
+│ Small Interface │ ← Few methods, simple params
+├─────────────────────┤
+│ │
+│ Deep Implementation│ ← Complex logic hidden
+│ │
+└─────────────────────┘
+```
+
+**Shallow module** = large interface + little implementation (avoid):
+
+```
+┌─────────────────────────────────┐
+│ Large Interface │ ← Many methods, complex params
+├─────────────────────────────────┤
+│ Thin Implementation │ ← Just passes through
+└─────────────────────────────────┘
+```
+
+When designing an interface, ask:
+
+- Can I reduce the number of methods?
+- Can I simplify the parameters?
+- Can I hide more complexity inside?
+
+## Principles
+
+- **Depth is a property of the interface, not the implementation.** A deep module can be internally composed of small, mockable, swappable parts — they just aren't part of the interface. A module can have **internal seams** (private to its implementation, used by its own tests) as well as the **external seam** at its interface.
+- **The deletion test.** Imagine deleting the module. If complexity vanishes, it was a pass-through. If complexity reappears across N callers, it was earning its keep.
+- **The interface is the test surface.** Callers and tests cross the same seam. If you want to test *past* the interface, the module is probably the wrong shape.
+- **One adapter means a hypothetical seam. Two adapters means a real one.** Don't introduce a seam unless something actually varies across it.
+
+## Designing for testability
+
+Good interfaces make testing natural:
+
+1. **Accept dependencies, don't create them.**
+
+ ```typescript
+ // Testable
+ function processOrder(order, paymentGateway) {}
+
+ // Hard to test
+ function processOrder(order) {
+ const gateway = new StripeGateway();
+ }
+ ```
+
+2. **Return results, don't produce side effects.**
+
+ ```typescript
+ // Testable
+ function calculateDiscount(cart): Discount {}
+
+ // Hard to test
+ function applyDiscount(cart): void {
+ cart.total -= discount;
+ }
+ ```
+
+3. **Small surface area.** Fewer methods = fewer tests needed. Fewer params = simpler test setup.
+
+## Relationships
+
+- A **Module** has exactly one **Interface** (the surface it presents to callers and tests).
+- **Depth** is a property of a **Module**, measured against its **Interface**.
+- A **Seam** is where a **Module**'s **Interface** lives.
+- An **Adapter** sits at a **Seam** and satisfies the **Interface**.
+- **Depth** produces **Leverage** for callers and **Locality** for maintainers.
+
+## Rejected framings
+
+- **Depth as ratio of implementation-lines to interface-lines** (Ousterhout): rewards padding the implementation. We use depth-as-leverage instead.
+- **"Interface" as the TypeScript `interface` keyword or a class's public methods**: too narrow — interface here includes every fact a caller must know.
+- **"Boundary"**: overloaded with DDD's bounded context. Say **seam** or **interface**.
+
+## Going deeper
+
+- **Deepening a cluster given its dependencies** — see [DEEPENING.md](DEEPENING.md): dependency categories, seam discipline, and replace-don't-layer testing.
+- **Exploring alternative interfaces** — see [DESIGN-IT-TWICE.md](DESIGN-IT-TWICE.md): spin up parallel sub-agents to design the interface several radically different ways, then compare on depth, locality, and seam placement.
diff --git a/.claude/skills/codebase-design/agents/openai.yaml b/.claude/skills/codebase-design/agents/openai.yaml
new file mode 100644
index 00000000000..3180715edb3
--- /dev/null
+++ b/.claude/skills/codebase-design/agents/openai.yaml
@@ -0,0 +1,3 @@
+interface:
+ display_name: "Codebase Design"
+ short_description: "Vocabulary for deep-module design"
diff --git a/.claude/skills/improve-codebase-architecture/HTML-REPORT.md b/.claude/skills/improve-codebase-architecture/HTML-REPORT.md
new file mode 100644
index 00000000000..17f6d2c7b83
--- /dev/null
+++ b/.claude/skills/improve-codebase-architecture/HTML-REPORT.md
@@ -0,0 +1,123 @@
+# HTML Report Format
+
+The architectural review is rendered as a single self-contained HTML file in the OS temp directory. Tailwind and Mermaid both come from CDNs. Mermaid handles graph-shaped diagrams reliably; hand-built divs and inline SVG handle the more editorial visuals (mass diagrams, cross-sections). Mix the two — don't lean on Mermaid for everything, it'll start to look generic.
+
+## Scaffold
+
+```html
+
+
+
+
+ Architecture review — {{repo name}}
+
+
+
+
+
+
+ ...
+ ...
+ ...
+
+
+
+```
+
+## Header
+
+Repo name, date, and a compact legend: solid box = module, dashed line = seam, red arrow = leakage, thick dark box = deep module. No introduction paragraph — straight into the candidates.
+
+## Candidate card
+
+The diagrams carry the weight. Prose is sparse, plain, and uses the glossary terms (from the `/codebase-design` skill) without ceremony.
+
+Each candidate is one ``:
+
+- **Title** — short, names the deepening (e.g. "Collapse the Order intake pipeline").
+- **Badge row** — recommendation strength (`Strong` = emerald, `Worth exploring` = amber, `Speculative` = slate), plus a tag for the dependency category (`in-process`, `local-substitutable`, `ports & adapters`, `mock`).
+- **Files** — monospaced list, `font-mono text-sm`.
+- **Before / After diagram** — the centrepiece. Two columns, side by side. See patterns below.
+- **Problem** — one sentence. What hurts.
+- **Solution** — one sentence. What changes.
+- **Wins** — bullets, ≤6 words each. e.g. "Tests hit one interface", "Pricing logic stops leaking", "Delete 4 shallow wrappers".
+- **ADR callout** (if applicable) — one line in an amber-tinted box.
+
+No paragraphs of explanation. If the diagram needs a paragraph to be understood, redraw the diagram.
+
+## Diagram patterns
+
+Pick the pattern that fits the candidate. Mix them. Don't make every diagram look the same — variety is part of the point.
+
+### Mermaid graph (the workhorse for dependencies / call flow)
+
+Use a Mermaid `flowchart` or `graph` when the point is "X calls Y calls Z, and look at the mess." Wrap it in a Tailwind-styled card so it doesn't feel parachuted in. Style with classDef to colour leakage edges red and the deep module dark. Sequence diagrams work well for "before: 6 round-trips; after: 1."
+
+```html
+
+
+ flowchart LR
+ A[OrderHandler] --> B[OrderValidator]
+ B --> C[OrderRepo]
+ C -.leak.-> D[PricingClient]
+ classDef leak stroke:#dc2626,stroke-width:2px;
+ class C,D leak
+
`s with borders and labels. Arrows as inline SVG `` or `` elements positioned absolutely over a relative container. Reach for this when you want the "after" diagram to feel like one thick-bordered deep module with greyed-out internals — Mermaid won't render that with the right weight.
+
+### Cross-section (good for layered shallowness)
+
+Stack horizontal bands (`h-12 border-l-4`) to show layers a call passes through. Before: 6 thin layers each doing nothing. After: 1 thick band labelled with the consolidated responsibility.
+
+### Mass diagram (good for "interface as wide as implementation")
+
+Two rectangles per module — one for interface surface area, one for implementation. Before: interface rectangle is nearly as tall as the implementation rectangle (shallow). After: interface rectangle is short, implementation rectangle is tall (deep).
+
+### Call-graph collapse
+
+Before: a tree of function calls rendered as nested boxes. After: the same tree collapsed into one box, with the now-internal calls shown faded inside it.
+
+## Style guidance
+
+- Lean editorial, not corporate-dashboard. Generous whitespace. Serif optional for headings (`font-serif` works well with stone/slate).
+- Colour sparingly: one accent (emerald or indigo) plus red for leakage and amber for warnings.
+- Keep diagrams ~320px tall so before/after sits comfortably side by side without scrolling.
+- Use `text-xs uppercase tracking-wider` for module labels inside diagrams — they should read as schematic, not as UI.
+- The only scripts are the Tailwind CDN and the Mermaid ESM import. The report is otherwise static — no app code, no interactivity beyond Mermaid's own rendering.
+
+## Top recommendation section
+
+One larger card. Candidate name, one sentence on why, anchor link to its card. That's it.
+
+## Tone
+
+Plain English, concise — but the architectural nouns and verbs come straight from the `/codebase-design` skill. Concision is not an excuse to drift.
+
+**Use exactly:** module, interface, implementation, depth, deep, shallow, seam, adapter, leverage, locality.
+
+**Never substitute:** component, service, unit (for module) · API, signature (for interface) · boundary (for seam) · layer, wrapper (for module, when you mean module).
+
+**Phrasings that fit the style:**
+
+- "Order intake module is shallow — interface nearly matches the implementation."
+- "Pricing leaks across the seam."
+- "Deepen: one interface, one place to test."
+- "Two adapters justify the seam: HTTP in prod, in-memory in tests."
+
+**Wins bullets** name the gain in glossary terms: *"locality: bugs concentrate in one module"*, *"leverage: one interface, N call sites"*, *"interface shrinks; implementation absorbs the wrappers"*. Don't write *"easier to maintain"* or *"cleaner code"* — those terms aren't in the glossary and don't earn their place.
+
+No hedging, no throat-clearing, no "it's worth noting that…". If a sentence could be a bullet, make it a bullet. If a bullet could be cut, cut it. If a term isn't in the `/codebase-design` glossary, reach for one that is before inventing a new one.
diff --git a/.claude/skills/improve-codebase-architecture/LICENSE b/.claude/skills/improve-codebase-architecture/LICENSE
new file mode 100644
index 00000000000..f1dd2c09108
--- /dev/null
+++ b/.claude/skills/improve-codebase-architecture/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Matt Pocock
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/.claude/skills/improve-codebase-architecture/SKILL.md b/.claude/skills/improve-codebase-architecture/SKILL.md
new file mode 100644
index 00000000000..529761a3a01
--- /dev/null
+++ b/.claude/skills/improve-codebase-architecture/SKILL.md
@@ -0,0 +1,71 @@
+---
+name: improve-codebase-architecture
+description: Scan a codebase for deepening opportunities, present them as a visual HTML report, then grill through whichever one you pick.
+disable-model-invocation: true
+---
+
+# Improve Codebase Architecture
+
+Surface architectural friction and propose **deepening opportunities** — refactors that turn shallow modules into deep ones. The aim is testability and AI-navigability.
+
+This command is _informed_ by the project's domain model and built on a shared design vocabulary:
+
+- Run the `/codebase-design` skill for the architecture vocabulary (**module**, **interface**, **depth**, **seam**, **adapter**, **leverage**, **locality**) and its principles (the deletion test, "the interface is the test surface", "one adapter = hypothetical seam, two = real"). Use these terms exactly in every suggestion — don't drift into "component," "service," "API," or "boundary."
+- The domain language in `CONTEXT.md` gives names to good seams; ADRs in `docs/adr/` record decisions this command should not re-litigate.
+
+## Process
+
+### 1. Explore
+
+**Scope before you scan — YAGNI.** Deepening a module pays off by making future changes to it easier, so put extra weight on the parts of the codebase that have recently changed. Decide *where* to look before you look:
+
+- If the user named a direction — a module, a subsystem, a pain point — take it, and skip the inference below.
+- Otherwise, walk back a good stretch of the commit history (`git log --oneline`) to find the codebase's hot spots — the files and areas that keep coming up — and let those paths pull your attention first. If the changes are scattered with no clear hot spot, widen the net.
+
+Read the project's domain glossary (`CONTEXT.md`) and any ADRs in the area you're touching first.
+
+Then spawn a sub-agent to walk the codebase. Don't follow rigid heuristics — explore organically and note where you experience friction:
+
+- Where does understanding one concept require bouncing between many small modules?
+- Where are modules **shallow** — interface nearly as complex as the implementation?
+- Where have pure functions been extracted just for testability, but the real bugs hide in how they're called (no **locality**)?
+- Where do tightly-coupled modules leak across their seams?
+- Which parts of the codebase are untested, or hard to test through their current interface?
+
+Apply the **deletion test** to anything you suspect is shallow: would deleting it concentrate complexity, or just move it? A "yes, concentrates" is the signal you want.
+
+### 2. Present candidates as an HTML report
+
+Write a self-contained HTML file to the OS temp directory so nothing lands in the repo. Resolve the temp dir from `$TMPDIR`, falling back to `/tmp` (or `%TEMP%` on Windows), and write to `/architecture-review-.html` so each run gets a fresh file. Open it for the user — `xdg-open ` on Linux, `open ` on macOS, `start ` on Windows — and tell them the absolute path.
+
+The report uses **Tailwind via CDN** for layout and styling, and **Mermaid via CDN** for diagrams where a graph/flow/sequence reliably communicates the structure. Mix Mermaid with hand-crafted CSS/SVG visuals — use Mermaid when relationships are graph-shaped (call graphs, dependencies, sequences), and hand-built divs/SVG when you want something more editorial (mass diagrams, cross-sections, collapse animations). Each candidate gets a **before/after visualisation**. Be visual.
+
+For each candidate, render a card with:
+
+- **Files** — which files/modules are involved
+- **Problem** — why the current architecture is causing friction
+- **Solution** — plain English description of what would change
+- **Benefits** — explained in terms of locality and leverage, and how tests would improve
+- **Before / After diagram** — side-by-side, custom-drawn, illustrating the shallowness and the deepening
+- **Recommendation strength** — one of `Strong`, `Worth exploring`, `Speculative`, rendered as a badge
+
+End the report with a **Top recommendation** section: which candidate you'd tackle first and why.
+
+**Use CONTEXT.md vocabulary for the domain, and the `/codebase-design` vocabulary for the architecture.** If `CONTEXT.md` defines "Order," talk about "the Order intake module" — not "the FooBarHandler," and not "the Order service."
+
+**ADR conflicts**: if a candidate contradicts an existing ADR, only surface it when the friction is real enough to warrant revisiting the ADR. Mark it clearly in the card (e.g. a warning callout: _"contradicts ADR-0007 — but worth reopening because…"_). Don't list every theoretical refactor an ADR forbids.
+
+See [HTML-REPORT.md](HTML-REPORT.md) for the full HTML scaffold, diagram patterns, and styling guidance.
+
+Do NOT propose interfaces yet. After the file is written, ask the user: "Which of these would you like to explore?"
+
+### 3. Grilling loop
+
+Once the user picks a candidate, run the `/grilling` skill to walk the decision tree with them — constraints, dependencies, the shape of the deepened module, what sits behind the seam, what tests survive.
+
+Side effects happen inline as decisions crystallize — run the `/domain-modeling` skill to keep the domain model current as you go:
+
+- **Naming a deepened module after a concept not in `CONTEXT.md`?** Add the term to `CONTEXT.md`. Create the file lazily if it doesn't exist.
+- **Sharpening a fuzzy term during the conversation?** Update `CONTEXT.md` right there.
+- **User rejects the candidate with a load-bearing reason?** Offer an ADR, framed as: _"Want me to record this as an ADR so future architecture reviews don't re-suggest it?"_ Only offer when the reason would actually be needed by a future explorer to avoid re-suggesting the same thing — skip ephemeral reasons ("not worth it right now") and self-evident ones.
+- **Want to explore alternative interfaces for the deepened module?** Run the `/codebase-design` skill and use its design-it-twice parallel sub-agent pattern.
diff --git a/.claude/skills/improve-codebase-architecture/agents/openai.yaml b/.claude/skills/improve-codebase-architecture/agents/openai.yaml
new file mode 100644
index 00000000000..706fdca096d
--- /dev/null
+++ b/.claude/skills/improve-codebase-architecture/agents/openai.yaml
@@ -0,0 +1,5 @@
+interface:
+ display_name: "Improve Codebase Architecture"
+ short_description: "Find and grill architecture improvements"
+policy:
+ allow_implicit_invocation: false
diff --git a/.codex b/.codex
new file mode 100644
index 00000000000..e69de29bb2d
diff --git a/.do/gitnexus/Caddyfile b/.do/gitnexus/Caddyfile
deleted file mode 100644
index 3c5dac2c6f6..00000000000
--- a/.do/gitnexus/Caddyfile
+++ /dev/null
@@ -1,25 +0,0 @@
-# Caddy reverse proxy with bearer token auth and automatic HTTPS.
-# The domain is supplied via environment variable GITNEXUS_DOMAIN,
-# and the auth token via API_TOKEN. Both are set in docker-compose.yml.
-
-{$GITNEXUS_DOMAIN} {
- # Health check — unauthenticated so monitoring can probe it
- @health path /health
- handle @health {
- reverse_proxy gitnexus:4747 {
- rewrite /api/info
- }
- }
-
- # All other routes require bearer token
- @authed {
- header Authorization "Bearer {$API_TOKEN}"
- }
-
- handle @authed {
- reverse_proxy gitnexus:4747
- }
-
- # Reject unauthenticated requests
- respond "Unauthorized" 401
-}
diff --git a/.do/gitnexus/Dockerfile b/.do/gitnexus/Dockerfile
deleted file mode 100644
index 8b7e538726e..00000000000
--- a/.do/gitnexus/Dockerfile
+++ /dev/null
@@ -1,46 +0,0 @@
-# Long-lived GitNexus image for DigitalOcean droplet deployment.
-#
-# This image does NOT bake in the index data. Indexes are mounted from
-# the host at /indexes//.gitnexus/ and registered at container
-# startup. A fresh index only requires rsync + container restart — no
-# image rebuild on every push.
-
-FROM node:24.16.0-slim
-
-ARG GITNEXUS_VERSION=1.6.7
-# Pin the native DB to match the index workflow; gitnexus's ^0.17.0 range
-# would otherwise let the served image drift from the CI-produced index.
-ARG LADYBUG_VERSION=0.17.1
-
-# 1. Build native addons with Bookworm toolchain, then remove build tools.
-# curl stays for the docker healthcheck; Caddy lives in its own container.
-# LadybugDB is pinned nested under gitnexus so step 3's require() resolves it.
-RUN apt-get update \
- && apt-get install -y --no-install-recommends python3 make g++ curl \
- && npm install -g gitnexus@${GITNEXUS_VERSION} \
- && npm install --no-save --prefix /usr/local/lib/node_modules/gitnexus "@ladybugdb/core@${LADYBUG_VERSION}" \
- && apt-get purge -y --auto-remove python3 make g++ \
- && rm -rf /var/lib/apt/lists/* /root/.npm
-
-# 2. Upgrade libstdc++ from Trixie — @ladybugdb/core prebuilt binary needs
-# GLIBCXX_3.4.32 which Bookworm (3.4.31) doesn't ship.
-RUN echo "deb http://deb.debian.org/debian trixie main" > /etc/apt/sources.list.d/trixie.list \
- && apt-get update \
- && apt-get install -y -t trixie libstdc++6 \
- && rm /etc/apt/sources.list.d/trixie.list \
- && rm -rf /var/lib/apt/lists/*
-
-# 3. Pre-install LadybugDB FTS + vector extensions so ~/.kuzu/extension/
-# is baked into the image. gitnexus serve loads extensions with a
-# load-only policy and never installs them at runtime, so the cache
-# must already exist. (GitNexus loads the vector extension itself
-# via loadVectorExtension — no adapter patch needed.)
-COPY install-extensions.js /tmp/install-extensions.js
-RUN node /tmp/install-extensions.js && rm -rf /tmp/install-extensions.js /tmp/lbug-ext-install
-
-COPY entrypoint.sh /entrypoint.sh
-RUN chmod +x /entrypoint.sh
-
-EXPOSE 4747
-
-ENTRYPOINT ["/entrypoint.sh"]
diff --git a/.do/gitnexus/docker-compose.yml b/.do/gitnexus/docker-compose.yml
deleted file mode 100644
index 7761a89a301..00000000000
--- a/.do/gitnexus/docker-compose.yml
+++ /dev/null
@@ -1,87 +0,0 @@
-# GitNexus stack for the DigitalOcean droplet.
-#
-# Two services: the gitnexus server (bound to an internal network only)
-# and a Caddy reverse proxy that handles TLS + auth.
-#
-# Index data lives on the host at /opt/gitnexus/indexes/ and is
-# bind-mounted read-write into the gitnexus container. The deploy
-# workflow rsyncs fresh indexes into that directory and restarts
-# only the gitnexus container — Caddy keeps running undisturbed.
-#
-# Break-glass: if gitnexus is stuck unhealthy and you need to restart
-# just Caddy (e.g. to push an emergency Caddyfile fix), the
-# `depends_on: condition: service_healthy` would block:
-# docker compose up -d caddy
-# Use --no-deps to bypass the dependency check:
-# docker compose up -d --no-deps caddy
-
-name: gitnexus
-
-# Shared logging defaults applied to both services so the droplet's
-# disk doesn't fill up with unbounded json-file logs.
-x-logging: &default-logging
- driver: json-file
- options:
- max-size: '50m'
- max-file: '3'
-
-services:
- gitnexus:
- # Override via GITNEXUS_IMAGE in /opt/gitnexus/.env to use a fork or
- # a pinned version tag like :v1.5.3 for reproducible rollbacks.
- image: ${GITNEXUS_IMAGE:-ghcr.io/danny-avila/librechat-gitnexus:latest}
- container_name: gitnexus
- restart: unless-stopped
- networks:
- - gitnexus-net
- volumes:
- - /opt/gitnexus/indexes:/indexes
- # memswap_limit equal to mem_limit disables swap for this container.
- # Without it, Docker lets the process silently swap onto host disk,
- # turning sub-second graph queries into multi-second ones. Hard
- # OOM-kill is preferable — the container restarts via unless-stopped,
- # the deploy health poll catches it, and the failure is explicit.
- mem_limit: 1792m
- memswap_limit: 1792m
- logging: *default-logging
- healthcheck:
- test: ['CMD', 'curl', '-fsS', 'http://127.0.0.1:4747/api/info']
- interval: 30s
- timeout: 5s
- retries: 3
- start_period: 60s
-
- caddy:
- image: caddy:2-alpine
- container_name: gitnexus-caddy
- restart: unless-stopped
- # service_healthy (not just service_started) ensures Caddy doesn't
- # start routing traffic until gitnexus passes its initial healthcheck
- # on a cold `compose up`. This only governs initial startup ordering —
- # during force-recreates of gitnexus, Caddy stays up and may briefly
- # return 502 while the new gitnexus container binds its port. The
- # deploy workflow's health poll catches any sustained failure.
- depends_on:
- gitnexus:
- condition: service_healthy
- ports:
- - '80:80'
- - '443:443'
- networks:
- - gitnexus-net
- volumes:
- - /opt/gitnexus/Caddyfile:/etc/caddy/Caddyfile:ro
- - caddy-data:/data
- - caddy-config:/config
- logging: *default-logging
- environment:
- GITNEXUS_DOMAIN: ${GITNEXUS_DOMAIN}
- API_TOKEN: ${API_TOKEN}
-
-networks:
- gitnexus-net:
- driver: bridge
-
-volumes:
- caddy-data:
- caddy-config:
diff --git a/.do/gitnexus/entrypoint.sh b/.do/gitnexus/entrypoint.sh
deleted file mode 100644
index a5f0e7e54a0..00000000000
--- a/.do/gitnexus/entrypoint.sh
+++ /dev/null
@@ -1,48 +0,0 @@
-#!/bin/sh
-set -e
-
-# Cap Node heap below the container's cgroup limit (1792m in compose),
-# leaving room for @ladybugdb/core's C++ heap and OS overhead. Native
-# allocations happen outside V8's view, so a slim V8 budget is the only
-# thing between a heavy query and a cgroup OOM-kill. Without this cap,
-# gitnexus defaults to --max-old-space-size=8192 and reserves memory
-# the container doesn't have.
-export NODE_OPTIONS="${NODE_OPTIONS:---max-old-space-size=1280}"
-
-# Register every index mounted under /indexes//.gitnexus/.
-# This is idempotent — re-registering an existing repo updates the
-# metadata pointer without touching the index data.
-#
-# Registration failure handling:
-# - main (LibreChat) and dev (LibreChat-dev) are critical. If either
-# fails to register, exit 1 so docker marks the container unhealthy
-# and the deploy workflow's readiness check surfaces the error.
-# - PR indexes (LibreChat-pr-*) are best-effort. A corrupt PR index
-# shouldn't take the whole server down.
-if [ -d /indexes ]; then
- for dir in /indexes/*/; do
- [ -d "$dir" ] || continue
- name=$(basename "$dir")
- [ -d "$dir.gitnexus" ] || continue
- echo "Registering index: $name"
- if ! gitnexus index "$dir" --allow-non-git; then
- case "$name" in
- LibreChat|LibreChat-dev)
- echo "ERROR: failed to register critical index $name" >&2
- exit 1
- ;;
- *)
- echo "WARN: failed to register PR index $name — skipping" >&2
- ;;
- esac
- fi
- done
-else
- echo "WARN: /indexes directory not mounted" >&2
-fi
-
-# Bind 0.0.0.0 inside the container so Caddy (in a separate container
-# on the same docker network) can reach gitnexus at gitnexus:4747.
-# docker-compose.yml intentionally does NOT expose port 4747 on the
-# host — only Caddy's 80/443 are published.
-exec gitnexus serve --host 0.0.0.0 --port 4747
diff --git a/.do/gitnexus/install-extensions.js b/.do/gitnexus/install-extensions.js
deleted file mode 100644
index 231741e949b..00000000000
--- a/.do/gitnexus/install-extensions.js
+++ /dev/null
@@ -1,46 +0,0 @@
-/**
- * Pre-install LadybugDB extensions (FTS + vector) into the Docker image's
- * extension cache (~/.kuzu/extension/). Without this, gitnexus serve's
- * lbug-adapter calls LOAD EXTENSION fts at runtime but fails silently
- * because the extension was never installed, causing all BM25 and
- * semantic queries via the query() tool to return empty.
- *
- * Workaround for upstream GitNexus 1.5.3 bug where the CI-produced
- * .gitnexus/ artifact doesn't include the extension cache.
- */
-
-const path = require('path');
-const fs = require('fs');
-
-// @ladybugdb/core lives under the globally-installed gitnexus package.
-// This path is stable across gitnexus versions because npm always nests
-// transitive deps under the installed package's node_modules.
-const lbugPath = '/usr/local/lib/node_modules/gitnexus/node_modules/@ladybugdb/core';
-const lbug = require(lbugPath);
-
-const tmpDir = '/tmp/lbug-ext-install';
-fs.mkdirSync(tmpDir, { recursive: true });
-
-// Open a throwaway database just to run INSTALL against. The extension
-// cache persists in ~/.kuzu/extension/ regardless of which database was
-// used to install it, so the throwaway db and tmpDir are deleted in the
-// Dockerfile after this script finishes.
-const db = new lbug.Database(path.join(tmpDir, 'db'), 0, false, false);
-const conn = new lbug.Connection(db);
-
-(async () => {
- try {
- await conn.query('INSTALL fts');
- console.log('FTS extension installed');
- } catch (err) {
- console.error('FTS install failed:', err.message);
- process.exit(1);
- }
- try {
- await conn.query('INSTALL vector');
- console.log('Vector extension installed');
- } catch (err) {
- console.error('Vector install failed:', err.message);
- process.exit(1);
- }
-})();
diff --git a/.env.example b/.env.example
index 09749f7e05b..454f8460300 100644
--- a/.env.example
+++ b/.env.example
@@ -14,6 +14,19 @@
HOST=localhost
PORT=3080
+# Optional Node.js HTTP server timeouts in milliseconds. When unset, Node.js defaults apply.
+# For an ALB, set the application keep-alive timeout above the ALB idle timeout.
+# Requires Node.js: Bun accepts these values but does not enforce them.
+# Header and request timeout expiry is only detected on a 30s connection sweep, so values
+# below 30000 take effect late and are not enforced at the precision configured.
+# The header timeout is clamped to the request timeout when the latter is lower, since Node
+# does not enforce a request timeout that a longer header timeout sits above.
+# Keep-alive is socket-driven and remains exact at any value.
+# HTTP_KEEP_ALIVE_TIMEOUT_MS=70000
+# HTTP_KEEP_ALIVE_TIMEOUT_BUFFER_MS=5000
+# HTTP_HEADERS_TIMEOUT_MS=80000
+# HTTP_REQUEST_TIMEOUT_MS=300000
+
MONGO_URI=mongodb://127.0.0.1:27017/LibreChat
#The maximum number of connections in the connection pool. */
MONGO_MAX_POOL_SIZE=
@@ -25,15 +38,18 @@ MONGO_MAX_CONNECTING=
MONGO_MAX_IDLE_TIME_MS=
#The maximum time in milliseconds that a thread can wait for a connection to become available. */
MONGO_WAIT_QUEUE_TIMEOUT_MS=
-# Set to false to disable automatic index creation for all models associated with this connection. */
+# Set to false to disable automatic index creation for all models associated with this connection.
+# Leave empty (unset) to use Mongoose's default — an empty value is not treated as false. */
MONGO_AUTO_INDEX=
-# Set to `false` to disable Mongoose automatically calling `createCollection()` on every model created on this connection. */
+# Set to `false` to disable Mongoose automatically calling `createCollection()` on every model created on this connection.
+# Leave empty (unset) to use Mongoose's default — an empty value is not treated as false. */
MONGO_AUTO_CREATE=
DOMAIN_CLIENT=http://localhost:3080
DOMAIN_SERVER=http://localhost:3080
# External admin panel base URL used for admin OAuth/SSO redirects.
+# When set, admins also get an Admin Panel link in Settings > General.
# Required when the admin panel is hosted separately from LibreChat.
# May include a path. Do not include a trailing slash.
# Example: https://admin.example.com/admin
@@ -49,6 +65,9 @@ ADMIN_PANEL_SESSION_SECRET=
# In deploy-compose the panel is served at http://admin.localhost via nginx.
# ADMIN_PANEL_PORT=3000
+# Enable the admin-only MongoDB Insights dashboard.
+ENABLE_INSIGHTS=false
+
NO_INDEX=true
# Use the address that is at most n number of hops away from the Express application.
# req.socket.remoteAddress is the first hop, and the rest are looked for in the X-Forwarded-For header from right to left.
@@ -56,6 +75,97 @@ NO_INDEX=true
# Defaulted to 1.
TRUST_PROXY=1
+#===============================#
+# Security Headers #
+#===============================#
+
+# Baseline HTTP security headers (HSTS, X-Frame-Options, X-Content-Type-Options,
+# COOP, CORP, Referrer-Policy) are sent on every response. Content-Security-Policy
+# is never set here. Set to false to send no security headers at all.
+# SECURITY_HEADERS=true
+
+# Strict-Transport-Security. Only meaningful over HTTPS; browsers ignore it on
+# plain HTTP. HSTS_INCLUDE_SUBDOMAINS applies the policy to every subdomain of
+# this host for the full max-age, so enable it only if all of them serve HTTPS.
+# HSTS_ENABLED=true
+# HSTS_MAX_AGE=31536000
+# HSTS_INCLUDE_SUBDOMAINS=false
+# HSTS_PRELOAD=false
+
+# X-Frame-Options. Set to DENY to block all framing, or to `off` if you embed
+# LibreChat in an iframe on another origin.
+# X_FRAME_OPTIONS=SAMEORIGIN
+
+# Referrer-Policy. Any standard token, or `off` to omit the header.
+# REFERRER_POLICY=no-referrer
+
+# Cross-Origin-Opener-Policy. Use same-origin-allow-popups if a popup-based
+# sign-in flow needs to reach back to the window that opened it.
+# CROSS_ORIGIN_OPENER_POLICY=same-origin
+
+# Cross-Origin-Resource-Policy. Use cross-origin if other sites need to load
+# resources served by LibreChat, such as uploaded images.
+# CROSS_ORIGIN_RESOURCE_POLICY=same-origin
+
+#===============================#
+# Content Security Policy #
+#===============================#
+
+# Nonce-based CSP for the SPA HTML response. Off by default so existing
+# deployments are unaffected. Turn it on in report-only mode first, review the
+# violations your deployment actually produces, then set CSP_REPORT_ONLY=false.
+# Only an explicit false/off/0/no enforces; anything unrecognized warns and stays
+# report-only, so a typo cannot silently start blocking scripts.
+# CSP_ENABLED=false
+# CSP_REPORT_ONLY=true
+# CSP_REPORT_URI=
+
+# The default policy accommodates what LibreChat actually loads at runtime:
+# script-src 'wasm-unsafe-eval' HEIC image conversion compiles WebAssembly
+# worker-src data: Monaco's loader bootstraps workers from data:
+# Both are narrower than 'unsafe-eval'. Set these to false to drop them if your
+# deployment uses neither HEIC uploads nor the artifact code editor. (The CSP_*_EXTRA
+# and CSP_ADDITIONAL_DIRECTIVES variables only add sources; they cannot remove one.)
+# CSP_ALLOW_WASM=true
+# CSP_ALLOW_DATA_WORKERS=true
+
+# While CSP is enabled the SPA shell is always sent as `no-store` and the
+# INDEX_CACHE_CONTROL / INDEX_PRAGMA / INDEX_EXPIRES overrides are ignored for it.
+# A cached shell would pin a single nonce across page loads and users, which is
+# precisely what a nonce policy exists to prevent.
+#
+# SECURITY_HEADERS=false disables CSP too; it is the global kill switch.
+
+# Add deployment-specific sources on top of LibreChat's defaults; they are
+# appended, never replacing them. Comma- or space-separated. Quote values
+# containing spaces.
+# CSP_CONNECT_SRC_EXTRA="https://telemetry.example.com wss://stream.example.com"
+# CSP_FRAME_SRC_EXTRA="https://tenant.sharepoint.com"
+# CSP_IMG_SRC_EXTRA="https://cdn.example.com"
+# CSP_STYLE_SRC_EXTRA=
+# CSP_FONT_SRC_EXTRA=
+# CSP_MEDIA_SRC_EXTRA=
+# CSP_WORKER_SRC_EXTRA=
+# CSP_FORM_ACTION_EXTRA=
+# CSP_DEFAULT_SRC_EXTRA=
+
+# Script hosts get their own note: the default policy uses 'strict-dynamic',
+# which makes browsers ignore every host source in script-src. Setting this
+# drops 'strict-dynamic' so the hosts you list actually take effect.
+# CSP_SCRIPT_SRC_EXTRA="https://trusted-scripts.example.com"
+
+# Who may frame LibreChat. Defaults to 'self'. Replace it if you embed LibreChat
+# in a portal on another origin, and set X_FRAME_OPTIONS=off alongside it since
+# older browsers honor that header instead.
+# CSP_FRAME_ANCESTORS="'self' https://portal.example.com"
+
+# Raw directives appended to the policy, separated by semicolons.
+# CSP_ADDITIONAL_DIRECTIVES="upgrade-insecure-requests"
+
+# Trust X-Tenant-Id on unauthenticated routes. Disabled by default.
+# Enable only when a trusted reverse proxy strips any client-supplied value and sets its own.
+# TRUST_TENANT_HEADER=false
+
# Minimum password length for user authentication
# Default: 8
# Note: When using LDAP authentication, you may want to set this to 1
@@ -84,6 +194,9 @@ CONSOLE_JSON=false
DEBUG_LOGGING=true
DEBUG_CONSOLE=false
+# Console verbosity: error, warn, info, http, verbose, debug, activity, silly, or
+# `silent` to turn console output off entirely. Defaults to `info`.
+# CONSOLE_LOG_LEVEL=info
# Set to false to disable file-backed Winston transports.
LOG_TO_FILE=true
# Set to true to enable agent debug logging
@@ -122,6 +235,18 @@ NODE_MAX_OLD_SPACE_SIZE=6144
# with the Skills capability enabled. Defaults to project root ./skill.
# DEPLOYMENT_SKILLS_DIR=./skill
+# Agent Plugins packages (skills + MCP servers + hooks) are loaded at startup
+# from this directory; each child directory is one plugin. Defaults to ./plugin.
+# DEPLOYMENT_PLUGINS_DIR=./plugin
+# DEPLOYMENT_PLUGIN_DATA_DIR=./data/plugins
+
+# Opt-in: execute `command` hook handlers declared by installed plugins
+# (ai.librechat/hooks/hooks.json). Commands run as child processes on the API
+# host with a minimal environment — only enable for plugins you trust, the
+# same trust level as toolApproval hook modules. Off by default: hook
+# documents are parsed but never executed.
+# DEPLOYMENT_PLUGIN_HOOKS=true
+
#==================#
# Langfuse Tracing #
#==================#
@@ -131,20 +256,100 @@ NODE_MAX_OLD_SPACE_SIZE=6144
# LANGFUSE_PUBLIC_KEY=
# LANGFUSE_SECRET_KEY=
# LANGFUSE_BASE_URL=
+# Optional stable project ID. When omitted, LibreChat discovers it from Langfuse in the background.
+# LANGFUSE_PROJECT_ID=
+# Set false to disable Langfuse traces and feedback scores.
+# LANGFUSE_TRACING_ENABLED=true
+# Trace-level sample rate from 0 to 1. Sampled-out traces do not receive scores.
+# LANGFUSE_SAMPLE_RATE=1
+
+# In single-tenant deployments without environment credentials, an admin can
+# configure one encrypted Langfuse connection in the application settings.
+# Complete environment credentials take precedence and hide those settings.
+
+# Optional Langfuse fanout for tenant-scoped Langfuse projects.
+# The fanout gateway is opt-in: add docker-compose.langfuse-fanout.yml,
+# deploy-compose.langfuse-fanout.yml, or enable helm langfuseFanout.
+# Tenant public/secret keys and a destination key are managed through
+# Settings > Langfuse. Destination keys resolve against known startup URLs;
+# credentials can be added or changed at runtime without restarting the gateway.
+# See otel/langfuse-fanout/README.md.
+# LANGFUSE_FANOUT_ENABLED=false
+# LANGFUSE_FANOUT_COLLECTOR_URL=http://langfuse-fanout-collector:4318
+# App-side switch: set true to tell the Langfuse SDK not to create media uploads
+# for central/fallback collector traces. Tenant-routed media uploads are unchanged.
+# LANGFUSE_FANOUT_CENTRAL_MEDIA_UPLOAD_DISABLED=false
+# Gateway HTTP listen address (default: :4318).
+# LANGFUSE_FANOUT_LISTEN_ADDR=:4318
+# Emergency switch: unset/false defaults enabled; set true to keep central fanout export but skip tenant trace/score export.
+# LANGFUSE_FANOUT_TENANT_EXPORT_DISABLED=false
+# Langfuse Cloud base URL options: https://cloud.langfuse.com (EU),
+# https://us.cloud.langfuse.com (US), https://jp.cloud.langfuse.com (JP).
+# Gateway-only central trace/media export URL. LibreChat feedback scores use
+# LANGFUSE_BASE_URL, so set both URLs to the same non-EU region when applicable.
+# LANGFUSE_FANOUT_CENTRAL_BASE_URL=https://cloud.langfuse.com
+# Gateway-only Basic auth header for central trace/media export. LibreChat feedback
+# scores use LANGFUSE_PUBLIC_KEY/LANGFUSE_SECRET_KEY instead.
+# LANGFUSE_FANOUT_CENTRAL_AUTH_HEADER=Basic
+# Set true on the gateway to disable central media export while leaving central
+# trace export unchanged.
+# LANGFUSE_FANOUT_CENTRAL_MEDIA_EXPORT_DISABLED=false
+# Compose's included gateway config supports the three listed destination keys.
+# Add custom keys only when the gateway is started with matching destination URLs.
+# LANGFUSE_FANOUT_TENANT_DESTINATIONS=eu=https://cloud.langfuse.com,us=https://us.cloud.langfuse.com,jp=https://jp.cloud.langfuse.com
+# Compose's collector config routes only these destination keys. The gateway
+# fails startup when LANGFUSE_FANOUT_TENANT_DESTINATIONS contains another key.
+# LANGFUSE_FANOUT_TRACE_DESTINATION_KEYS=eu,us,jp
+# Gateway base URL used to build one-time media upload URLs. Compose sets this
+# to its private service URL; Helm derives an internal service URL unless set.
+# LANGFUSE_FANOUT_PUBLIC_URL=http://langfuse-fanout-collector:4318
+# Internal gateway-to-collector trace endpoint. Compose sets this automatically.
+# LANGFUSE_FANOUT_TRACE_COLLECTOR_URL=http://langfuse-fanout-otel:4319
+# Redis-backed one-time upload plans let multiple gateway pods handle Langfuse
+# media create/upload requests. Compose sets this to its private Redis service.
+# LANGFUSE_FANOUT_REDIS_URI=redis://langfuse-fanout-redis:6379
+# LANGFUSE_FANOUT_REDIS_USERNAME=
+# LANGFUSE_FANOUT_REDIS_PASSWORD=
+# LANGFUSE_FANOUT_REDIS_KEY_PREFIX=langfuse-fanout
+# Internal collector receiver bind address. Helm uses 127.0.0.1 because the
+# collector is a sidecar; Compose uses 0.0.0.0 on the private fanout network.
+# LANGFUSE_FANOUT_OTEL_RECEIVER_ENDPOINT=0.0.0.0:4319
+# Static Compose collector destination URLs. Helm derives these from values.
+# LANGFUSE_FANOUT_TENANT_EU_BASE_URL=https://cloud.langfuse.com
+# LANGFUSE_FANOUT_TENANT_US_BASE_URL=https://us.cloud.langfuse.com
+# LANGFUSE_FANOUT_TENANT_JP_BASE_URL=https://jp.cloud.langfuse.com
+# LANGFUSE_FANOUT_UPSTREAM_TIMEOUT=30s
+# Optional bearer token for scraping the fanout gateway /metrics endpoint.
+# If unset, /metrics returns 401. The gateway also accepts METRICS_SECRET when present.
+# LANGFUSE_FANOUT_METRICS_SECRET=
+# LANGFUSE_FANOUT_MEMORY_LIMIT_MIB=256
+# LANGFUSE_FANOUT_MEMORY_SPIKE_LIMIT_MIB=64
+# LANGFUSE_FANOUT_BATCH_TIMEOUT=1s
+# LANGFUSE_FANOUT_BATCH_SEND_SIZE=128
+# LANGFUSE_FANOUT_METADATA_CARDINALITY_LIMIT=1000
-#=======================#
-# OpenTelemetry Tracing #
-#=======================#
+#===============#
+# OpenTelemetry #
+#===============#
# Enables backend OpenTelemetry tracing. General backend visibility only;
# use Langfuse for GenAI-specific prompt/model observability.
# OTEL_TRACING_ENABLED=false
+# Exports application logs as OpenTelemetry log records, correlated with the
+# active trace. Logs below OTEL_LOGS_LEVEL are not exported.
+# OTEL_LOGS_ENABLED=false
+# OTEL_LOGS_LEVEL=info
# OTEL_SERVICE_NAME=librechat
# OTEL_SERVICE_VERSION=
+# Exporter protocol for every signal: http/protobuf (default, port 4318) or grpc (port 4317).
+# Per-signal overrides: OTEL_EXPORTER_OTLP_TRACES_PROTOCOL, OTEL_EXPORTER_OTLP_LOGS_PROTOCOL.
+# OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318
# OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=
+# OTEL_EXPORTER_OTLP_LOGS_ENDPOINT=
# OTEL_EXPORTER_OTLP_HEADERS=
# OTEL_TRACES_EXPORTER=otlp
+# OTEL_LOGS_EXPORTER=otlp
# OTEL_TRACES_SAMPLER=parentbased_always_on
# OTEL_LOG_LEVEL=INFO
# OTEL_SDK_DISABLED=false
@@ -173,6 +378,12 @@ NODE_MAX_OLD_SPACE_SIZE=6144
# RUM_AUTH_MODE=proxy
# RUM_PROXY_TARGET_URL=http://otel-collector:4318
# RUM_PROXY_TIMEOUT_MS=10000
+# Optional server-only Authorization header for the upstream collector. Kept in env
+# as a deployment secret, never exposed through browser startup config.
+# For ClickStack, use the raw ingestion API key (no Bearer prefix).
+# RUM_PROXY_AUTHORIZATION=
+# Use an HTTPS RUM_PROXY_TARGET_URL across trust boundaries. With authorization set,
+# redirects are rejected; configure the final OTLP base URL, not a UI URL or /v1/traces.
# Optional comma-separated first-party HTTPS origins/URLs that should receive traceparent headers.
# Wildcards and non-HTTPS targets are ignored.
@@ -225,12 +436,15 @@ PROXY=
#============#
ANTHROPIC_API_KEY=user_provided
-# ANTHROPIC_MODELS=claude-fable-5,claude-opus-4-8,claude-opus-4-7,claude-sonnet-4-6,claude-opus-4-6,claude-opus-4-20250514,claude-3-7-sonnet-20250219,claude-3-5-sonnet-20241022,claude-3-5-haiku-20241022,claude-3-opus-20240229,claude-3-sonnet-20240229,claude-3-haiku-20240307
+# ANTHROPIC_MODELS=claude-fable-5-1,claude-fable-5,claude-opus-5,claude-opus-4-8,claude-opus-4-7,claude-sonnet-5,claude-sonnet-4-6,claude-opus-4-6,claude-opus-4-20250514,claude-3-7-sonnet-20250219,claude-3-5-sonnet-20241022,claude-3-5-haiku-20241022,claude-3-opus-20240229,claude-3-sonnet-20240229,claude-3-haiku-20240307
# ANTHROPIC_REVERSE_PROXY=
# Set to true to use Anthropic models through Google Vertex AI instead of direct API
# ANTHROPIC_USE_VERTEX=
# Supports regional locations like us-east5 and multi-region locations: us, eu, global
+# IMPORTANT: specific regional endpoints (us-east5, europe-west1, ...) only serve Claude Sonnet 4.6
+# and earlier. Newer models (Opus 4.7+, Opus 5, Sonnet 5, Fable 5/5.1) require `global` or a multi-region
+# location (`us`/`eu`) and will 404 on a specific region. `global` also avoids the 10% regional premium.
# ANTHROPIC_VERTEX_REGION=us-east5
#============#
@@ -291,8 +505,11 @@ ANTHROPIC_API_KEY=user_provided
# BEDROCK_AWS_BEARER_TOKEN=yourBedrockApiKey
# Note: This example list is not meant to be exhaustive. If omitted, all known, supported model IDs will be included for you.
-# BEDROCK_AWS_MODELS=anthropic.claude-fable-5,anthropic.claude-opus-4-8,anthropic.claude-opus-4-7,anthropic.claude-sonnet-4-6,anthropic.claude-opus-4-6-v1,anthropic.claude-3-5-sonnet-20240620-v1:0,meta.llama3-1-8b-instruct-v1:0
-# Cross-region inference model IDs: us.anthropic.claude-fable-5,us.anthropic.claude-opus-4-8,us.anthropic.claude-opus-4-7,us.anthropic.claude-sonnet-4-6,us.anthropic.claude-opus-4-6-v1,global.anthropic.claude-opus-4-6-v1
+# Claude 4+ models cannot be invoked on-demand by their bare `anthropic.` foundation-model ID; Bedrock requires a
+# cross-region inference profile (`global.` or `us.`) for those. The `global.` profile has no regional pricing premium.
+# BEDROCK_AWS_MODELS=global.anthropic.claude-fable-5-1,global.anthropic.claude-fable-5,global.anthropic.claude-opus-5,global.anthropic.claude-opus-4-8,global.anthropic.claude-opus-4-7,global.anthropic.claude-sonnet-5,global.anthropic.claude-sonnet-4-6,global.anthropic.claude-opus-4-6-v1,global.anthropic.claude-haiku-4-5-20251001-v1:0,meta.llama3-1-8b-instruct-v1:0
+# US-only routing alternative: us.anthropic.claude-fable-5-1,us.anthropic.claude-fable-5,us.anthropic.claude-opus-5,us.anthropic.claude-opus-4-8,us.anthropic.claude-opus-4-7,us.anthropic.claude-sonnet-5,us.anthropic.claude-sonnet-4-6,us.anthropic.claude-opus-4-6-v1
+# List the profiles available to your account with: aws bedrock list-inference-profiles --region
# See all Bedrock model IDs here: https://docs.aws.amazon.com/bedrock/latest/userguide/model-ids.html#model-ids-arns
@@ -303,9 +520,10 @@ ANTHROPIC_API_KEY=user_provided
# The following models are not support due to not supporting conversation history:
# ai21.j2-ultra-v1, cohere.command-text-v14, cohere.command-light-text-v14
-# Claude Mythos-class models (anthropic.claude-fable-5, anthropic.claude-mythos-5) are inference-profile
-# only on Bedrock — use a profile ID (e.g. us.anthropic.claude-fable-5) — and require opting into Anthropic
-# data sharing via the Bedrock Data Retention API/console before they can be invoked.
+# Claude Mythos-class models (anthropic.claude-fable-5-1, anthropic.claude-mythos-5-1, and their 5.0
+# predecessors) are inference-profile only on Bedrock — use a profile ID (e.g. us.anthropic.claude-fable-5-1)
+# — and require opting into Anthropic data sharing via the Bedrock Data Retention API/console before they
+# can be invoked.
#============#
# Google #
@@ -318,10 +536,10 @@ GOOGLE_KEY=user_provided
# GOOGLE_AUTH_HEADER=true
# Gemini API (AI Studio)
-# GOOGLE_MODELS=gemini-3.1-pro-preview,gemini-3.1-pro-preview-customtools,gemini-3.1-flash-lite-preview,gemini-2.5-pro,gemini-2.5-flash,gemini-2.5-flash-lite,gemini-2.0-flash,gemini-2.0-flash-lite
+# GOOGLE_MODELS=gemini-3.8-flash,gemini-3.7-flash,gemini-3.6-flash,gemini-3.5-flash,gemini-3.5-flash-lite,gemini-3.1-pro-preview,gemini-3.1-pro-preview-customtools,gemini-3.1-flash-lite-preview,gemini-2.5-pro,gemini-2.5-flash,gemini-2.5-flash-lite,gemini-2.0-flash,gemini-2.0-flash-lite
# Vertex AI
-# GOOGLE_MODELS=gemini-3.1-pro-preview,gemini-3.1-pro-preview-customtools,gemini-3.1-flash-lite-preview,gemini-2.5-pro,gemini-2.5-flash,gemini-2.5-flash-lite,gemini-2.0-flash-001,gemini-2.0-flash-lite-001
+# GOOGLE_MODELS=gemini-3.8-flash,gemini-3.7-flash,gemini-3.6-flash,gemini-3.5-flash,gemini-3.5-flash-lite,gemini-3.1-pro-preview,gemini-3.1-pro-preview-customtools,gemini-3.1-flash-lite-preview,gemini-2.5-pro,gemini-2.5-flash,gemini-2.5-flash-lite,gemini-2.0-flash-001,gemini-2.0-flash-lite-001
# GOOGLE_TITLE_MODEL=gemini-2.0-flash-lite-001
@@ -377,7 +595,7 @@ GOOGLE_KEY=user_provided
#============#
OPENAI_API_KEY=user_provided
-# OPENAI_MODELS=gpt-5,gpt-5-codex,gpt-5-mini,gpt-5-nano,o3-pro,o3,o4-mini,gpt-4.1,gpt-4.1-mini,gpt-4.1-nano,o3-mini,o1-pro,o1,gpt-4o,gpt-4o-mini
+# OPENAI_MODELS=gpt-6-astra,gpt-6-sol,gpt-6-luna,gpt-5.6,gpt-5.6-terra,gpt-5.6-luna,gpt-5.5,gpt-5.5-pro,chat-latest,gpt-5.4,gpt-5.4-pro,gpt-5.4-mini,gpt-5.4-nano,gpt-5.3-codex,gpt-5.2,gpt-5,gpt-5-codex,gpt-5-mini,gpt-5-nano,o3-pro,o3,o4-mini,gpt-4.1,gpt-4.1-mini,gpt-4.1-nano,o3-mini,o1-pro,o1,gpt-4o,gpt-4o-mini
DEBUG_OPENAI=false
@@ -411,8 +629,10 @@ ASSISTANTS_API_KEY=user_provided
# More info, including how to enable use of Assistants with Azure here:
# https://www.librechat.ai/docs/configuration/librechat_yaml/ai_endpoints/azure#using-assistants-with-azure
-CREDS_KEY=f34be427ebb29de8d88c107a71546019685ed8b241d8f2ed00c3df97ad2566f0
-CREDS_IV=e2341419ec3dd3d19b13a1a87fafcbfb
+# Leave these blank to let LibreChat generate and persist temporary credentials in .env.temp.
+# Configure unique, persistent values before using a production instance.
+CREDS_KEY=
+CREDS_IV=
# Azure AI Search
#-----------------
@@ -492,10 +712,12 @@ ZAPIER_NLA_API_KEY=
# Search #
#==================================================#
-SEARCH=true
+# Set both SEARCH=true and a unique MEILI_MASTER_KEY to enable search.
+SEARCH=false
MEILI_NO_ANALYTICS=true
MEILI_HOST=http://0.0.0.0:7700
-MEILI_MASTER_KEY=DrhYf7zENyR6AlUCKmnz0eYASOQdl6zxH7s7MKFSfFCt
+# Set a unique value when Meilisearch is enabled; do not reuse a published default.
+MEILI_MASTER_KEY=
# Optional: Disable indexing, useful in a multi-node setup
# where only one instance should perform an index sync.
@@ -508,6 +730,52 @@ MEILI_MASTER_KEY=DrhYf7zENyR6AlUCKmnz0eYASOQdl6zxH7s7MKFSfFCt
STT_API_KEY=
TTS_API_KEY=
+#==================================================#
+# Code Interpreter #
+#==================================================#
+
+# LIBRECHAT_CODE_API_KEY=
+
+# Advertise the immutable per-conversation code-environment decision protocol.
+# Enable only after every LibreChat API replica runs a version that supports protocol v1.
+# CODE_ENVIRONMENT_DECISION_VERSION=1
+# LIBRECHAT_CODE_BASEURL=
+# Current self-hosted Code Interpreter deployments use per-user LibreChat JWTs outside local mode.
+# Configure the matching public verifier on Code Interpreter; see:
+# https://www.librechat.ai/docs/features/code_interpreter#self-hosted-jwt-authentication
+# CODEAPI_AUTH_PROVIDER=librechat-jwt
+# CODEAPI_JWT_ENABLED=false
+# Set at least one private-key source. Precedence: PEM, base64-encoded PEM, then private JWK JSON.
+# CODEAPI_JWT_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
+# CODEAPI_JWT_PRIVATE_KEY_BASE64=
+# CODEAPI_JWT_PRIVATE_JWK_JSON=
+# CODEAPI_JWT_ALGORITHM=EdDSA
+# CODEAPI_JWT_KID=lc-codeapi-2026-05
+# CODEAPI_JWT_ISSUER=librechat
+# CODEAPI_JWT_AUDIENCE=codeapi
+# CODEAPI_JWT_TTL_SECONDS=300
+# CODEAPI_JWT_MINT_CACHE_SECONDS=30
+# CODEAPI_JWT_SINGLE_TENANT_ID=legacy
+# Multi-tenant deployments can reject requests without authenticated tenant context.
+# The Code Interpreter service must also set CODEAPI_TENANT_ISOLATION_STRICT=true.
+# TENANT_ISOLATION_STRICT=false
+# Optional dedicated Code API deployment for agents with Stateful code sessions enabled.
+# When configured, stateless agents continue using LIBRECHAT_CODE_BASEURL while stateful
+# agents fail closed onto this endpoint. The endpoint must advertise the `stateful` profile.
+# LIBRECHAT_CODE_BASEURL_STATEFUL=
+# Prewarm selected stateful sandboxes in parallel with model generation (default: true).
+# CODE_SANDBOX_PREWARM=true
+# Time in milliseconds before LibreChat treats a tracked sandbox as cold (default: 2100000 / 35 minutes).
+# CODE_SANDBOX_COLD_AFTER_MS=2100000
+# Sandbox stdout a single /exec response may carry (default: 65536). Sandbox image reads are
+# windowed to fill this budget, so matching it to the runner's SANDBOX_OUTPUT_MAX_SIZE keeps the
+# round-trip count — and the load on the Code API's execution rate limit — as low as possible.
+# A runner with a smaller cap is also detected at runtime, at the cost of one discarded read.
+# LIBRECHAT_CODE_SANDBOX_OUTPUT_MAX_SIZE=65536
+# Bytes read per sandbox image window, overriding the size derived from the stdout budget above.
+# Prefer setting the budget; use this only to pin a window size exactly.
+# LIBRECHAT_CODE_IMAGE_CHUNK_BYTES=
+
#==================================================#
# RAG #
#==================================================#
@@ -520,10 +788,20 @@ TTS_API_KEY=
# EMBEDDINGS_PROVIDER=openai
# EMBEDDINGS_MODEL=text-embedding-3-small
+# Stream upload responses with heartbeats during long-running file processing.
+# FILE_UPLOAD_SSE_ENABLED=false
+# Timeout in milliseconds for server-side remote file downloads (default: 15000).
+# REMOTE_FILE_FETCH_TIMEOUT_MS=15000
+# Maximum size in bytes for server-side remote file downloads (default: 536870912 / 512 MiB).
+# REMOTE_FILE_FETCH_MAX_BYTES=536870912
+
#===================================================#
# User System #
#===================================================#
+# Maximum characters in one mid-run Agent steering message (default: 16000).
+# STEER_MAX_LENGTH=16000
+
#========================#
# Moderation #
#========================#
@@ -536,6 +814,9 @@ BAN_VIOLATIONS=true
BAN_DURATION=1000 * 60 * 60 * 2
BAN_INTERVAL=20
+# Violation scores expire after this long (in ms) without new violations; 0 = never expire
+VIOLATION_SCORE_TTL=1000 * 60 * 60
+
LOGIN_VIOLATION_SCORE=1
REGISTRATION_VIOLATION_SCORE=1
CONCURRENT_VIOLATION_SCORE=1
@@ -545,13 +826,41 @@ TTS_VIOLATION_SCORE=0
STT_VIOLATION_SCORE=0
FORK_VIOLATION_SCORE=0
IMPORT_VIOLATION_SCORE=0
+SHARE_VIOLATION_SCORE=0
FILE_UPLOAD_VIOLATION_SCORE=0
+# Shared link retrieval re-inspects the whole shared snapshot on every request
+# and is reachable without authentication (defaults: 100 per IP and 60 per user,
+# both per minute).
+# SHARE_IP_MAX=100
+# SHARE_IP_WINDOW=1
+# SHARE_USER_MAX=60
+# SHARE_USER_WINDOW=1
+# Per-user limiter for metadata-only /files/usage requests that renew the TTL
+# of attachments waiting in queued Agent messages (default: 120 per 15 minutes).
+# FILE_USAGE_USER_MAX=120
+# FILE_USAGE_USER_WINDOW=15
+# Password-reset and verification request/submission scores default to 1 when unset.
+# RESET_PASSWORD_VIOLATION_SCORE=1
+# VERIFY_EMAIL_VIOLATION_SCORE=1
+# RESET_PASSWORD_SUBMISSION_VIOLATION_SCORE=1
+# VERIFY_EMAIL_SUBMISSION_VIOLATION_SCORE=1
LOGIN_MAX=7
LOGIN_WINDOW=5
REGISTER_MAX=5
REGISTER_WINDOW=60
+# Password-reset email requests and token submissions are limited separately.
+# Submission values inherit the matching request value when omitted; all default to 2.
+# RESET_PASSWORD_MAX=2
+# RESET_PASSWORD_WINDOW=2
+# RESET_PASSWORD_SUBMISSION_MAX=2
+# RESET_PASSWORD_SUBMISSION_WINDOW=2
+# VERIFY_EMAIL_MAX=2
+# VERIFY_EMAIL_WINDOW=2
+# VERIFY_EMAIL_SUBMISSION_MAX=2
+# VERIFY_EMAIL_SUBMISSION_WINDOW=2
+
LIMIT_CONCURRENT_MESSAGES=true
CONCURRENT_MESSAGE_MAX=2
@@ -577,6 +886,7 @@ ILLEGAL_MODEL_REQ_SCORE=5
#========================#
ALLOW_EMAIL_LOGIN=true
+# ALLOW_EMAIL_LOGIN_OVERRIDE=false # note: permits direct API email login while ALLOW_EMAIL_LOGIN=false; each use is logged
ALLOW_REGISTRATION=true
ALLOW_SOCIAL_LOGIN=false
ALLOW_SOCIAL_REGISTRATION=false
@@ -591,8 +901,10 @@ REFRESH_TOKEN_EXPIRY=(1000 * 60 * 60 * 24) * 7
# Set to false only for HTTP-only deployments where browsers drop Secure cookies.
# SESSION_COOKIE_SECURE=false
-JWT_SECRET=16f8c0ef4a5d391b26034086c628469d3f9f497f08163ab9b40137092f2909ef
-JWT_REFRESH_SECRET=eaa5191f2914e30b9387fd84e254e4ba6fc51b4654968a9b0803b456a54b8418
+# Leave these blank to use generated temporary secrets from .env.temp.
+# Configure unique, persistent values before using a production instance.
+JWT_SECRET=
+JWT_REFRESH_SECRET=
# Discord
DISCORD_CLIENT_ID=
@@ -681,6 +993,15 @@ OPENID_REUSE_TOKENS=
#is not rotated/revoked out from under downstream consumers (e.g. MCP servers that introspect the bearer).
#When OPENID_REUSE_TOKENS=true, the OpenID session cookie maxAge is extended to at least this value.
OPENID_REUSE_MAX_SESSION_AGE_MS=
+# Discovery attempts during startup (0-100, default 1). Set to 0 to use background retries only.
+# librechat.yaml `registration.openidDiscovery.startupAttempts` takes precedence.
+OPENID_DISCOVERY_RETRY_ATTEMPTS=
+# Delay in milliseconds between startup and background discovery retries (100-3600000, default 5000).
+# librechat.yaml `registration.openidDiscovery.retryDelayMs` takes precedence.
+OPENID_DISCOVERY_RETRY_DELAY_MS=
+#Short recovery window for a rotated OpenID refresh token while LibreChat publishes the refreshed session. Default 60000 ms (1 min).
+#Accepts arithmetic expressions. Increase only when slow session persistence or cross-replica publication needs more time.
+OPENID_REFRESH_BRIDGE_GRACE_MS=
#By default, signing key verification results are cached in order to prevent excessive HTTP requests to the JWKS endpoint.
#If a signing key matching the kid is found, this will be cached and the next time this kid is requested the signing key will be served from the cache.
#Default is true.
@@ -720,6 +1041,13 @@ SAML_CERT=
SAML_CALLBACK_URL=/oauth/saml/callback
SAML_SESSION_SECRET=
+# Stable NameID format requested from the IdP. Transient identifiers are rejected.
+# Persistent identifiers are recommended for account binding.
+# SAML_NAME_ID_FORMAT=urn:oasis:names:tc:SAML:2.0:nameid-format:persistent
+
+# Expected IdP entity ID. When set, assertions from a different or missing issuer are rejected.
+SAML_IDP_ISSUER=
+
# Attribute mappings (optional)
SAML_EMAIL_CLAIM=
SAML_USERNAME_CLAIM=
@@ -753,6 +1081,11 @@ ENTRA_ID_INCLUDE_OWNERS_AS_MEMBERS=false
# Default scopes provide access to user profiles and group memberships
OPENID_GRAPH_SCOPES=User.Read,People.Read,GroupMember.Read.All
+# Space-separated Microsoft Graph scopes requested by the OBO exchange for
+# {{LIBRECHAT_GRAPH_ACCESS_TOKEN}} placeholders in YAML-defined MCP servers.
+# This is separate from OPENID_GRAPH_SCOPES above.
+# GRAPH_API_SCOPES=https://graph.microsoft.com/.default
+
# LDAP
LDAP_URL=
LDAP_BIND_DN=
@@ -880,7 +1213,7 @@ HELP_AND_FAQ_URL=https://librechat.ai
# such as the below example of 250 mib
# CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES=262144000
-# Max size (bytes) of a code-execution artifact (docx/xlsx/csv/pptx/text/pdf) rendered as an
+# Max size (bytes) of a code-execution artifact (docx/xlsx/csv/pptx/potx/text/pdf) rendered as an
# inline preview. Larger files fall back to download-only. Default: 2 MB (2097152). Note the
# rendered HTML is independently capped at 512 KB, so very rich files may still skip preview.
# FILE_PREVIEW_MAX_EXTRACT_BYTES=2097152
@@ -895,6 +1228,30 @@ HELP_AND_FAQ_URL=https://librechat.ai
# Enable Redis for resumable LLM streams (defaults to USE_REDIS value if not set)
# Set to false to use in-memory storage for streams while keeping Redis for other caches
# USE_REDIS_STREAMS=true
+# Scheduled chats require shared Redis streams in multi-replica deployments.
+# Set this only when the deployment truly runs one LibreChat process without Redis.
+# Scheduled agents that can pause for approval or ask_user_question require both
+# Redis streams for shared action state and a durable shared checkpointer.
+# MongoDB is currently the built-in durable checkpointer (and the default).
+# SCHEDULES_SINGLE_PROCESS=true
+# Emergency global stop for both automatic and manual scheduled runs.
+# SCHEDULES_DISABLED=true
+
+# Coalesce streamed model/tool-argument deltas into windowed Redis publications (ms).
+# Defaults to 25ms when unset; explicit 0 publishes per delta. Batches both publish
+# and durable append operations (fewer Redis round trips and repeated guard/TTL work at high
+# token rates), adding up to one window of buffering latency and crash-loss exposure
+# for unflushed deltas. In-memory streams are unchanged. Values are capped at 1000.
+# Batched publications retain individual chunk frames for older subscribers; Pub/Sub
+# message count is unchanged. Incoming chunk_batch frames remain supported for existing
+# opt-in producers. See UPGRADING.md for compatibility guidance.
+# Keep the window <= the stream-smoothing cadence (`streamRate`, default 25ms):
+# each smoothing tick emits its pieces in one burst, so a tick-sized window
+# captures exactly one batch per tick; a larger window re-batches the paced
+# deltas and quantizes the smoothed cadence at delivery. With smoothing
+# disabled (`streamRate: 0`) there is no cadence to preserve — the window is
+# then purely the Redis-cost vs delivery-latency tradeoff described above.
+# STREAM_DELTA_COALESCE_MS=25
# Single Redis instance
# REDIS_URI=redis://127.0.0.1:6379
@@ -929,17 +1286,48 @@ HELP_AND_FAQ_URL=https://librechat.ai
# Redis connection limits
# REDIS_MAX_LISTENERS=40
+# Minimum interval in milliseconds between Keyv reconnect attempts after a
+# READONLY reply during standalone or Sentinel failover (default: 5000)
+# REDIS_READONLY_RECOVERY_INTERVAL=5000
+
# Redis ping interval in seconds (0 = disabled, >0 = enabled)
# When set to a positive integer, Redis clients will ping the server at this interval to keep connections alive
# When unset or 0, no pinging is performed (recommended for most use cases)
# REDIS_PING_INTERVAL=300
+# Milliseconds a heartbeat PING may go unanswered before the socket is presumed dead and
+# reconnected (default: 5000). Applies to REDIS_PING_INTERVAL and the subscriber heartbeat.
+# REDIS_PING_TIMEOUT=5000
+
+# Heartbeat interval in seconds for dedicated pub/sub subscriber connections (default: 15, 0 = disabled)
+# Subscribers carry no traffic between generations, so a peer that vanished without closing the
+# socket would otherwise go unnoticed until the kernel gives up (about 15 minutes at Linux defaults)
+# REDIS_SUBSCRIBER_PING_INTERVAL=15
+
+# TCP keepalive idle delay in milliseconds for ioredis sockets (default: 10000, 0 = kernel default)
+# REDIS_KEEP_ALIVE=10000
+
# Force specific cache namespaces to use in-memory storage even when Redis is enabled
# Comma-separated list of CacheKeys
# Defaults to CONFIG_STORE,APP_CONFIG so YAML-derived config stays per-container (safe for blue/green deployments)
# Set to empty string to force all namespaces through Redis: FORCED_IN_MEMORY_CACHE_NAMESPACES=
# FORCED_IN_MEMORY_CACHE_NAMESPACES=CONFIG_STORE,APP_CONFIG
+# Opt-in cache for authenticated user documents during request bursts. Requires Redis and
+# the AUTH_USER_DOC namespace to remain Redis-backed (default: off; set exactly to "on").
+# AUTH_USER_CACHE_MODE=off
+
+# TTL in milliseconds for cached group memberships used in ACL permission checks (default: 300000 / 5 minutes; 0 disables)
+# Membership changes invalidate affected entries immediately; the TTL bounds staleness from cross-process races.
+# USER_PRINCIPALS_CACHE_TTL_MS=300000
+# Redis lock TTL in milliseconds for cross-container cache builds (default: 5000)
+# 0 disables build locking only; the delayed stale-rewrite eviction pass still runs on Redis-backed stores.
+# Only used when the USER_PRINCIPALS namespace is Redis-backed; non-Redis deployments use in-process deduplication.
+# USER_PRINCIPALS_LOCK_TTL_MS=5000
+# Maximum time in milliseconds to wait for another container holding the lock to fill the cache
+# before falling back to a direct database read (default: USER_PRINCIPALS_LOCK_TTL_MS)
+# USER_PRINCIPALS_LOCK_WAIT_MS=5000
+
# Leader Election Configuration (for multi-instance deployments with Redis)
# Duration in seconds that the leader lease is valid before it expires (default: 25)
# LEADER_LEASE_DURATION=25
@@ -997,6 +1385,14 @@ OPENWEATHER_API_KEY=
# Tavily (Search Provider and/or Scraper)
# TAVILY_API_KEY=your_tavily_api_key
+# Keenable (Search Provider and/or Scraper; keyless by default, a key only lifts
+# rate limits and covers both search and page fetch)
+# KEENABLE_API_KEY=your_keenable_api_key
+# Optional: Custom Keenable search API URL
+# KEENABLE_API_URL=your_keenable_api_url
+# Optional: Custom Keenable fetch API URL (used when scraperProvider is keenable)
+# KEENABLE_FETCH_URL=your_keenable_fetch_api_url
+
# Scraper (Required)
# FIRECRAWL_API_KEY=your_firecrawl_api_key
# Optional: Custom Firecrawl API URL
diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
index 6524947ba22..d6ffb8399a6 100644
--- a/.github/CONTRIBUTING.md
+++ b/.github/CONTRIBUTING.md
@@ -10,6 +10,43 @@ Please note that a pull request involving a feature that has not been reviewed a
If you would like to discuss the changes you wish to make, join our [Discord community](https://discord.librechat.ai), where you can engage with other contributors and seek guidance from the community.
+## AI-Assisted Contributions
+
+AI coding agents are welcome here. A good part of this project is written with them, and we do not judge a pull request by whether a model helped write it. What we do ask is that agent-assisted work arrives the same way human work always has: attached to an issue, claimed in the open, and expected by someone.
+
+An agent makes a patch cheap to produce, which moves the whole cost of it onto the person reviewing it. Maintainer review time is the scarce resource in this project, so the rules below are about protecting that, not about which tools you use.
+
+### Claim the work first
+
+1. Find an existing issue, or open one describing the problem.
+2. Say in the issue that you would like to take it, and wait to be assigned.
+3. Open one pull request, linked to that issue, after it is assigned to you.
+
+A pull request that appears unannounced, with no issue, no assignment and no prior conversation, may be closed without review no matter how good the patch is. Features need prior approval as described above; agent assistance does not exempt a feature from the roadmap or the discussions board.
+
+**The one exception is a novel P0/P1 defect**: data loss, a broken release, a crash, or a regression with no workaround, that nobody has reported yet. Open it, and put the impact and the reproduction in the first paragraph. Novel is the operative word. A patch for something already reported, already assigned, or already fixed on `dev` is not an exception, and neither is a cosmetic or speculative change dressed up as urgent.
+
+**Security is never an exception.** Do not open a pull request, an issue, or a public message that describes a vulnerability, even a critical one, and even with a fix attached. A pull request is a public disclosure that explains the attack and points at the affected code. Report it through LibreChat's [private vulnerability reporting form](https://github.com/danny-avila/LibreChat/security/advisories/new) and we will open a private channel and coordinate the fix and its release there. See [SECURITY.md](./SECURITY.md).
+
+### Pull requests generated from issues
+
+A pull request produced by pointing an agent at our issue tracker will be rejected unless the issue it addresses was assigned to you. Sweeping open issues and emitting patches for them is not a contribution; it asks a maintainer to review work they never scoped, on an issue that may already belong to someone else. Being first to a patch does not claim an issue, and an issue assigned to another contributor is not available even if your fix is better.
+
+### What we close on sight
+
+These are patterns we actually receive, not hypotheticals:
+
+- **Batches.** Several unrelated pull requests opened minutes apart, or the same sweep run across many projects at once. One issue, one pull request, one conversation.
+- **Whole-file rewrites.** A one-line fix arriving as a thousand-line diff because the file was reformatted or its line endings were converted. Keep the diff to the lines you changed, and configure your tooling not to rewrite the rest (`git config core.autocrlf input` on Windows). An unreadable diff hides things, including reverts of recent commits your branch predates.
+- **Unverifiable claims.** A description asserting a bug, a root cause, or a passing test suite with nothing a reviewer can reproduce. Say what you ran and what you did not.
+- **A patch you cannot discuss.** You are the author of anything you submit. If you cannot explain in review why the change is correct, what it affects, and why the tests cover it, it is not ready.
+
+### If we continue your work
+
+A maintainer, or one of the agents working alongside us, may push commits to your branch and take a pull request the rest of the way instead of asking you for another round. That is the house style here, and it is meant as help rather than a takeover: the branch stays yours, and so does the authorship.
+
+If you would rather finish the work yourself, say so in the pull request description. One line is enough, and we will keep our suggestions in review instead.
+
## Our Standards
We strive to maintain a positive and inclusive environment within our project community. We expect all contributors to adhere to the following standards:
@@ -43,8 +80,16 @@ Project maintainers have the right and responsibility to remove, edit, or reject
## 2. Development Notes
-1. Before starting work, make sure your main branch has the latest commits with `npm run update`.
+1. Before starting work, sync `dev` from this repository. You are working in a fork, so `origin` is
+ your fork — add the canonical remote once and sync from it:
+ - `git remote add upstream https://github.com/danny-avila/LibreChat.git`
+ - `git fetch upstream dev && git checkout -B dev upstream/dev`
+ - `npm run update` is the self-host deployment updater — it checks out `main` and rebuilds your
+ containers. Do not use it to refresh a development branch.
2. Run linting command to find errors: `npm run lint`. Alternatively, ensure husky pre-commit checks are functioning.
+ - `npm install` sets the hooks up for you; set `HUSKY=0` to opt out.
+ - The pre-commit hook runs the Static Checks CI job locally, scoped to the files in the commit. Run it by hand with `npm run static-checks`, against a base ref with `npm run static-checks -- --against origin/dev`, or with the slow gates (TypeScript, config migration tests, unused i18n keys, unused npm packages) via `npm run static-checks:full`.
+ - Every commit gets ESLint, Prettier, import order and circular-dependency detection; the slower gates stay opt-in so commits stay fast.
3. After your changes, reinstall packages in your current branch using `npm run reinstall` and ensure everything still works.
- Restart the ESLint server ("ESLint: Restart ESLint Server" in VS Code command bar) and your IDE after reinstalling or updating.
4. Clear web app localStorage and cookies before and after changes.
@@ -57,11 +102,11 @@ Project maintainers have the right and responsibility to remove, edit, or reject
We utilize a GitFlow workflow to manage changes to this project's codebase. Follow these general steps when contributing code:
-1. Fork the repository and create a new branch with a descriptive slash-based name (e.g., `new/feature/x`).
+1. Fork the repository and branch off `dev` with a descriptive slash-based name (e.g., `new/feature/x`). All contributions target `dev`; `main` only moves at release time, and pull requests opened against it are retargeted automatically.
2. Implement your changes and ensure that all tests pass.
3. Commit your changes using conventional commit messages with GitFlow flags. Begin the commit message with a tag indicating the change type, such as "feat" (new feature), "fix" (bug fix), "docs" (documentation), or "refactor" (code refactoring), followed by a brief summary of the changes (e.g., `feat: Add new feature X to the project`).
-4. Submit a pull request with a clear and concise description of your changes and the reasons behind them.
-5. We will review your pull request, provide feedback as needed, and eventually merge the approved changes into the main branch.
+4. Submit a pull request against `dev` with a clear and concise description of your changes and the reasons behind them.
+5. We will review your pull request, provide feedback as needed, and eventually merge the approved changes into the `dev` branch.
## 4. Commit Message Format
diff --git a/.github/SECURITY.md b/.github/SECURITY.md
index b01e04e0160..92a5930fe52 100644
--- a/.github/SECURITY.md
+++ b/.github/SECURITY.md
@@ -8,7 +8,7 @@ At LibreChat, we prioritize the security of our project and value the contributi
When reporting a security vulnerability, you have the following options to reach out to us:
-- **Option 1: GitHub Security Advisory System**: We encourage you to use GitHub's Security Advisory system to report any security vulnerabilities you find. This allows us to receive vulnerability reports directly through GitHub. For more information on how to submit a security advisory report, please refer to the [GitHub Security Advisories documentation](https://docs.github.com/en/code-security/getting-started-with-security-vulnerability-alerts/about-github-security-advisories).
+- **Option 1: GitHub Private Vulnerability Reporting**: Submit sensitive vulnerability details through LibreChat's [private vulnerability reporting form](https://github.com/danny-avila/LibreChat/security/advisories/new). This sends the report confidentially to the project maintainers.
- **Option 2: GitHub Issues**: You can initiate first contact via GitHub Issues. However, please note that initial contact through GitHub Issues should not include any sensitive details.
@@ -51,11 +51,10 @@ We would like to express our gratitude to the security researchers and community
## Bug Bounty Program
-We currently do not have a bug bounty program in place. However, we welcome and appreciate any
-
- security-related contributions through pull requests (PRs) that address vulnerabilities in our codebase. We believe in the power of collaboration to improve the security of our project and invite you to join us in making it more robust.
+We currently do not have a bug bounty program in place. We do welcome security-related contributions, a proposed fix included, but the report comes first and it comes privately: disclose through one of the channels above so we can confirm the issue and agree on how the fix lands. Please do not open a public pull request, issue or discussion that describes a vulnerability, even with a patch attached, because the patch itself explains the attack to everyone reading it before users have a release to upgrade to. We believe in the power of collaboration to improve the security of our project and invite you to join us in making it more robust.
**Reference**
+
- https://cheatsheetseries.owasp.org/cheatsheets/Vulnerability_Disclosure_Cheat_Sheet.html
---
diff --git a/.github/playwright.yml b/.github/playwright.yml
index 27f026a525b..142d99b8813 100644
--- a/.github/playwright.yml
+++ b/.github/playwright.yml
@@ -13,7 +13,7 @@
# jobs:
# tests_e2e:
# name: Run Playwright tests
-# if: github.event.pull_request.head.repo.full_name == 'danny-avila/LibreChat'
+# if: github.event.pull_request.head.repo.full_name == github.repository
# timeout-minutes: 60
# runs-on: ubuntu-latest
# env:
@@ -36,8 +36,8 @@
# PLAYWRIGHT_BROWSERS_PATH: 0 # Places binaries to node_modules/@playwright/test
# TITLE_CONVO: false
# steps:
-# - uses: actions/checkout@v4
-# - uses: actions/setup-node@v4
+# - uses: actions/checkout@v5
+# - uses: actions/setup-node@v5
# with:
# node-version: 24.16.0
# cache: 'npm'
@@ -64,7 +64,7 @@
# run: npm run e2e:ci
# - name: Upload playwright report
-# uses: actions/upload-artifact@v3
+# uses: actions/upload-artifact@v6
# if: always()
# with:
# name: playwright-report
diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md
index cb637787f12..f5ea98dc12f 100644
--- a/.github/pull_request_template.md
+++ b/.github/pull_request_template.md
@@ -1,41 +1,214 @@
-# Pull Request Template
-
-⚠️ Before Submitting a PR, Please Review:
-- Please ensure that you have thoroughly read and understood the [Contributing Docs](https://github.com/danny-avila/LibreChat/blob/main/.github/CONTRIBUTING.md) before submitting your Pull Request.
-
-⚠️ Documentation Updates Notice:
-- Kindly note that documentation updates are managed in this repository: [librechat.ai](https://github.com/LibreChat-AI/librechat.ai)
-
-## Summary
-
-Please provide a brief summary of your changes and the related issue. Include any motivation and context that is relevant to your changes. If there are any dependencies necessary for your changes, please list them here.
-
-## Change Type
-
-Please delete any irrelevant options.
-
-- [ ] Bug fix (non-breaking change which fixes an issue)
-- [ ] New feature (non-breaking change which adds functionality)
-- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
-- [ ] This change requires a documentation update
-- [ ] Translation update
-
-## Testing
-
-Please describe your test process and include instructions so that we can reproduce your test. If there are any important variables for your testing configuration, list them here.
-
-### **Test Configuration**:
-
-## Checklist
-
-Please delete any irrelevant options.
-
-- [ ] My code adheres to this project's style guidelines
-- [ ] I have performed a self-review of my own code
-- [ ] I have commented in any complex areas of my code
-- [ ] I have made pertinent documentation changes
-- [ ] My changes do not introduce new warnings
-- [ ] I have written tests demonstrating that my changes are effective or that my feature works
-- [ ] Local unit tests pass with my changes
-- [ ] Any changes dependent on mine have been merged and published in downstream modules.
-- [ ] A pull request for updating the documentation has been submitted.
+# Pull Request
+
+> Before submitting, please review the [Contributing Guide](https://github.com/danny-avila/LibreChat/blob/main/.github/CONTRIBUTING.md).
+>
+> Documentation changes belong in the [LibreChat documentation repository](https://github.com/LibreChat-AI/librechat.ai).
+
+## Summary
+
+
+
+## How it works
+
+
+
+## Type of change
+
+
+
+* [ ] Bug fix
+* [ ] Feature
+* [ ] Refactor
+* [ ] Performance improvement
+* [ ] Breaking change
+* [ ] Documentation
+* [ ] Translation
+* [ ] Tests / tooling / CI
+
+## Testing
+
+
+
+**Tested environments/configuration:**
+
+
+
+**Automated tests:**
+
+
+
+## Screenshots / recordings
+
+
+
+## Risk / compatibility
+
+
+
+## Checklist
+
+* [ ] I reviewed my own changes
+* [ ] Relevant tests have been added or updated
+* [ ] Existing relevant tests pass
+* [ ] The change does not introduce new warnings or errors
+* [ ] User-facing or complex behavior is documented where necessary
+* [ ] Required dependency changes have been merged/published
+* [ ] Required documentation PR:
diff --git a/.github/scripts/install-playwright-fonts.sh b/.github/scripts/install-playwright-fonts.sh
new file mode 100755
index 00000000000..ada4fa55377
--- /dev/null
+++ b/.github/scripts/install-playwright-fonts.sh
@@ -0,0 +1,30 @@
+#!/usr/bin/env bash
+#
+# Installs Playwright's optional font packages for the `chrome` channel.
+#
+# The GitHub runner ships Chrome as an apt package, so every library Playwright
+# lists is already satisfied by apt itself. The only packages `install-deps` adds
+# are decorative CJK/Thai/Cyrillic fonts (~21MB) that no CI assertion depends on:
+# the one spec that takes screenshots gates the comparison behind
+# `E2E_VISUAL_SNAPSHOTS`, which CI never sets.
+#
+# Ubuntu's mirrors stall often enough that a hard failure here has repeatedly
+# taken down whole e2e runs, so each attempt is capped and a final failure is
+# only a warning. The workflow keeps `continue-on-error: true` as a backstop for
+# the case where the step itself is killed by its timeout.
+
+set -uo pipefail
+
+readonly ATTEMPT_TIMEOUT_SECONDS=70
+readonly MAX_ATTEMPTS=3
+
+for attempt in $(seq 1 "${MAX_ATTEMPTS}"); do
+ if timeout "${ATTEMPT_TIMEOUT_SECONDS}" npx playwright install-deps chrome; then
+ exit 0
+ fi
+ echo "::warning::playwright install-deps attempt ${attempt}/${MAX_ATTEMPTS} failed or timed out"
+ sleep 5
+done
+
+echo "::warning::Optional Playwright font packages were not installed; continuing without them."
+exit 0
diff --git a/.github/scripts/retarget-prs.sh b/.github/scripts/retarget-prs.sh
new file mode 100755
index 00000000000..f9358b4efc7
--- /dev/null
+++ b/.github/scripts/retarget-prs.sh
@@ -0,0 +1,155 @@
+#!/usr/bin/env bash
+# Retarget pull requests opened against the release branch onto the development branch.
+# Used by .github/workflows/pr-retarget-dev.yml for both the on-open hook and the manual sweep.
+set -euo pipefail
+
+REPO="${REPO:?REPO is required (owner/name)}"
+RELEASE_BASE="${RELEASE_BASE:-main}"
+TARGET_BASE="${TARGET_BASE:-dev}"
+DRY_RUN="${DRY_RUN:-false}"
+EXPLAIN_MISSING="${EXPLAIN_MISSING:-false}"
+KEEP_LABEL="${KEEP_LABEL:-target: main}"
+THROTTLE_SECONDS="${THROTTLE_SECONDS:-0}"
+MARKER=""
+
+if [ "$#" -eq 0 ]; then
+ echo "usage: REPO=owner/name $0 [pr-number...]" >&2
+ exit 64
+fi
+
+# Branches on the upstream repository that legitimately merge into the release branch.
+# Backport branches are deliberately absent: `main` is kept as a fast-forward of `dev`, so a
+# backport merged straight to `main` would break that invariant. Use the label to exempt one.
+keeps_release_base() {
+ local head_repo="$1" head_ref="$2"
+ [ "$head_repo" = "$REPO" ] || return 1
+ case "$head_ref" in
+ "$TARGET_BASE" | release/* | hotfix/*) return 0 ;;
+ *) return 1 ;;
+ esac
+}
+
+# 0 = already explained, 1 = not explained, 2 = the lookup itself failed. The third status
+# matters: treating a failed read as "not explained" would post a duplicate. Bodies are collected
+# before grepping because piping into `grep -q` lets SIGPIPE fail the pipeline under `pipefail`.
+already_explained() {
+ local bodies
+ bodies="$(gh api "repos/$REPO/issues/$1/comments" --paginate --jq '.[].body')" || return 2
+ grep -qF "$MARKER" <<<"$bodies"
+}
+
+comment_body() {
+ cat </dev/null; then
+ echo "#$number: skipped — labelled '$KEEP_LABEL'"
+ skipped=$((skipped + 1))
+ continue
+ fi
+
+ if keeps_release_base "$head_repo" "$head_ref"; then
+ echo "#$number: skipped — $head_repo:$head_ref is a release-bound branch"
+ skipped=$((skipped + 1))
+ continue
+ fi
+
+ if [ "$DRY_RUN" = "true" ]; then
+ echo "#$number: would retarget $RELEASE_BASE -> $TARGET_BASE ($head_repo:$head_ref)"
+ matched=$((matched + 1))
+ continue
+ fi
+
+ echo "#$number: retargeting $RELEASE_BASE -> $TARGET_BASE ($head_repo:$head_ref)"
+ if ! gh pr edit "$number" --repo "$REPO" --base "$TARGET_BASE"; then
+ echo "#$number: FAILED to change base branch"
+ failed=$((failed + 1))
+ continue
+ fi
+ post_explanation "$number"
+ matched=$((matched + 1))
+ [ "$THROTTLE_SECONDS" = "0" ] || sleep "$THROTTLE_SECONDS"
+done
+
+if [ "$DRY_RUN" = "true" ]; then
+ echo "DRY RUN — no pull request was modified. would_retarget=$matched skipped=$skipped failed=$failed"
+ summary="**Dry run — nothing was modified.** Would retarget **$matched**, skip **$skipped**, failed to read **$failed**."
+else
+ echo "retargeted=$matched skipped=$skipped failed=$failed unexplained=$unexplained"
+ summary="Retargeted **$matched** pull request(s) onto \`$TARGET_BASE\`, skipped **$skipped**, failed **$failed**, missing an explanation **$unexplained**."
+fi
+
+echo "$summary"
+[ -z "${GITHUB_STEP_SUMMARY:-}" ] || echo "$summary" >> "$GITHUB_STEP_SUMMARY"
+
+[ "$failed" -eq 0 ] && [ "$unexplained" -eq 0 ]
diff --git a/.github/scripts/verify-playwright-ffmpeg.sh b/.github/scripts/verify-playwright-ffmpeg.sh
new file mode 100755
index 00000000000..6048bdd2ec7
--- /dev/null
+++ b/.github/scripts/verify-playwright-ffmpeg.sh
@@ -0,0 +1,43 @@
+#!/usr/bin/env bash
+#
+# Verifies that Playwright's ffmpeg download produced a usable binary.
+#
+# `playwright install` is not trustworthy on its own here: under the Node 24.16.0
+# yauzl/extract-zip regression (Playwright < 1.60.0) it would hang mid-extraction
+# and leave a truncated `ffmpeg-linux` behind with no INSTALLATION_COMPLETE marker,
+# so the exit code said nothing about whether ffmpeg actually worked.
+#
+# CI caches the download, so this runs before the cache is saved: checking both the
+# marker and that the binary actually executes is what keeps a partial extraction
+# from being promoted into a cache that every later job would restore. The install
+# directory is read back from Playwright so this stays correct across version bumps
+# and never lets a stale revision vouch for the one actually required.
+
+set -uo pipefail
+
+install_dir=$(npx playwright install --dry-run ffmpeg 2>/dev/null |
+ sed -n 's/^[[:space:]]*Install location:[[:space:]]*//p' | head -1)
+
+if [ -z "${install_dir}" ]; then
+ echo "::warning::Could not determine Playwright's ffmpeg install location; skipping cache save."
+ exit 1
+fi
+
+if [ ! -f "${install_dir}/INSTALLATION_COMPLETE" ]; then
+ echo "::warning::${install_dir} has no INSTALLATION_COMPLETE marker; the download did not finish."
+ exit 1
+fi
+
+binary="${install_dir}/ffmpeg-linux"
+
+if [ ! -x "${binary}" ]; then
+ echo "::warning::${binary} is missing or not executable."
+ exit 1
+fi
+
+if ! "${binary}" -version >/dev/null 2>&1; then
+ echo "::warning::${binary} is present but does not execute; treating it as a partial extraction."
+ exit 1
+fi
+
+echo "Verified Playwright ffmpeg at ${binary}"
diff --git a/.github/workflows/a11y.yml b/.github/workflows/a11y.yml
index 344592cf3ed..e2a521807be 100644
--- a/.github/workflows/a11y.yml
+++ b/.github/workflows/a11y.yml
@@ -4,6 +4,7 @@ on:
pull_request:
paths:
- 'client/src/**'
+ - '!**.md'
workflow_dispatch:
inputs:
run_workflow:
@@ -15,16 +16,20 @@ permissions:
contents: read
pull-requests: write
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
jobs:
axe-linter:
runs-on: ubuntu-latest
if: >
- (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == 'danny-avila/LibreChat') ||
+ (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) ||
(github.event_name == 'workflow_dispatch' && github.event.inputs.run_workflow == 'true')
steps:
- - uses: actions/checkout@v4
- - uses: dequelabs/axe-linter-action@v1
+ - uses: actions/checkout@v5
+ - uses: dequelabs/axe-linter-action@v2
with:
api_key: ${{ secrets.AXE_LINTER_API_KEY }}
github_token: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/agents-integration-tests.yml b/.github/workflows/agents-integration-tests.yml
new file mode 100644
index 00000000000..f015ee618ac
--- /dev/null
+++ b/.github/workflows/agents-integration-tests.yml
@@ -0,0 +1,117 @@
+name: Integration Tests
+
+# Runs every packages/api `*.integration.spec.ts` / `*.integration.test.ts` suite (e.g. the
+# durable HITL checkpointer and cross-replica subagent delivery against real MongoDB and
+# Redis, the admin config secret registry against a real Config collection, MCP flows
+# against in-process servers). `test:ci` deliberately excludes them, and the Redis-backed
+# `*.cache_integration` / `*.stream_integration` suites run in cache-integration-tests.yml —
+# without this job they run nowhere and their regressions guard nothing. Selection is by
+# suffix, not folder, so a suite added anywhere under src is picked up.
+on:
+ pull_request:
+ branches:
+ - main
+ - dev
+ - dev-staging
+ - release/*
+ # The suites build and consume data-provider and data-schemas and import
+ # across packages/api (the build-cache keys below hash all three src trees),
+ # so they must re-run on any of them.
+ paths:
+ - 'packages/api/src/**'
+ - 'packages/api/package.json'
+ - 'packages/data-provider/src/**'
+ - 'packages/data-provider/package.json'
+ - 'packages/data-schemas/src/**'
+ - 'packages/data-schemas/package.json'
+ - '.github/workflows/agents-integration-tests.yml'
+ - '!**.md'
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ agents_integration_tests:
+ name: Integration Tests (MongoDB and Redis)
+ timeout-minutes: 20
+ runs-on: ubuntu-latest
+
+ services:
+ redis:
+ image: redis:7-alpine
+ ports:
+ - 6379:6379
+ options: >-
+ --health-cmd "redis-cli ping"
+ --health-interval 5s
+ --health-timeout 5s
+ --health-retries 5
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v5
+
+ - name: Use Node.js 24.16.0
+ uses: actions/setup-node@v5
+ with:
+ node-version: '24.16.0'
+
+ - name: Restore node_modules cache
+ id: cache-node-modules
+ uses: actions/cache@v5
+ with:
+ path: |
+ node_modules
+ api/node_modules
+ packages/api/node_modules
+ packages/data-provider/node_modules
+ packages/data-schemas/node_modules
+ key: node-modules-backend-${{ runner.os }}-24.16.0-${{ hashFiles('package-lock.json') }}
+
+ - name: Install dependencies
+ if: steps.cache-node-modules.outputs.cache-hit != 'true'
+ run: npm ci
+
+ - name: Restore data-provider build cache
+ id: cache-data-provider
+ uses: actions/cache@v5
+ with:
+ path: packages/data-provider/dist
+ key: build-data-provider-${{ runner.os }}-${{ hashFiles('package.json', 'package-lock.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }}
+
+ - name: Build data-provider
+ if: steps.cache-data-provider.outputs.cache-hit != 'true'
+ run: npm run build:data-provider
+
+ - name: Restore data-schemas build cache
+ id: cache-data-schemas
+ uses: actions/cache@v5
+ with:
+ path: packages/data-schemas/dist
+ key: build-data-schemas-${{ runner.os }}-${{ hashFiles('package.json', 'package-lock.json', 'packages/data-schemas/src/**', 'packages/data-schemas/tsconfig*.json', 'packages/data-schemas/tsdown.config.mjs', 'packages/data-schemas/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }}
+
+ - name: Build data-schemas
+ if: steps.cache-data-schemas.outputs.cache-hit != 'true'
+ run: npm run build:data-schemas
+
+ - name: Restore api build cache
+ id: cache-api
+ uses: actions/cache@v5
+ with:
+ path: packages/api/dist
+ key: build-api-${{ runner.os }}-${{ hashFiles('package.json', 'package-lock.json', 'packages/api/src/**', 'packages/api/tsconfig*.json', 'packages/api/tsdown.config.mjs', 'packages/api/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json', 'packages/data-schemas/src/**', 'packages/data-schemas/tsconfig*.json', 'packages/data-schemas/tsdown.config.mjs', 'packages/data-schemas/package.json') }}
+
+ - name: Build api
+ if: steps.cache-api.outputs.cache-hit != 'true'
+ run: npm run build:api
+
+ - name: Run integration tests
+ working-directory: packages/api
+ env:
+ NODE_ENV: test
+ REDIS_URI: redis://127.0.0.1:6379
+ run: npm run test:integration
diff --git a/.github/workflows/backend-review.yml b/.github/workflows/backend-review.yml
index 46a698cd5ac..82d0088cdc9 100644
--- a/.github/workflows/backend-review.yml
+++ b/.github/workflows/backend-review.yml
@@ -1,14 +1,45 @@
name: Backend Unit Tests
on:
+ # push-to-dev runs are the post-merge safety net: gating only ever narrows pull_request
+ # synchronize runs, so every merged state still gets the full suite — which is also what
+ # keeps ground-truth recall telemetry alive for the codegraph shadow evaluator once
+ # selection hides skipped tests from PR runs.
+ push:
+ branches:
+ - dev
+ paths:
+ - 'api/**'
+ - 'packages/**'
+ - 'package.json'
+ - 'package-lock.json'
+ - 'config/circular-deps.mjs'
+ - '.github/workflows/backend-review.yml'
+ - '!**.md'
pull_request:
paths:
- 'api/**'
- 'packages/**'
+ - 'package.json'
+ - 'package-lock.json'
+ - 'config/circular-deps.mjs'
+ - '.github/workflows/backend-review.yml'
+ - '!**.md'
permissions:
contents: read
+ pull-requests: read
+
+concurrency:
+ # PR pushes supersede each other (per-PR canceling group). Push events get a PER-COMMIT group:
+ # dev-push runs are the post-merge safety net and the full-run baseline, and with a shared
+ # canceling group closely spaced merges cancel each other's runs — observed live on 2026-08-23,
+ # when three consecutive dev merges cancelled the runs that would have caught #15142's red
+ # (Codex P2 on #15145).
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
+ cancel-in-progress: true
env:
+ SCARF_ANALYTICS: 'false'
NODE_ENV: CI
NODE_OPTIONS: '--max-old-space-size=${{ secrets.NODE_MAX_OLD_SPACE_SIZE || 6144 }}'
@@ -18,16 +49,16 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
- name: Use Node.js 24.16.0
- uses: actions/setup-node@v4
+ uses: actions/setup-node@v5
with:
node-version: '24.16.0'
- name: Restore node_modules cache
id: cache-node-modules
- uses: actions/cache@v4
+ uses: actions/cache@v5
with:
path: |
node_modules
@@ -43,10 +74,10 @@ jobs:
- name: Restore data-provider build cache
id: cache-data-provider
- uses: actions/cache@v4
+ uses: actions/cache@v5
with:
path: packages/data-provider/dist
- key: build-data-provider-${{ runner.os }}-${{ hashFiles('packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }}
+ key: build-data-provider-${{ runner.os }}-${{ hashFiles('package.json', 'package-lock.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }}
- name: Build data-provider
if: steps.cache-data-provider.outputs.cache-hit != 'true'
@@ -54,10 +85,10 @@ jobs:
- name: Restore data-schemas build cache
id: cache-data-schemas
- uses: actions/cache@v4
+ uses: actions/cache@v5
with:
path: packages/data-schemas/dist
- key: build-data-schemas-${{ runner.os }}-${{ hashFiles('packages/data-schemas/src/**', 'packages/data-schemas/tsconfig*.json', 'packages/data-schemas/tsdown.config.mjs', 'packages/data-schemas/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }}
+ key: build-data-schemas-${{ runner.os }}-${{ hashFiles('package.json', 'package-lock.json', 'packages/data-schemas/src/**', 'packages/data-schemas/tsconfig*.json', 'packages/data-schemas/tsdown.config.mjs', 'packages/data-schemas/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }}
- name: Build data-schemas
if: steps.cache-data-schemas.outputs.cache-hit != 'true'
@@ -65,52 +96,178 @@ jobs:
- name: Restore api build cache
id: cache-api
- uses: actions/cache@v4
+ uses: actions/cache@v5
with:
path: packages/api/dist
- key: build-api-${{ runner.os }}-${{ hashFiles('packages/api/src/**', 'packages/api/tsconfig*.json', 'packages/api/tsdown.config.mjs', 'packages/api/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json', 'packages/data-schemas/src/**', 'packages/data-schemas/tsconfig*.json', 'packages/data-schemas/tsdown.config.mjs', 'packages/data-schemas/package.json') }}
+ key: build-api-${{ runner.os }}-${{ hashFiles('package.json', 'package-lock.json', 'packages/api/src/**', 'packages/api/tsconfig*.json', 'packages/api/tsdown.config.mjs', 'packages/api/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json', 'packages/data-schemas/src/**', 'packages/data-schemas/tsconfig*.json', 'packages/data-schemas/tsdown.config.mjs', 'packages/data-schemas/package.json') }}
- name: Build api
if: steps.cache-api.outputs.cache-hit != 'true'
run: npm run build:api
- name: Upload data-provider build
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v6
with:
name: build-data-provider
path: packages/data-provider/dist
retention-days: 2
- name: Upload data-schemas build
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v6
with:
name: build-data-schemas
path: packages/data-schemas/dist
retention-days: 2
- name: Upload api build
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v6
with:
name: build-api
path: packages/api/dist
retention-days: 2
+ # Codegraph test selection — the GATE (stage 1: backend jest only).
+ #
+ # Paranoia policy: FULL on opened/reopened PRs and on every push to dev (see the push
+ # trigger above); SELECTED only on pull_request synchronize. Fork PRs carry no secrets, so
+ # the curl fails and everything falls back to FULL. Kill switch: set repo variable
+ # CODEGRAPH_GATING=off and this job skips, which makes every output empty and every test
+ # job behave exactly as before this workflow change. The server itself fails open (stale
+ # graph, unclassifiable change, root/lockfile floors => mode FULL per workspace), and this
+ # job emits nothing unless the response parses end to end — the worst case at every layer
+ # is "CI runs everything", which is the pre-gating behavior.
+ codegraph_select:
+ name: Codegraph select
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ if: >-
+ github.event_name == 'pull_request' &&
+ github.event.action == 'synchronize' &&
+ vars.CODEGRAPH_GATING != 'off'
+ outputs:
+ decided: ${{ steps.sel.outputs.decided }}
+ api_run: ${{ steps.sel.outputs.api_run }}
+ api_files: ${{ steps.sel.outputs.api_files }}
+ pkgapi_run: ${{ steps.sel.outputs.pkgapi_run }}
+ pkgapi_files: ${{ steps.sel.outputs.pkgapi_files }}
+ dataprovider_run: ${{ steps.sel.outputs.dataprovider_run }}
+ dataprovider_files: ${{ steps.sel.outputs.dataprovider_files }}
+ dataschemas_run: ${{ steps.sel.outputs.dataschemas_run }}
+ dataschemas_files: ${{ steps.sel.outputs.dataschemas_files }}
+ steps:
+ - name: Select tests, fail open on any doubt
+ id: sel
+ env:
+ URL: ${{ secrets.CODEGRAPH_URL }}
+ TOKEN: ${{ secrets.CODEGRAPH_TOKEN }}
+ GH_TOKEN: ${{ github.token }}
+ REPO: ${{ github.repository }}
+ PR: ${{ github.event.pull_request.number }}
+ BASE_SHA: ${{ github.event.pull_request.base.sha }}
+ HEAD_SHA: ${{ github.event.pull_request.head.sha }}
+ CHANGED: ${{ github.event.pull_request.changed_files }}
+ run: |
+ set +e
+ note() { echo "$1" >> "$GITHUB_STEP_SUMMARY"; }
+ note "### Codegraph select — GATING (backend jest)"
+ if [ -z "$URL" ] || [ -z "$TOKEN" ]; then note "_no codegraph config; running FULL_"; exit 0; fi
+ # A failed or truncated page must not become a shorter file list: the pipeline would hide
+ # gh's exit status behind jq, and a partial list can turn a required lane off. Check the
+ # fetch status AND the count against the PR's own changed_files (Codex P1, #15136).
+ if ! gh api "repos/$REPO/pulls/$PR/files" --paginate \
+ --jq '.[] | {path: .filename, status, patch}' > files.ndjson; then
+ note "_could not fetch changed files; running FULL_"; exit 0
+ fi
+ jq -s . files.ndjson > files.json
+ N=$(jq 'length' files.json)
+ if [ "$N" -eq 0 ] || { [ -n "$CHANGED" ] && [ "$N" -ne "$CHANGED" ]; }; then
+ note "_changed-file list incomplete ($N of ${CHANGED:-?}); running FULL_"; exit 0
+ fi
+ jq -c --arg b "$BASE_SHA" --arg h "$HEAD_SHA" '{files: ., mode: "safe", lockBaseSha: $b, lockHeadSha: $h}' files.json > body.json
+ # curl's status is checked explicitly: a transfer that times out or truncates after a
+ # parseable body must fail open, not be honoured (Codex P1, #15136). --fail-with-body
+ # also turns HTTP errors into a failure while keeping the error text for the summary.
+ RESP=$(curl -sS --fail-with-body -m 45 -H "Authorization: Bearer $TOKEN" \
+ -H 'content-type: application/json' --data-binary @body.json "$URL/v1/select"); RC=$?
+ if [ "$RC" -ne 0 ] || [ -z "$RESP" ] || ! echo "$RESP" | jq -e '.selected.api.mode' >/dev/null 2>&1; then
+ note "_codegraph unavailable (curl exit $RC: ${RESP:0:120}); running FULL_"
+ exit 0
+ fi
+ # Emit per-workspace run flag + workspace-relative file list. A workspace emits
+ # run=false ONLY on an explicit NONE; FULL and FILES both run (FILES filtered).
+ # Any selected path containing a space forces that workspace FULL (paths are
+ # server-validated to exclude quotes/backslashes/control chars, so plain
+ # interpolation into the test command below is safe; spaces are the one shape
+ # that would split — refuse to filter rather than risk it).
+ emit() {
+ key="$1"; ws="$2"; ignore="$3"
+ mode=$(echo "$RESP" | jq -r --arg w "$ws" '.selected[$w].mode')
+ files=""
+ if [ "$mode" = "FILES" ]; then
+ # Only an explicit NONE may skip. FILES with a missing/empty list is malformed and
+ # runs FULL (Codex P1 on #15145). A NON-empty list that the ignore regex filters to
+ # nothing is different and legitimately NONE: those files are exactly what this
+ # workspace's own jest run excludes, so full CI would not run them either.
+ raw_n=$(echo "$RESP" | jq -r --arg w "$ws" '.selected[$w].files // [] | length')
+ # Every selected path must live under the workspace: a wrong-prefixed path would
+ # survive ltrimstr, match nothing in the workspace cwd, and --passWithNoTests would
+ # turn "ran nothing" into green — a silent fail-closed (Codex P1 on #15145).
+ misplaced=$(echo "$RESP" | jq -r --arg w "$ws" --arg p "$ws/" '[.selected[$w].files // [] | .[] | select(startswith($p) | not)] | length')
+ if [ "$raw_n" = "0" ] || [ "$misplaced" != "0" ]; then
+ mode="FULL"
+ note "| $ws | malformed FILES decision ($raw_n files, $misplaced outside $ws/); running FULL |"
+ else
+ files=$(echo "$RESP" | jq -r --arg w "$ws" --arg p "$ws/" --arg ig "$ignore" '.selected[$w].files // [] | map(select((test(" ") | not) and (($ig == "") or (test($ig) | not)))) | map(ltrimstr($p)) | join(" ")')
+ spaced=$(echo "$RESP" | jq -r --arg w "$ws" '[.selected[$w].files // [] | .[] | select(test(" "))] | length')
+ if [ "$spaced" != "0" ]; then mode="FULL"; files=""; fi
+ if [ "$mode" = "FILES" ] && [ -z "$files" ]; then mode="NONE"; fi
+ fi
+ fi
+ if [ "$mode" = "NONE" ]; then
+ echo "${key}_run=false" >> "$GITHUB_OUTPUT"
+ note "| $ws | skip (no reachable tests) |"
+ elif [ "$mode" = "FILES" ]; then
+ n=$(echo "$files" | wc -w | tr -d ' ')
+ echo "${key}_run=true" >> "$GITHUB_OUTPUT"
+ echo "${key}_files=$files" >> "$GITHUB_OUTPUT"
+ note "| $ws | $n selected files |"
+ else
+ echo "${key}_run=true" >> "$GITHUB_OUTPUT"
+ note "| $ws | FULL |"
+ fi
+ }
+ note "| workspace | decision |"
+ note "|---|---|"
+ # Ignore regexes mirror what each workspace's own jest run excludes, because
+ # --runTestsByPath BYPASSES testPathIgnorePatterns (verified empirically) — without
+ # this, selection would newly run integration/manual/misc suites that full CI skips.
+ # Character classes instead of backslashes: these strings cross YAML->bash->jq and
+ # every escape layer is a chance to ship a filter that silently matches nothing.
+ emit api api ""
+ emit pkgapi packages/api 'integration|helper|__tests__/helpers/|manual[.]spec[.]'
+ emit dataprovider packages/data-provider ""
+ emit dataschemas packages/data-schemas 'misc/|dist/|node_modules/'
+ echo "decided=true" >> "$GITHUB_OUTPUT"
+ note ""
+ note "kill switch: repo variable \`CODEGRAPH_GATING=off\`; full runs remain on PR open and on every dev push"
+ exit 0
+
typecheck:
name: TypeScript type checks
needs: build
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
- name: Use Node.js 24.16.0
- uses: actions/setup-node@v4
+ uses: actions/setup-node@v5
with:
node-version: '24.16.0'
- name: Restore node_modules cache
id: cache-node-modules
- uses: actions/cache@v4
+ uses: actions/cache@v5
with:
path: |
node_modules
@@ -125,19 +282,19 @@ jobs:
run: npm ci
- name: Download data-provider build
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v7
with:
name: build-data-provider
path: packages/data-provider/dist
- name: Download data-schemas build
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v7
with:
name: build-data-schemas
path: packages/data-schemas/dist
- name: Download api build
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v7
with:
name: build-api
path: packages/api/dist
@@ -151,25 +308,30 @@ jobs:
- name: Type check @librechat/api
run: npx tsc --noEmit -p packages/api/tsconfig.json
+ - name: OpenAPI spec drift check
+ run: npm run -w @librechat/api openapi:check
+
+ - name: OpenAPI built documentation smoke test
+ run: npm run -w @librechat/api openapi:test
+
- name: Type check @librechat/client
run: npx tsc --noEmit -p packages/client/tsconfig.json
circular-deps:
name: Circular dependency checks
- needs: build
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
- name: Use Node.js 24.16.0
- uses: actions/setup-node@v4
+ uses: actions/setup-node@v5
with:
node-version: '24.16.0'
- name: Restore node_modules cache
id: cache-node-modules
- uses: actions/cache@v4
+ uses: actions/cache@v5
with:
path: |
node_modules
@@ -183,40 +345,13 @@ jobs:
if: steps.cache-node-modules.outputs.cache-hit != 'true'
run: npm ci
- - name: Download data-provider build
- uses: actions/download-artifact@v4
- with:
- name: build-data-provider
- path: packages/data-provider/dist
-
- - name: Download data-schemas build
- uses: actions/download-artifact@v4
- with:
- name: build-data-schemas
- path: packages/data-schemas/dist
-
- - name: Rebuild @librechat/api and check for circular dependencies
- run: |
- output=$(npm run build:api 2>&1)
- echo "$output"
- if echo "$output" | grep -q "Circular depend"; then
- echo "Error: Circular dependency detected in @librechat/api!"
- exit 1
- fi
-
- - name: Detect circular dependencies in rollup
- working-directory: ./packages/data-provider
- run: |
- output=$(npm run rollup:api)
- echo "$output"
- if echo "$output" | grep -q "Circular dependency"; then
- echo "Error: Circular dependency detected!"
- exit 1
- fi
+ - name: Detect circular dependencies
+ run: node config/circular-deps.mjs
test-api:
name: 'Tests: api (shard ${{ matrix.shard }}/3)'
- needs: build
+ needs: [build, codegraph_select]
+ if: ${{ !cancelled() && needs.build.result == 'success' && needs.codegraph_select.outputs.api_run != 'false' }}
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
@@ -233,16 +368,16 @@ jobs:
BAN_DURATION: ${{ secrets.BAN_DURATION }}
BAN_INTERVAL: ${{ secrets.BAN_INTERVAL }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
- name: Use Node.js 24.16.0
- uses: actions/setup-node@v4
+ uses: actions/setup-node@v5
with:
node-version: '24.16.0'
- name: Restore node_modules cache
id: cache-node-modules
- uses: actions/cache@v4
+ uses: actions/cache@v5
with:
path: |
node_modules
@@ -257,19 +392,19 @@ jobs:
run: npm ci
- name: Download data-provider build
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v7
with:
name: build-data-provider
path: packages/data-provider/dist
- name: Download data-schemas build
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v7
with:
name: build-data-schemas
path: packages/data-schemas/dist
- name: Download api build
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v7
with:
name: build-api
path: packages/api/dist
@@ -282,25 +417,77 @@ jobs:
- name: Prepare .env.test file
run: cp api/test/.env.test.example api/test/.env.test
+ # mongodb-memory-server cold-downloads a ~122MB MongoDB binary into
+ # ~/.cache/mongodb-binaries on first use — inside a 15s beforeAll hook, which is a
+ # timeout on a slow mirror day. Every MISSED verdict the codegraph shadow evaluator has
+ # ever recorded (9 across 6 PRs) plus several chronically flaky suites trace to exactly
+ # this download. restore-keys keeps the previous binary warm across lockfile churn; a
+ # genuinely new binary version downloads once and re-saves.
+ - name: Cache MongoDB memory-server binaries
+ uses: actions/cache@v5
+ with:
+ path: ~/.cache/mongodb-binaries
+ key: mongodb-binaries-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
+ restore-keys: |
+ mongodb-binaries-${{ runner.os }}-
- name: Run unit tests (shard ${{ matrix.shard }}/3)
- run: cd api && npm run test:ci -- --shard=${{ matrix.shard }}/3
+ env:
+ SELECTED: ${{ needs.codegraph_select.outputs.api_files }}
+ JEST_JSON: --json --outputFile=${{ github.workspace }}/jest-results/jest-results-api-${{ matrix.shard }}.json
+ run: |
+ mkdir -p "$GITHUB_WORKSPACE/jest-results"
+ cd api
+ # A selected path can be stale in exactly two ways at this checkout (Codex P2, #15145 r6):
+ # deleted on the branch — dropped, which matches full CI (the file runs nowhere) — or
+ # renamed, where the NEW path is a changed test file and is selected independently. If
+ # NOTHING selected exists, the selection is stale wholesale and the suite runs FULL;
+ # --passWithNoTests must never turn "ran nothing" into green.
+ if [ -n "$SELECTED" ]; then
+ KEEP=""
+ for f in $SELECTED; do
+ if [ -f "$f" ]; then KEEP="$KEEP $f"; else echo "dropping selected path absent at HEAD (deleted or renamed): $f"; fi
+ done
+ KEEP="${KEEP# }"
+ if [ -z "$KEEP" ]; then
+ echo "no selected test file exists at HEAD (stale selection); running FULL"
+ npm run test:ci -- --shard=${{ matrix.shard }}/3 $JEST_JSON
+ else
+ echo "codegraph: $(echo $KEEP | wc -w) selected test files (safe mode)"
+ npm run test:ci -- --shard=${{ matrix.shard }}/3 --passWithNoTests --runTestsByPath $KEEP $JEST_JSON
+ fi
+ else
+ npm run test:ci -- --shard=${{ matrix.shard }}/3 $JEST_JSON
+ fi
+ # Per-test results keyed by head SHA (run.head_sha), for the codegraph test-evidence feed.
+ # Never part of the gate: it cannot fail the job, and a re-run overwrites its own artifact.
+ - name: Upload Jest results
+ if: ${{ !cancelled() }}
+ continue-on-error: true
+ uses: actions/upload-artifact@v6
+ with:
+ name: jest-results-api-${{ matrix.shard }}
+ path: jest-results/
+ retention-days: 7
+ if-no-files-found: ignore
+ overwrite: true
test-data-provider:
name: 'Tests: data-provider'
- needs: build
+ needs: [build, codegraph_select]
+ if: ${{ !cancelled() && needs.build.result == 'success' && needs.codegraph_select.outputs.dataprovider_run != 'false' }}
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
- name: Use Node.js 24.16.0
- uses: actions/setup-node@v4
+ uses: actions/setup-node@v5
with:
node-version: '24.16.0'
- name: Restore node_modules cache
id: cache-node-modules
- uses: actions/cache@v4
+ uses: actions/cache@v5
with:
path: |
node_modules
@@ -315,30 +502,77 @@ jobs:
run: npm ci
- name: Download data-provider build
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v7
with:
name: build-data-provider
path: packages/data-provider/dist
+ # mongodb-memory-server cold-downloads a ~122MB MongoDB binary into
+ # ~/.cache/mongodb-binaries on first use — inside a 15s beforeAll hook, which is a
+ # timeout on a slow mirror day. Every MISSED verdict the codegraph shadow evaluator has
+ # ever recorded (9 across 6 PRs) plus several chronically flaky suites trace to exactly
+ # this download. restore-keys keeps the previous binary warm across lockfile churn; a
+ # genuinely new binary version downloads once and re-saves.
+ - name: Cache MongoDB memory-server binaries
+ uses: actions/cache@v5
+ with:
+ path: ~/.cache/mongodb-binaries
+ key: mongodb-binaries-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
+ restore-keys: |
+ mongodb-binaries-${{ runner.os }}-
- name: Run unit tests
- run: cd packages/data-provider && npm run test:ci
+ env:
+ SELECTED: ${{ needs.codegraph_select.outputs.dataprovider_files }}
+ JEST_JSON: --json --outputFile=${{ github.workspace }}/jest-results/jest-results-data-provider.json
+ run: |
+ mkdir -p "$GITHUB_WORKSPACE/jest-results"
+ cd packages/data-provider
+ if [ -n "$SELECTED" ]; then
+ KEEP=""
+ for f in $SELECTED; do
+ if [ -f "$f" ]; then KEEP="$KEEP $f"; else echo "dropping selected path absent at HEAD (deleted or renamed): $f"; fi
+ done
+ KEEP="${KEEP# }"
+ if [ -z "$KEEP" ]; then
+ echo "no selected test file exists at HEAD (stale selection); running FULL"
+ npm run test:ci -- $JEST_JSON
+ else
+ echo "codegraph: $(echo $KEEP | wc -w) selected test files (safe mode)"
+ npm run test:ci -- --passWithNoTests --runTestsByPath $KEEP $JEST_JSON
+ fi
+ else
+ npm run test:ci -- $JEST_JSON
+ fi
+ # Per-test results keyed by head SHA (run.head_sha), for the codegraph test-evidence feed.
+ # Never part of the gate: it cannot fail the job, and a re-run overwrites its own artifact.
+ - name: Upload Jest results
+ if: ${{ !cancelled() }}
+ continue-on-error: true
+ uses: actions/upload-artifact@v6
+ with:
+ name: jest-results-data-provider
+ path: jest-results/
+ retention-days: 7
+ if-no-files-found: ignore
+ overwrite: true
test-data-schemas:
name: 'Tests: data-schemas'
- needs: build
+ needs: [build, codegraph_select]
+ if: ${{ !cancelled() && needs.build.result == 'success' && needs.codegraph_select.outputs.dataschemas_run != 'false' }}
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
- name: Use Node.js 24.16.0
- uses: actions/setup-node@v4
+ uses: actions/setup-node@v5
with:
node-version: '24.16.0'
- name: Restore node_modules cache
id: cache-node-modules
- uses: actions/cache@v4
+ uses: actions/cache@v5
with:
path: |
node_modules
@@ -353,23 +587,70 @@ jobs:
run: npm ci
- name: Download data-provider build
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v7
with:
name: build-data-provider
path: packages/data-provider/dist
- name: Download data-schemas build
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v7
with:
name: build-data-schemas
path: packages/data-schemas/dist
+ # mongodb-memory-server cold-downloads a ~122MB MongoDB binary into
+ # ~/.cache/mongodb-binaries on first use — inside a 15s beforeAll hook, which is a
+ # timeout on a slow mirror day. Every MISSED verdict the codegraph shadow evaluator has
+ # ever recorded (9 across 6 PRs) plus several chronically flaky suites trace to exactly
+ # this download. restore-keys keeps the previous binary warm across lockfile churn; a
+ # genuinely new binary version downloads once and re-saves.
+ - name: Cache MongoDB memory-server binaries
+ uses: actions/cache@v5
+ with:
+ path: ~/.cache/mongodb-binaries
+ key: mongodb-binaries-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
+ restore-keys: |
+ mongodb-binaries-${{ runner.os }}-
- name: Run unit tests
- run: cd packages/data-schemas && npm run test:ci
+ env:
+ SELECTED: ${{ needs.codegraph_select.outputs.dataschemas_files }}
+ JEST_JSON: --json --outputFile=${{ github.workspace }}/jest-results/jest-results-data-schemas.json
+ run: |
+ mkdir -p "$GITHUB_WORKSPACE/jest-results"
+ cd packages/data-schemas
+ if [ -n "$SELECTED" ]; then
+ KEEP=""
+ for f in $SELECTED; do
+ if [ -f "$f" ]; then KEEP="$KEEP $f"; else echo "dropping selected path absent at HEAD (deleted or renamed): $f"; fi
+ done
+ KEEP="${KEEP# }"
+ if [ -z "$KEEP" ]; then
+ echo "no selected test file exists at HEAD (stale selection); running FULL"
+ npm run test:ci -- $JEST_JSON
+ else
+ echo "codegraph: $(echo $KEEP | wc -w) selected test files (safe mode)"
+ npm run test:ci -- --passWithNoTests --runTestsByPath $KEEP $JEST_JSON
+ fi
+ else
+ npm run test:ci -- $JEST_JSON
+ fi
+ # Per-test results keyed by head SHA (run.head_sha), for the codegraph test-evidence feed.
+ # Never part of the gate: it cannot fail the job, and a re-run overwrites its own artifact.
+ - name: Upload Jest results
+ if: ${{ !cancelled() }}
+ continue-on-error: true
+ uses: actions/upload-artifact@v6
+ with:
+ name: jest-results-data-schemas
+ path: jest-results/
+ retention-days: 7
+ if-no-files-found: ignore
+ overwrite: true
test-packages-api:
name: 'Tests: @librechat/api (shard ${{ matrix.shard }}/4)'
- needs: build
+ needs: [build, codegraph_select]
+ if: ${{ !cancelled() && needs.build.result == 'success' && needs.codegraph_select.outputs.pkgapi_run != 'false' }}
runs-on: ubuntu-latest
# Suite typically completes in ~5 min on a warm runner, but tail-latency
# cancellations have started showing up: tests are actively passing right
@@ -381,16 +662,16 @@ jobs:
matrix:
shard: [1, 2, 3, 4]
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
- name: Use Node.js 24.16.0
- uses: actions/setup-node@v4
+ uses: actions/setup-node@v5
with:
node-version: '24.16.0'
- name: Restore node_modules cache
id: cache-node-modules
- uses: actions/cache@v4
+ uses: actions/cache@v5
with:
path: |
node_modules
@@ -405,22 +686,68 @@ jobs:
run: npm ci
- name: Download data-provider build
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v7
with:
name: build-data-provider
path: packages/data-provider/dist
- name: Download data-schemas build
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v7
with:
name: build-data-schemas
path: packages/data-schemas/dist
- name: Download api build
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v7
with:
name: build-api
path: packages/api/dist
+ # mongodb-memory-server cold-downloads a ~122MB MongoDB binary into
+ # ~/.cache/mongodb-binaries on first use — inside a 15s beforeAll hook, which is a
+ # timeout on a slow mirror day. Every MISSED verdict the codegraph shadow evaluator has
+ # ever recorded (9 across 6 PRs) plus several chronically flaky suites trace to exactly
+ # this download. restore-keys keeps the previous binary warm across lockfile churn; a
+ # genuinely new binary version downloads once and re-saves.
+ - name: Cache MongoDB memory-server binaries
+ uses: actions/cache@v5
+ with:
+ path: ~/.cache/mongodb-binaries
+ key: mongodb-binaries-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
+ restore-keys: |
+ mongodb-binaries-${{ runner.os }}-
- name: Run unit tests (shard ${{ matrix.shard }}/4)
- run: cd packages/api && npm run test:ci -- --shard=${{ matrix.shard }}/4
+ env:
+ SELECTED: ${{ needs.codegraph_select.outputs.pkgapi_files }}
+ JEST_JSON: --json --outputFile=${{ github.workspace }}/jest-results/jest-results-packages-api-${{ matrix.shard }}.json
+ run: |
+ mkdir -p "$GITHUB_WORKSPACE/jest-results"
+ cd packages/api
+ if [ -n "$SELECTED" ]; then
+ KEEP=""
+ for f in $SELECTED; do
+ if [ -f "$f" ]; then KEEP="$KEEP $f"; else echo "dropping selected path absent at HEAD (deleted or renamed): $f"; fi
+ done
+ KEEP="${KEEP# }"
+ if [ -z "$KEEP" ]; then
+ echo "no selected test file exists at HEAD (stale selection); running FULL"
+ npm run test:ci -- --shard=${{ matrix.shard }}/4 $JEST_JSON
+ else
+ echo "codegraph: $(echo $KEEP | wc -w) selected test files (safe mode)"
+ npm run test:ci -- --shard=${{ matrix.shard }}/4 --passWithNoTests --runTestsByPath $KEEP $JEST_JSON
+ fi
+ else
+ npm run test:ci -- --shard=${{ matrix.shard }}/4 $JEST_JSON
+ fi
+ # Per-test results keyed by head SHA (run.head_sha), for the codegraph test-evidence feed.
+ # Never part of the gate: it cannot fail the job, and a re-run overwrites its own artifact.
+ - name: Upload Jest results
+ if: ${{ !cancelled() }}
+ continue-on-error: true
+ uses: actions/upload-artifact@v6
+ with:
+ name: jest-results-packages-api-${{ matrix.shard }}
+ path: jest-results/
+ retention-days: 7
+ if-no-files-found: ignore
+ overwrite: true
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
deleted file mode 100644
index 9210b80a93d..00000000000
--- a/.github/workflows/build.yml
+++ /dev/null
@@ -1,41 +0,0 @@
-name: Linux_Container_Workflow
-
-on:
- workflow_dispatch:
-
-permissions:
- contents: read
-
-env:
- RUNNER_VERSION: 2.293.0
-
-jobs:
- build-and-push:
- runs-on: ubuntu-latest
- steps:
- # checkout the repo
- - name: 'Checkout GitHub Action'
- uses: actions/checkout@v4
-
- - name: 'Login via Azure CLI'
- uses: azure/login@v2
- with:
- creds: ${{ secrets.AZURE_CREDENTIALS }}
-
- - name: 'Build GitHub Runner container image'
- uses: docker/login-action@v3
- with:
- registry: ${{ secrets.REGISTRY_LOGIN_SERVER }}
- username: ${{ secrets.REGISTRY_USERNAME }}
- password: ${{ secrets.REGISTRY_PASSWORD }}
- - run: |
- docker build --build-arg RUNNER_VERSION=${{ env.RUNNER_VERSION }} -t ${{ secrets.REGISTRY_LOGIN_SERVER }}/pwd9000-github-runner-lin:${{ env.RUNNER_VERSION }} .
-
- - name: 'Push container image to ACR'
- uses: docker/login-action@v3
- with:
- registry: ${{ secrets.REGISTRY_LOGIN_SERVER }}
- username: ${{ secrets.REGISTRY_USERNAME }}
- password: ${{ secrets.REGISTRY_PASSWORD }}
- - run: |
- docker push ${{ secrets.REGISTRY_LOGIN_SERVER }}/pwd9000-github-runner-lin:${{ env.RUNNER_VERSION }}
diff --git a/.github/workflows/cache-integration-tests.yml b/.github/workflows/cache-integration-tests.yml
index 1a70e4b6b0e..9634569cf5b 100644
--- a/.github/workflows/cache-integration-tests.yml
+++ b/.github/workflows/cache-integration-tests.yml
@@ -7,17 +7,28 @@ on:
- dev
- dev-staging
- release/*
+ # The tested modules import across packages/api (e.g. mcp/oauth pulls in
+ # flow/manager) and consume built data-provider and data-schemas, so the
+ # whole src trees must trigger — subdirectory filters silently skip
+ # regressions in imported files.
paths:
- - 'packages/api/src/cache/**'
- - 'packages/api/src/cluster/**'
- - 'packages/api/src/mcp/**'
- - 'packages/api/src/stream/**'
+ - 'packages/api/src/**'
+ - 'packages/api/package.json'
+ - 'packages/data-provider/src/**'
+ - 'packages/data-provider/package.json'
+ - 'packages/data-schemas/src/**'
+ - 'packages/data-schemas/package.json'
- 'redis-config/**'
- '.github/workflows/cache-integration-tests.yml'
+ - '!**.md'
permissions:
contents: read
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
jobs:
cache_integration_tests:
name: Integration Tests that use actual Redis Cache
@@ -26,17 +37,37 @@ jobs:
steps:
- name: Checkout repository
- uses: actions/checkout@v4
+ uses: actions/checkout@v5
- name: Use Node.js 24.16.0
- uses: actions/setup-node@v4
+ uses: actions/setup-node@v5
with:
node-version: '24.16.0'
- name: Install Redis tools
+ timeout-minutes: 10
run: |
- sudo apt-get update
- sudo apt-get install -y redis-server redis-tools
+ # Same runner apt contention that broke the MCP job in
+ # playwright-mock.yml: apt-daily/unattended-upgrades hold
+ # /var/lib/apt/lists/lock at boot. Without a step timeout this hung
+ # until the job-level one fired, taking the whole leg with it.
+ sudo systemctl stop apt-daily.service apt-daily-upgrade.service \
+ unattended-upgrades.service 2>/dev/null || true
+ sudo systemctl kill --kill-who=all apt-daily.service \
+ apt-daily-upgrade.service 2>/dev/null || true
+
+ apt_with_lock_wait() {
+ for attempt in $(seq 1 30); do
+ if sudo apt-get -o DPkg::Lock::Timeout=60 "$@"; then
+ return 0
+ fi
+ echo "apt-get $1 could not take the lock (attempt ${attempt}/30), retrying"
+ sleep 10
+ done
+ return 1
+ }
+ apt_with_lock_wait update
+ apt_with_lock_wait install -y redis-server redis-tools
- name: Start Single Redis Instance
run: |
@@ -58,7 +89,7 @@ jobs:
- name: Restore node_modules cache
id: cache-node-modules
- uses: actions/cache@v4
+ uses: actions/cache@v5
with:
path: |
node_modules
@@ -74,10 +105,10 @@ jobs:
- name: Restore data-provider build cache
id: cache-data-provider
- uses: actions/cache@v4
+ uses: actions/cache@v5
with:
path: packages/data-provider/dist
- key: build-data-provider-${{ runner.os }}-${{ hashFiles('packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }}
+ key: build-data-provider-${{ runner.os }}-${{ hashFiles('package.json', 'package-lock.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }}
- name: Build data-provider
if: steps.cache-data-provider.outputs.cache-hit != 'true'
@@ -85,10 +116,10 @@ jobs:
- name: Restore data-schemas build cache
id: cache-data-schemas
- uses: actions/cache@v4
+ uses: actions/cache@v5
with:
path: packages/data-schemas/dist
- key: build-data-schemas-${{ runner.os }}-${{ hashFiles('packages/data-schemas/src/**', 'packages/data-schemas/tsconfig*.json', 'packages/data-schemas/tsdown.config.mjs', 'packages/data-schemas/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }}
+ key: build-data-schemas-${{ runner.os }}-${{ hashFiles('package.json', 'package-lock.json', 'packages/data-schemas/src/**', 'packages/data-schemas/tsconfig*.json', 'packages/data-schemas/tsdown.config.mjs', 'packages/data-schemas/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }}
- name: Build data-schemas
if: steps.cache-data-schemas.outputs.cache-hit != 'true'
@@ -96,10 +127,10 @@ jobs:
- name: Restore api build cache
id: cache-api
- uses: actions/cache@v4
+ uses: actions/cache@v5
with:
path: packages/api/dist
- key: build-api-${{ runner.os }}-${{ hashFiles('packages/api/src/**', 'packages/api/tsconfig*.json', 'packages/api/tsdown.config.mjs', 'packages/api/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json', 'packages/data-schemas/src/**', 'packages/data-schemas/tsconfig*.json', 'packages/data-schemas/tsdown.config.mjs', 'packages/data-schemas/package.json') }}
+ key: build-api-${{ runner.os }}-${{ hashFiles('package.json', 'package-lock.json', 'packages/api/src/**', 'packages/api/tsconfig*.json', 'packages/api/tsdown.config.mjs', 'packages/api/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json', 'packages/data-schemas/src/**', 'packages/data-schemas/tsconfig*.json', 'packages/data-schemas/tsdown.config.mjs', 'packages/data-schemas/package.json') }}
- name: Build api
if: steps.cache-api.outputs.cache-hit != 'true'
diff --git a/.github/workflows/client.yml b/.github/workflows/client.yml
index e4dc8c56264..5cf3aad9575 100644
--- a/.github/workflows/client.yml
+++ b/.github/workflows/client.yml
@@ -22,10 +22,10 @@ jobs:
outputs:
skip: ${{ steps.check.outputs.skip }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
- name: Use Node.js
- uses: actions/setup-node@v4
+ uses: actions/setup-node@v5
with:
node-version: '24.16.0'
@@ -58,7 +58,7 @@ jobs:
- name: Upload package
if: steps.check.outputs.skip != 'true'
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v6
with:
name: librechat-client-package
path: npm-package/*.tgz
@@ -75,7 +75,7 @@ jobs:
id-token: write # Required for OIDC trusted publishing
steps:
- name: Use Node.js
- uses: actions/setup-node@v4
+ uses: actions/setup-node@v5
with:
node-version: '24.16.0'
registry-url: 'https://registry.npmjs.org'
@@ -84,7 +84,7 @@ jobs:
run: npm install -g npm@11.14.1 --ignore-scripts
- name: Download package
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v7
with:
name: librechat-client-package
path: npm-package
diff --git a/.github/workflows/codegraph-e2e-votes.yml b/.github/workflows/codegraph-e2e-votes.yml
new file mode 100644
index 00000000000..251c7a9a716
--- /dev/null
+++ b/.github/workflows/codegraph-e2e-votes.yml
@@ -0,0 +1,207 @@
+# Codegraph e2e VOTES — observe-only, post-merge, time-boxed.
+#
+# Playwright never runs on pushes to dev, so evidence for the e2e skip election would
+# otherwise wait on rare organic PR spec failures. This workflow runs the FULL mock suite on
+# every merge: each run is one graduation trial for every spec it executes, and doubles as
+# the post-merge safety net the jest workflows already have via their dev-push triggers.
+#
+# It previously ran only the merged PR's skippable tier, passing the tier as CLI path
+# filters. playwright.config.mock.ts scopes discovery to testDir specs/mock/, so tier
+# entries outside that directory matched nothing — and the covered-list log line still
+# claimed them, minting graduation trials for specs that never executed (run 32701691037:
+# a11y/keys/messages in the covered list, zero of their tests run). The covered list below
+# is therefore derived from the run's EXECUTED results — discovery is not enough either,
+# since env-gated suites self-skip under this job's default env — and the run takes no
+# path filters at all.
+#
+# It cannot fail the branch: the test step is continue-on-error. The newest merge cancels
+# older vote runs. The whole campaign switches off by setting repo variable
+# CODEGRAPH_E2E_VOTES=off once the election passes.
+name: Codegraph E2E Votes
+
+on:
+ push:
+ branches:
+ - dev
+ paths:
+ - '**'
+ - '!**.md'
+ - '!.github/workflows/**'
+ - '.github/workflows/codegraph-e2e-votes.yml'
+
+permissions:
+ contents: read
+
+concurrency:
+ group: codegraph-e2e-votes
+ cancel-in-progress: true
+
+env:
+ NODE_OPTIONS: '--max-old-space-size=6144'
+ PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1'
+
+jobs:
+ vote:
+ name: vote (full suite)
+ if: vars.CODEGRAPH_E2E_VOTES != 'off'
+ runs-on: ubuntu-latest
+ timeout-minutes: 45
+ env:
+ CI: 'true'
+ E2E_CHROMIUM_CHANNEL: chrome
+ E2E_STREAM_STORE: memory
+ steps:
+ - uses: actions/checkout@v5
+
+ - name: Use Node.js 24.16.0
+ uses: actions/setup-node@v5
+ with:
+ node-version: '24.16.0'
+
+ - name: Restore node_modules cache
+ id: cache-node-modules
+ uses: actions/cache@v5
+ with:
+ path: |
+ node_modules
+ client/node_modules
+ packages/client/node_modules
+ packages/data-provider/node_modules
+ packages/data-schemas/node_modules
+ packages/api/node_modules
+ api/node_modules
+ key: node-modules-e2e-${{ runner.os }}-24.16.0-${{ hashFiles('package-lock.json') }}
+
+ - name: Install dependencies
+ if: steps.cache-node-modules.outputs.cache-hit != 'true'
+ run: npm ci
+
+ - name: Restore data-provider build cache
+ id: cache-data-provider
+ uses: actions/cache@v5
+ with:
+ path: packages/data-provider/dist
+ key: build-data-provider-${{ runner.os }}-${{ hashFiles('package.json', 'package-lock.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }}
+
+ - name: Build data-provider
+ if: steps.cache-data-provider.outputs.cache-hit != 'true'
+ run: npm run build:data-provider
+
+ - name: Restore data-schemas build cache
+ id: cache-data-schemas
+ uses: actions/cache@v5
+ with:
+ path: packages/data-schemas/dist
+ key: build-data-schemas-${{ runner.os }}-${{ hashFiles('package.json', 'package-lock.json', 'packages/data-schemas/src/**', 'packages/data-schemas/tsconfig*.json', 'packages/data-schemas/tsdown.config.mjs', 'packages/data-schemas/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }}
+
+ - name: Build data-schemas
+ if: steps.cache-data-schemas.outputs.cache-hit != 'true'
+ run: npm run build:data-schemas
+
+ - name: Restore api build cache
+ id: cache-api
+ uses: actions/cache@v5
+ with:
+ path: packages/api/dist
+ key: build-api-${{ runner.os }}-${{ hashFiles('package.json', 'package-lock.json', 'packages/api/src/**', 'packages/api/tsconfig*.json', 'packages/api/tsdown.config.mjs', 'packages/api/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json', 'packages/data-schemas/src/**', 'packages/data-schemas/tsconfig*.json', 'packages/data-schemas/tsdown.config.mjs', 'packages/data-schemas/package.json') }}
+
+ - name: Build api
+ if: steps.cache-api.outputs.cache-hit != 'true'
+ run: npm run build:api
+
+ - name: Restore client-package build cache
+ id: cache-client-package
+ uses: actions/cache@v5
+ with:
+ path: packages/client/dist
+ key: build-client-package-${{ runner.os }}-${{ hashFiles('package.json', 'package-lock.json', 'packages/client/src/**', 'packages/client/tsconfig*.json', 'packages/client/tsdown.config.mjs', 'packages/client/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }}
+
+ - name: Build client-package
+ if: steps.cache-client-package.outputs.cache-hit != 'true'
+ run: npm run build:client-package
+
+ - name: Restore client app build cache
+ id: cache-client-app
+ uses: actions/cache@v5
+ with:
+ path: client/dist
+ key: build-client-app-e2e-${{ runner.os }}-${{ hashFiles('package.json', 'package-lock.json', 'client/src/**', 'client/public/**', 'client/index.html', 'client/package.json', 'client/vite.config.*', 'client/tsconfig*.json', 'client/tailwind.config.*', 'client/postcss.config.*', 'packages/client/src/**', 'packages/client/tailwind.preset.cjs', 'packages/client/tsconfig*.json', 'packages/client/tsdown.config.mjs', 'packages/client/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }}
+
+ - name: Build client app
+ if: steps.cache-client-app.outputs.cache-hit != 'true'
+ run: npm run build:client
+
+ - name: Verify Chrome is present
+ run: google-chrome --version
+
+ # ffmpeg for retry video — see the note in playwright-mock.yml.
+ - name: Resolve Playwright version
+ id: playwright-version
+ run: |
+ version=$(node -p "require('./package-lock.json').packages['node_modules/playwright-core'].version")
+ echo "version=${version}" >> "$GITHUB_OUTPUT"
+
+ - name: Restore Playwright ffmpeg cache
+ id: cache-ffmpeg
+ uses: actions/cache/restore@v5
+ with:
+ path: ~/.cache/ms-playwright
+ key: playwright-ffmpeg-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}
+
+ - name: Install Playwright ffmpeg (best effort)
+ id: install-ffmpeg
+ if: steps.cache-ffmpeg.outputs.cache-hit != 'true'
+ timeout-minutes: 3
+ continue-on-error: true
+ run: |
+ timeout -k 10 60 npx playwright install ffmpeg
+ .github/scripts/verify-playwright-ffmpeg.sh
+
+ - name: Save Playwright ffmpeg cache
+ if: steps.install-ffmpeg.outcome == 'success'
+ continue-on-error: true
+ uses: actions/cache/save@v5
+ with:
+ path: ~/.cache/ms-playwright
+ key: playwright-ffmpeg-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}
+
+ # Optional fonts only — see the note in playwright-mock.yml.
+ - name: Install optional Playwright font dependencies (best effort)
+ timeout-minutes: 4
+ continue-on-error: true
+ run: .github/scripts/install-playwright-fonts.sh
+
+ - name: Vote — run the full mock suite (cannot fail the branch)
+ continue-on-error: true
+ env:
+ # Absolute on purpose: Playwright resolves a relative PLAYWRIGHT_JSON_OUTPUT_NAME
+ # against the CONFIG directory (e2e/), not the working directory — the first live run
+ # wrote e2e/pw-results.json while the ledger looked in the repo root and logged zero
+ # trials (fail-safe, but a silent no-op).
+ PLAYWRIGHT_JSON_OUTPUT_NAME: ${{ github.workspace }}/pw-results.json
+ run: npx playwright test --config=e2e/playwright.config.mock.ts --reporter=line,json
+
+ - name: Ledger — log the specs that actually executed
+ run: |
+ set +e
+ # The shadow's per-spec graduation ledger counts a clean trial for every spec a green
+ # run covered, so the covered list must come from EXECUTED tests, not from discovery:
+ # env-gated suites (mcp-tool-list-changed needs E2E_MCP_LIST_CHANGED, enforced-model-
+ # specs needs E2E_MODEL_SPECS_ENFORCE) are discovered by --list yet skip every test
+ # under this job's default env — counting them as covered would mint phantom trials,
+ # the exact bug this workflow revision exists to kill (Codex P1 on #15162). A spec is
+ # covered iff at least one of its tests reached a non-skipped outcome.
+ if jq -e '.suites' "$GITHUB_WORKSPACE/pw-results.json" >/dev/null 2>&1; then
+ jq -r '[.suites[] | recurse(.suites[]?) | .specs[]? | select([.tests[]?.status] | any(. != "skipped")) | .file] | unique | .[]' "$GITHUB_WORKSPACE/pw-results.json" \
+ | sed 's|^|specs/mock/|' > covered.txt
+ N=$(wc -l < covered.txt | tr -d ' ')
+ echo "codegraph-votes: running $N specs (executed, full suite)"
+ if [ "$N" != "0" ]; then echo "codegraph-votes-specs: $(tr '\n' ' ' < covered.txt)"; fi
+ else
+ echo "codegraph-votes: no results json — run crashed before reporting; no trials logged"
+ fi
+ exit 0
+
+ - name: Done
+ if: always()
+ run: 'echo "codegraph-votes: complete"'
diff --git a/.github/workflows/codegraph-select.yml b/.github/workflows/codegraph-select.yml
new file mode 100644
index 00000000000..9257c4bac1d
--- /dev/null
+++ b/.github/workflows/codegraph-select.yml
@@ -0,0 +1,98 @@
+# Codegraph test selection — OBSERVE-ONLY.
+#
+# Asks the codegraph service which test files / matrix jobs this PR actually needs and writes
+# the answer to the job summary. It gates NOTHING: no workflow reads its outputs yet, it cannot
+# fail the PR (every path exits 0), and forks without secrets no-op silently. This is the
+# production probe for the shadow-mode evaluation: the same decision CI would act on, made
+# visible next to the runs it would have replaced.
+#
+# Requires repo secrets: CODEGRAPH_URL (https endpoint), CODEGRAPH_TOKEN (bearer).
+name: Codegraph Select (observe)
+
+on:
+ pull_request:
+ types: [opened, synchronize, reopened]
+
+permissions:
+ contents: read
+ pull-requests: read
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ select:
+ runs-on: ubuntu-latest
+ timeout-minutes: 3
+ steps:
+ - name: Ask codegraph, render, never fail
+ env:
+ URL: ${{ secrets.CODEGRAPH_URL }}
+ TOKEN: ${{ secrets.CODEGRAPH_TOKEN }}
+ GH_TOKEN: ${{ github.token }}
+ REPO: ${{ github.repository }}
+ PR: ${{ github.event.pull_request.number }}
+ BASE_SHA: ${{ github.event.pull_request.base.sha }}
+ HEAD_SHA: ${{ github.event.pull_request.head.sha }}
+ CHANGED: ${{ github.event.pull_request.changed_files }}
+ run: |
+ set +e
+ note() { echo "$1" >> "$GITHUB_STEP_SUMMARY"; }
+ note "### Codegraph select — observe-only"
+ if [ -z "$URL" ] || [ -z "$TOKEN" ]; then note "_secrets not configured; skipped_"; exit 0; fi
+
+ # A failed or truncated page must not become a shorter file list: the pipeline would hide
+ # gh's exit status behind jq, and a partial list can turn a required lane off. Check the
+ # fetch status AND the count against the PR's own changed_files (Codex P1, #15136).
+ if ! gh api "repos/$REPO/pulls/$PR/files" --paginate \
+ --jq '.[] | {path: .filename, status, patch}' > files.ndjson; then
+ note "_could not fetch changed files; skipped_"; exit 0
+ fi
+ jq -s . files.ndjson > files.json
+ N=$(jq 'length' files.json)
+ if [ "$N" -eq 0 ] || { [ -n "$CHANGED" ] && [ "$N" -ne "$CHANGED" ]; }; then
+ note "_changed-file list incomplete ($N of ${CHANGED:-?}); skipped_"; exit 0
+ fi
+
+ jq -c --arg b "$BASE_SHA" --arg h "$HEAD_SHA" \
+ '{files: ., lockBaseSha: $b, lockHeadSha: $h}' files.json > body.json
+ # curl's status is checked explicitly: a transfer that times out or truncates after a
+ # parseable body must fail open, not be honoured (Codex P1, #15136). --fail-with-body
+ # also turns HTTP errors into a failure while keeping the error text for the summary.
+ RESP=$(curl -sS --fail-with-body -m 45 -H "Authorization: Bearer $TOKEN" \
+ -H 'content-type: application/json' --data-binary @body.json "$URL/v1/select"); RC=$?
+ if [ "$RC" -ne 0 ] || [ -z "$RESP" ] || ! echo "$RESP" | jq -e .selected >/dev/null 2>&1; then
+ note "_codegraph unavailable (curl exit $RC: ${RESP:0:120}); skipped, full CI runs as always_"
+ exit 0
+ fi
+
+ TABLE=$(echo "$RESP" | jq -r '
+ "graph `\(.gate.head[0:12] // "?")` · \(.engine) · \(.mode) · \(.ms)ms · reached \(.reached)",
+ "",
+ "| workspace | decision |",
+ "|---|---|",
+ (.selected | to_entries[] |
+ "| \(.key) | " + (if .value.mode == "FULL" then "FULL — \(.value.why)"
+ elif .value.mode == "NONE" then "no tests"
+ else "\(.value.files | length) test files" end) + " |"),
+ "",
+ "matrix: " + ([.matrix | to_entries[] | .key as $wf | .value | to_entries[] |
+ "\($wf)/\(.key)=" + (if .value then "run" else "SKIP" end)] | join(" ")),
+ (if .shards then "shards: " + (.shards | tojson) else empty end),
+ (if .lock_workspaces then "lockfile → " + (.lock_workspaces | tojson) else empty end),
+ (if .e2e and (.e2e.error | not) then
+ "e2e tiers: must \(.e2e.must_run | length) · floor \(.e2e.floor | length) · skippable \(.e2e.skippable | length)" +
+ (if (.e2e.must_run | length) > 0 then " — must: " + (.e2e.must_run[:4] | join(", ")) else "" end)
+ else empty end)
+ ' 2>render.err)
+ if [ -n "$TABLE" ]; then
+ echo "$TABLE" >> "$GITHUB_STEP_SUMMARY"
+ else
+ note "_summary render failed: $(head -c 200 render.err 2>/dev/null)_"
+ note '~~~'
+ note "${RESP:0:600}"
+ note '~~~'
+ fi
+ echo "rendered summary: ${#TABLE} chars"
+ exit 0
diff --git a/.github/workflows/config-review.yml b/.github/workflows/config-review.yml
deleted file mode 100644
index fc25989aa87..00000000000
--- a/.github/workflows/config-review.yml
+++ /dev/null
@@ -1,88 +0,0 @@
-name: Config Migration Tests
-on:
- pull_request:
- paths:
- - 'config/**'
- - 'api/models/**'
- - 'api/db/**'
- - 'packages/data-schemas/src/**'
- - 'packages/data-provider/src/**'
- - 'packages/api/src/acl/**'
- - 'packages/api/src/shared-links/**'
-
-env:
- NODE_ENV: CI
- NODE_OPTIONS: '--max-old-space-size=${{ secrets.NODE_MAX_OLD_SPACE_SIZE || 6144 }}'
-
-jobs:
- test-config:
- name: 'Tests: config migrations'
- runs-on: ubuntu-latest
- timeout-minutes: 15
- steps:
- - uses: actions/checkout@v4
-
- - name: Use Node.js 24.16.0
- uses: actions/setup-node@v4
- with:
- node-version: '24.16.0'
-
- - name: Restore node_modules cache
- id: cache-node-modules
- uses: actions/cache@v4
- with:
- path: |
- node_modules
- api/node_modules
- packages/api/node_modules
- packages/data-provider/node_modules
- packages/data-schemas/node_modules
- key: node-modules-backend-${{ runner.os }}-20.19-${{ hashFiles('package-lock.json') }}
-
- - name: Install dependencies
- if: steps.cache-node-modules.outputs.cache-hit != 'true'
- run: npm ci
-
- - name: Restore data-provider build cache
- id: cache-data-provider
- uses: actions/cache@v4
- with:
- path: packages/data-provider/dist
- key: build-data-provider-${{ runner.os }}-${{ hashFiles('packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }}
-
- - name: Build data-provider
- if: steps.cache-data-provider.outputs.cache-hit != 'true'
- run: npm run build:data-provider
-
- - name: Restore data-schemas build cache
- id: cache-data-schemas
- uses: actions/cache@v4
- with:
- path: packages/data-schemas/dist
- key: build-data-schemas-${{ runner.os }}-${{ hashFiles('packages/data-schemas/src/**', 'packages/data-schemas/tsconfig*.json', 'packages/data-schemas/tsdown.config.mjs', 'packages/data-schemas/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }}
-
- - name: Build data-schemas
- if: steps.cache-data-schemas.outputs.cache-hit != 'true'
- run: npm run build:data-schemas
-
- - name: Restore api build cache
- id: cache-api
- uses: actions/cache@v4
- with:
- path: packages/api/dist
- key: build-api-${{ runner.os }}-${{ hashFiles('packages/api/src/**', 'packages/api/tsconfig*.json', 'packages/api/tsdown.config.mjs', 'packages/api/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json', 'packages/data-schemas/src/**', 'packages/data-schemas/tsconfig*.json', 'packages/data-schemas/tsdown.config.mjs', 'packages/data-schemas/package.json') }}
-
- - name: Build api
- if: steps.cache-api.outputs.cache-hit != 'true'
- run: npm run build:api
-
- - name: Create empty auth.json file
- run: |
- mkdir -p api/data
- echo '{}' > api/data/auth.json
-
- - name: Prepare .env.test file
- run: cp api/test/.env.test.example api/test/.env.test
-
- - name: Run config migration tests
- run: npm run test:config
diff --git a/.github/workflows/data-provider.yml b/.github/workflows/data-provider.yml
index eae746ece94..65cda55297f 100644
--- a/.github/workflows/data-provider.yml
+++ b/.github/workflows/data-provider.yml
@@ -20,8 +20,8 @@ jobs:
pack:
runs-on: ubuntu-latest
steps:
- - uses: actions/checkout@v4
- - uses: actions/setup-node@v4
+ - uses: actions/checkout@v5
+ - uses: actions/setup-node@v5
with:
node-version: '24.16.0'
- run: cd packages/data-provider && npm ci
@@ -32,7 +32,7 @@ jobs:
cd packages/data-provider
npm pack --pack-destination "$GITHUB_WORKSPACE/npm-package"
- name: Upload package
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v6
with:
name: librechat-data-provider-package
path: npm-package/*.tgz
@@ -48,7 +48,7 @@ jobs:
contents: read
id-token: write # Required for OIDC trusted publishing
steps:
- - uses: actions/setup-node@v4
+ - uses: actions/setup-node@v5
with:
node-version: '24.16.0'
registry-url: 'https://registry.npmjs.org'
@@ -57,7 +57,7 @@ jobs:
run: npm install -g npm@11.14.1 --ignore-scripts
- name: Download package
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v7
with:
name: librechat-data-provider-package
path: npm-package
diff --git a/.github/workflows/data-schemas.yml b/.github/workflows/data-schemas.yml
index bb8f90ea842..18d0dc7cc56 100644
--- a/.github/workflows/data-schemas.yml
+++ b/.github/workflows/data-schemas.yml
@@ -22,10 +22,10 @@ jobs:
outputs:
skip: ${{ steps.check.outputs.skip }}
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
- name: Use Node.js
- uses: actions/setup-node@v4
+ uses: actions/setup-node@v5
with:
node-version: '24.16.0'
@@ -58,7 +58,7 @@ jobs:
- name: Upload package
if: steps.check.outputs.skip != 'true'
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v6
with:
name: librechat-data-schemas-package
path: npm-package/*.tgz
@@ -75,7 +75,7 @@ jobs:
id-token: write # Required for OIDC trusted publishing
steps:
- name: Use Node.js
- uses: actions/setup-node@v4
+ uses: actions/setup-node@v5
with:
node-version: '24.16.0'
registry-url: 'https://registry.npmjs.org'
@@ -84,7 +84,7 @@ jobs:
run: npm install -g npm@11.14.1 --ignore-scripts
- name: Download package
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v7
with:
name: librechat-data-schemas-package
path: npm-package
diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml
deleted file mode 100644
index 57875bc513e..00000000000
--- a/.github/workflows/deploy-dev.yml
+++ /dev/null
@@ -1,49 +0,0 @@
-name: Update Test Server
-
-on:
- workflow_run:
- workflows: ["Docker Dev Branch Images Build"]
- types:
- - completed
- workflow_dispatch:
-
-permissions:
- contents: read
-
-jobs:
- deploy:
- runs-on: ubuntu-latest
- if: |
- github.repository == 'danny-avila/LibreChat' &&
- (github.event_name == 'workflow_dispatch' ||
- (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_branch == 'dev'))
- steps:
- - name: Checkout repository
- uses: actions/checkout@v4
-
- - name: Install SSH Key
- uses: shimataro/ssh-key-action@v2
- with:
- key: ${{ secrets.DO_SSH_PRIVATE_KEY }}
- known_hosts: ${{ secrets.DO_KNOWN_HOSTS }}
-
- - name: Run update script on DigitalOcean Droplet
- env:
- DO_HOST: ${{ secrets.DO_HOST }}
- DO_USER: ${{ secrets.DO_USER }}
- run: |
- ssh ${DO_USER}@${DO_HOST} << EOF
- sudo -i -u danny bash << 'EEOF'
- cd ~/LibreChat && \
- git fetch origin main && \
- sudo npm run stop:deployed && \
- sudo docker images --format "{{.Repository}}:{{.ID}}" | grep -E "lc-dev|librechat" | cut -d: -f2 | xargs -r sudo docker rmi -f || true && \
- sudo npm run update:deployed && \
- git checkout dev && \
- git pull origin dev && \
- git checkout do-deploy && \
- git rebase dev && \
- sudo npm run start:deployed && \
- echo "Update completed. Application should be running now."
- EEOF
- EOF
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
deleted file mode 100644
index e4b73da617a..00000000000
--- a/.github/workflows/deploy.yml
+++ /dev/null
@@ -1,41 +0,0 @@
-name: Deploy_GHRunner_Linux_ACI
-
-on:
- workflow_dispatch:
-
-permissions:
- contents: read
-
-env:
- RUNNER_VERSION: 2.293.0
- ACI_RESOURCE_GROUP: 'Demo-ACI-GitHub-Runners-RG'
- ACI_NAME: 'gh-runner-linux-01'
- DNS_NAME_LABEL: 'gh-lin-01'
- GH_OWNER: ${{ github.repository_owner }}
- GH_REPOSITORY: 'LibreChat' #Change here to deploy self hosted runner ACI to another repo.
-
-jobs:
- deploy-gh-runner-aci:
- runs-on: ubuntu-latest
- steps:
- # checkout the repo
- - name: 'Checkout GitHub Action'
- uses: actions/checkout@v4
-
- - name: 'Login via Azure CLI'
- uses: azure/login@v2
- with:
- creds: ${{ secrets.AZURE_CREDENTIALS }}
-
- - name: 'Deploy to Azure Container Instances'
- uses: 'azure/aci-deploy@v1'
- with:
- resource-group: ${{ env.ACI_RESOURCE_GROUP }}
- image: ${{ secrets.REGISTRY_LOGIN_SERVER }}/pwd9000-github-runner-lin:${{ env.RUNNER_VERSION }}
- registry-login-server: ${{ secrets.REGISTRY_LOGIN_SERVER }}
- registry-username: ${{ secrets.REGISTRY_USERNAME }}
- registry-password: ${{ secrets.REGISTRY_PASSWORD }}
- name: ${{ env.ACI_NAME }}
- dns-name-label: ${{ env.DNS_NAME_LABEL }}
- environment-variables: GH_TOKEN=${{ secrets.PAT_TOKEN }} GH_OWNER=${{ env.GH_OWNER }} GH_REPOSITORY=${{ env.GH_REPOSITORY }}
- location: 'eastus'
diff --git a/.github/workflows/dev-branch-images.yml b/.github/workflows/dev-branch-images.yml
index f0e2ba54b57..2c71cfd7d19 100644
--- a/.github/workflows/dev-branch-images.yml
+++ b/.github/workflows/dev-branch-images.yml
@@ -8,11 +8,21 @@ on:
paths:
- 'api/**'
- 'client/**'
+ - 'config/**'
+ - 'skill/**'
- 'packages/**'
- 'package.json'
- 'package-lock.json'
- 'Dockerfile'
- 'Dockerfile.multi'
+ - '.dockerignore'
+ - '!**.md'
+ # Deployment skills are Markdown the image ships and reads at runtime
+ # (skill//SKILL.md plus md resources); re-include them after the
+ # !**.md exclusion (later patterns win), keeping the top-level README
+ # documentation-only.
+ - 'skill/**/*.md'
+ - '!skill/README.md'
permissions:
contents: read
@@ -23,73 +33,17 @@ concurrency:
cancel-in-progress: true
jobs:
- build:
- runs-on: ubuntu-latest
- timeout-minutes: 130
- strategy:
- matrix:
- include:
- - target: api-build
- file: Dockerfile.multi
- image_name: lc-dev-api
- - target: node
- file: Dockerfile
- image_name: lc-dev
-
- steps:
- # Check out the repository
- - name: Checkout
- uses: actions/checkout@v4
-
- # Set up QEMU
- - name: Set up QEMU
- uses: docker/setup-qemu-action@v3
-
- # Set up Docker Buildx
- - name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v3
-
- # Log in to GitHub Container Registry
- - name: Log in to GitHub Container Registry
- uses: docker/login-action@v3
- with:
- registry: ghcr.io
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
-
- # Login to Docker Hub
- - name: Login to Docker Hub
- uses: docker/login-action@v3
- with:
- username: ${{ secrets.DOCKERHUB_USERNAME }}
- password: ${{ secrets.DOCKERHUB_TOKEN }}
-
- # Prepare the environment
- - name: Prepare environment
- run: |
- cp .env.example .env
-
- - name: Compute build metadata
- run: |
- echo "BUILD_COMMIT=${{ github.sha }}" >> $GITHUB_ENV
- echo "BUILD_BRANCH=${{ github.ref_name }}" >> $GITHUB_ENV
- echo "BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_ENV
-
- # Build and push Docker images for each target
- - name: Build and push Docker images
- uses: docker/build-push-action@v5
- with:
- context: .
- file: ${{ matrix.file }}
- push: true
- tags: |
- ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}:${{ github.sha }}
- ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}:latest
- ${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}:${{ github.sha }}
- ${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}:latest
- platforms: linux/amd64,linux/arm64
- target: ${{ matrix.target }}
- build-args: |
- BUILD_COMMIT=${{ env.BUILD_COMMIT }}
- BUILD_BRANCH=${{ env.BUILD_BRANCH }}
- BUILD_DATE=${{ env.BUILD_DATE }}
+ publish:
+ uses: ./.github/workflows/docker-publish.yml
+ with:
+ images: >-
+ [{"target":"api-build","file":"Dockerfile.multi","image_name":"lc-dev-api"},
+ {"target":"node","file":"Dockerfile","image_name":"lc-dev"}]
+ tag_suffixes: |
+ ${{ github.sha }}
+ latest
+ build_branch: ${{ github.ref_name }}
+ secrets:
+ DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
+ DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
+ LEGACY_GHCR_TOKEN: ${{ secrets.LEGACY_GHCR_TOKEN }}
diff --git a/.github/workflows/dev-images.yml b/.github/workflows/dev-images.yml
index efdd2027546..a8b819ae72a 100644
--- a/.github/workflows/dev-images.yml
+++ b/.github/workflows/dev-images.yml
@@ -8,84 +8,38 @@ on:
paths:
- 'api/**'
- 'client/**'
+ - 'config/**'
+ - 'skill/**'
- 'packages/**'
- 'package.json'
- 'package-lock.json'
- 'Dockerfile'
- 'Dockerfile.multi'
+ - '.dockerignore'
+ - '!**.md'
+ # Deployment skills are Markdown the image ships and reads at runtime
+ # (skill//SKILL.md plus md resources); re-include them after the
+ # !**.md exclusion (later patterns win), keeping the top-level README
+ # documentation-only.
+ - 'skill/**/*.md'
+ - '!skill/README.md'
permissions:
contents: read
packages: write
jobs:
- build:
- runs-on: ubuntu-latest
- timeout-minutes: 130
- strategy:
- matrix:
- include:
- - target: api-build
- file: Dockerfile.multi
- image_name: librechat-dev-api
- - target: node
- file: Dockerfile
- image_name: librechat-dev
-
- steps:
- # Check out the repository
- - name: Checkout
- uses: actions/checkout@v4
-
- # Set up QEMU
- - name: Set up QEMU
- uses: docker/setup-qemu-action@v3
-
- # Set up Docker Buildx
- - name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v3
-
- # Log in to GitHub Container Registry
- - name: Log in to GitHub Container Registry
- uses: docker/login-action@v3
- with:
- registry: ghcr.io
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
-
- # Login to Docker Hub
- - name: Login to Docker Hub
- uses: docker/login-action@v3
- with:
- username: ${{ secrets.DOCKERHUB_USERNAME }}
- password: ${{ secrets.DOCKERHUB_TOKEN }}
-
- # Prepare the environment
- - name: Prepare environment
- run: |
- cp .env.example .env
-
- - name: Compute build metadata
- run: |
- echo "BUILD_COMMIT=${{ github.sha }}" >> $GITHUB_ENV
- echo "BUILD_BRANCH=${{ github.ref_name }}" >> $GITHUB_ENV
- echo "BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_ENV
-
- # Build and push Docker images for each target
- - name: Build and push Docker images
- uses: docker/build-push-action@v5
- with:
- context: .
- file: ${{ matrix.file }}
- push: true
- tags: |
- ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}:${{ github.sha }}
- ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}:latest
- ${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}:${{ github.sha }}
- ${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}:latest
- platforms: linux/amd64,linux/arm64
- target: ${{ matrix.target }}
- build-args: |
- BUILD_COMMIT=${{ env.BUILD_COMMIT }}
- BUILD_BRANCH=${{ env.BUILD_BRANCH }}
- BUILD_DATE=${{ env.BUILD_DATE }}
+ publish:
+ uses: ./.github/workflows/docker-publish.yml
+ with:
+ images: >-
+ [{"target":"api-build","file":"Dockerfile.multi","image_name":"librechat-dev-api"},
+ {"target":"node","file":"Dockerfile","image_name":"librechat-dev"}]
+ tag_suffixes: |
+ ${{ github.sha }}
+ latest
+ build_branch: ${{ github.ref_name }}
+ secrets:
+ DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
+ DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
+ LEGACY_GHCR_TOKEN: ${{ secrets.LEGACY_GHCR_TOKEN }}
diff --git a/.github/workflows/dev-staging-images.yml b/.github/workflows/dev-staging-images.yml
index 6deb86205ca..18e13cc920f 100644
--- a/.github/workflows/dev-staging-images.yml
+++ b/.github/workflows/dev-staging-images.yml
@@ -8,72 +8,17 @@ permissions:
packages: write
jobs:
- build:
- runs-on: ubuntu-latest
- strategy:
- matrix:
- include:
- - target: api-build
- file: Dockerfile.multi
- image_name: lc-dev-staging-api
- - target: node
- file: Dockerfile
- image_name: lc-dev-staging
-
- steps:
- # Check out the repository
- - name: Checkout
- uses: actions/checkout@v4
-
- # Set up QEMU
- - name: Set up QEMU
- uses: docker/setup-qemu-action@v3
-
- # Set up Docker Buildx
- - name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v3
-
- # Log in to GitHub Container Registry
- - name: Log in to GitHub Container Registry
- uses: docker/login-action@v3
- with:
- registry: ghcr.io
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
-
- # Login to Docker Hub
- - name: Login to Docker Hub
- uses: docker/login-action@v3
- with:
- username: ${{ secrets.DOCKERHUB_USERNAME }}
- password: ${{ secrets.DOCKERHUB_TOKEN }}
-
- # Prepare the environment
- - name: Prepare environment
- run: |
- cp .env.example .env
-
- - name: Compute build metadata
- run: |
- echo "BUILD_COMMIT=${{ github.sha }}" >> $GITHUB_ENV
- echo "BUILD_BRANCH=${{ github.ref_name }}" >> $GITHUB_ENV
- echo "BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_ENV
-
- # Build and push Docker images for each target
- - name: Build and push Docker images
- uses: docker/build-push-action@v5
- with:
- context: .
- file: ${{ matrix.file }}
- push: true
- tags: |
- ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}:${{ github.sha }}
- ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}:latest
- ${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}:${{ github.sha }}
- ${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}:latest
- platforms: linux/amd64,linux/arm64
- target: ${{ matrix.target }}
- build-args: |
- BUILD_COMMIT=${{ env.BUILD_COMMIT }}
- BUILD_BRANCH=${{ env.BUILD_BRANCH }}
- BUILD_DATE=${{ env.BUILD_DATE }}
+ publish:
+ uses: ./.github/workflows/docker-publish.yml
+ with:
+ images: >-
+ [{"target":"api-build","file":"Dockerfile.multi","image_name":"lc-dev-staging-api"},
+ {"target":"node","file":"Dockerfile","image_name":"lc-dev-staging"}]
+ tag_suffixes: |
+ ${{ github.sha }}
+ latest
+ build_branch: ${{ github.ref_name }}
+ secrets:
+ DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
+ DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
+ LEGACY_GHCR_TOKEN: ${{ secrets.LEGACY_GHCR_TOKEN }}
diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml
new file mode 100644
index 00000000000..2ec0776a1f6
--- /dev/null
+++ b/.github/workflows/docker-publish.yml
@@ -0,0 +1,289 @@
+name: Reusable Docker Publish
+
+# Builds each image once per architecture on a runner of that architecture, pushes
+# untagged digest-addressed manifests, then merges the digests into multi-platform
+# manifest lists. Building linux/arm64 on an amd64 runner needs QEMU, which measured
+# 5-6x slower than native on these images (npm ci 52.6s -> 333.2s, npm run frontend
+# 90.4s -> 442.8s) and made the emulated leg ~84% of the build.
+#
+# Every image-publishing workflow calls this, so a fix here reaches all of them. The
+# prefix-glob bug in #15446 was one artifact-name expression that would otherwise have
+# needed correcting in five separate files.
+
+on:
+ workflow_call:
+ inputs:
+ images:
+ description: >-
+ JSON array of objects with `target`, `file` and `image_name`, e.g.
+ [{"target":"node","file":"Dockerfile","image_name":"lc-dev"}]
+ required: true
+ type: string
+ tag_suffixes:
+ description: >-
+ Newline-separated tags applied to every image in every registry, e.g.
+ "abc1234\nlatest". Blank lines are ignored.
+ required: true
+ type: string
+ arches:
+ description: JSON array of architectures to build.
+ type: string
+ default: '["amd64","arm64"]'
+ checkout_ref:
+ description: Ref to build. Defaults to the ref that triggered the caller.
+ type: string
+ default: ''
+ build_branch:
+ description: Value for the BUILD_BRANCH build-arg and runtime env.
+ type: string
+ default: ''
+ build_timeout_minutes:
+ type: number
+ default: 90
+ secrets:
+ DOCKERHUB_USERNAME:
+ required: true
+ DOCKERHUB_TOKEN:
+ required: true
+ LEGACY_GHCR_TOKEN:
+ description: >-
+ Token that can write to the LEGACY_GHCR_OWNER namespace. Without it the
+ mirror step is skipped.
+ required: false
+
+permissions:
+ contents: read
+ packages: write
+
+jobs:
+ build:
+ name: Build ${{ matrix.image.image_name }} (${{ matrix.arch }})
+ runs-on: ${{ matrix.arch == 'arm64' && 'ubuntu-24.04-arm' || 'ubuntu-latest' }}
+ timeout-minutes: ${{ inputs.build_timeout_minutes }}
+ strategy:
+ fail-fast: false
+ matrix:
+ image: ${{ fromJSON(inputs.images) }}
+ arch: ${{ fromJSON(inputs.arches) }}
+
+ steps:
+ # Falls back to the caller's commit SHA rather than an empty ref, so every
+ # build leg of a run checks out the same immutable commit even if the
+ # branch advances mid-run.
+ - name: Checkout
+ uses: actions/checkout@v5
+ with:
+ ref: ${{ inputs.checkout_ref || github.sha }}
+
+ # No QEMU: the runner is already the target architecture.
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v4
+
+ - name: Log in to GitHub Container Registry
+ uses: docker/login-action@v4
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Login to Docker Hub
+ uses: docker/login-action@v4
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ - name: Prepare environment
+ run: cp .env.example .env
+
+ # Read from the checkout rather than github.sha, so a caller that builds a
+ # specific ref (main-image-workflow) stamps that ref's commit. Registry names
+ # must be lowercase, and the repository owner is not guaranteed to be.
+ - name: Compute build metadata
+ env:
+ BUILD_BRANCH_INPUT: ${{ inputs.build_branch }}
+ IMAGE_NAME: ${{ matrix.image.image_name }}
+ run: |
+ set -euo pipefail
+ printf 'GHCR_IMAGE=ghcr.io/%s/%s\n' "${GITHUB_REPOSITORY_OWNER,,}" "$IMAGE_NAME" >> "$GITHUB_ENV"
+ printf 'BUILD_COMMIT=%s\n' "$(git rev-parse HEAD)" >> "$GITHUB_ENV"
+ printf 'BUILD_BRANCH=%s\n' "$BUILD_BRANCH_INPUT" >> "$GITHUB_ENV"
+ printf 'BUILD_DATE=%s\n' "$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> "$GITHUB_ENV"
+
+ # The layer cache lives in GHCR beside the image: no 10GB Actions-cache cap,
+ # and readable from every branch and workflow, unlike type=gha which is
+ # branch-scoped. The ref is per-architecture so the platform jobs cannot
+ # clobber each other's cache manifest.
+ - name: Build and push by digest
+ id: build
+ uses: docker/build-push-action@v7
+ with:
+ context: .
+ file: ${{ matrix.image.file }}
+ platforms: linux/${{ matrix.arch }}
+ target: ${{ matrix.image.target }}
+ cache-from: type=registry,ref=${{ env.GHCR_IMAGE }}:buildcache-${{ matrix.arch }}
+ cache-to: type=registry,ref=${{ env.GHCR_IMAGE }}:buildcache-${{ matrix.arch }},mode=max
+ outputs: type=image,"name=${{ env.GHCR_IMAGE }},docker.io/${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image.image_name }}",push-by-digest=true,name-canonical=true,push=true
+ build-args: |
+ BUILD_COMMIT=${{ env.BUILD_COMMIT }}
+ BUILD_BRANCH=${{ env.BUILD_BRANCH }}
+ BUILD_DATE=${{ env.BUILD_DATE }}
+
+ - name: Export digest
+ env:
+ BUILD_DIGEST: ${{ steps.build.outputs.digest }}
+ run: |
+ set -euo pipefail
+ mkdir -p /tmp/digests
+ digest="$BUILD_DIGEST"
+ if [ -z "$digest" ]; then
+ echo "Build produced no digest." >&2
+ exit 1
+ fi
+ touch "/tmp/digests/${digest#sha256:}"
+
+ # The `-arch-` separator keeps one image's artifacts out of another's glob.
+ # Image names commonly prefix one another (`librechat` / `librechat-api`,
+ # `lc-dev` / `lc-dev-api`), and a bare `digests--*` pattern matches
+ # both, which is exactly the failure in #15446.
+ - name: Upload digest
+ uses: actions/upload-artifact@v4
+ with:
+ name: digests-${{ matrix.image.image_name }}-arch-${{ matrix.arch }}
+ path: /tmp/digests/*
+ if-no-files-found: error
+ retention-days: 1
+ # A full re-run (retry-docker-builds uses `rerun` when a job was
+ # cancelled) keeps the run id, and v4 refuses to upload over an existing
+ # artifact name. Overwrite instead of qualifying the name by
+ # run_attempt: `rerun-failed-jobs` re-runs the merge alone, which must
+ # still find the digests the earlier attempt's build legs uploaded.
+ overwrite: true
+
+ merge:
+ name: Merge manifests (${{ matrix.image.image_name }})
+ runs-on: ubuntu-latest
+ needs: build
+ timeout-minutes: 15
+ strategy:
+ fail-fast: false
+ matrix:
+ image: ${{ fromJSON(inputs.images) }}
+
+ steps:
+ # Registry names must be lowercase, and the repository owner is not guaranteed to be.
+ - name: Resolve GHCR image name
+ env:
+ IMAGE_NAME: ${{ matrix.image.image_name }}
+ run: printf 'GHCR_IMAGE=ghcr.io/%s/%s\n' "${GITHUB_REPOSITORY_OWNER,,}" "$IMAGE_NAME" >> "$GITHUB_ENV"
+
+ - name: Download digests
+ uses: actions/download-artifact@v7
+ with:
+ pattern: digests-${{ matrix.image.image_name }}-arch-*
+ merge-multiple: true
+ path: /tmp/digests
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v4
+
+ - name: Log in to GitHub Container Registry
+ uses: docker/login-action@v4
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Login to Docker Hub
+ uses: docker/login-action@v4
+ with:
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
+
+ # The digests are identical across registries (manifests are content-addressed),
+ # so the same set sources both manifest lists.
+ - name: Create manifest lists and push
+ working-directory: /tmp/digests
+ env:
+ DOCKERHUB_IMAGE: docker.io/${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image.image_name }}
+ EXPECTED_ARCHES: ${{ join(fromJSON(inputs.arches), ' ') }}
+ TAG_SUFFIXES: ${{ inputs.tag_suffixes }}
+ run: |
+ set -euo pipefail
+ shopt -s nullglob
+
+ # One digest per architecture. Assert the exact count: too few would
+ # publish a manifest missing an architecture, too many means the artifact
+ # glob crossed images (#15446). The old guard only rejected an empty
+ # directory, which is why four digests sailed through.
+ expected=$(wc -w <<< "$EXPECTED_ARCHES")
+ digests=(*)
+ if [ "${#digests[@]}" -ne "$expected" ]; then
+ echo "Expected $expected platform digests ($EXPECTED_ARCHES), found ${#digests[@]}: ${digests[*]}" >&2
+ exit 1
+ fi
+
+ tag_args=()
+ while IFS= read -r suffix; do
+ [ -n "$suffix" ] || continue
+ tag_args+=(-t "IMAGE:${suffix}")
+ done <<< "$TAG_SUFFIXES"
+ if [ "${#tag_args[@]}" -eq 0 ]; then
+ echo "No tag suffixes supplied; refusing to publish an untagged manifest." >&2
+ exit 1
+ fi
+
+ echo "Merging ${#digests[@]} digests into $(( ${#tag_args[@]} / 2 )) tag(s) per registry"
+ for image in "$GHCR_IMAGE" "$DOCKERHUB_IMAGE"; do
+ docker buildx imagetools create \
+ "${tag_args[@]/IMAGE:/${image}:}" \
+ "${digests[@]/#/${image}@sha256:}"
+ done
+
+ - name: Inspect image
+ env:
+ TAG_SUFFIXES: ${{ inputs.tag_suffixes }}
+ run: |
+ set -euo pipefail
+ first_tag=$(grep -m1 -v '^[[:space:]]*$' <<< "$TAG_SUFFIXES")
+ docker buildx imagetools inspect "${GHCR_IMAGE}:${first_tag}"
+
+ # After the move to another owner, deployments still pull the old GHCR
+ # namespace. Copying each published tag there keeps them updating until the
+ # deprecation window closes. Copies are cross-repo blob mounts within GHCR,
+ # so no image data is rebuilt or re-uploaded.
+ #
+ # Set the LEGACY_GHCR_OWNER variable and the LEGACY_GHCR_TOKEN secret (a token
+ # with write:packages for that owner) to turn this on. It stays off when either
+ # is unset, and while the owner still matches the one publishing the images.
+ - name: Mirror tags to the legacy namespace
+ if: ${{ vars.LEGACY_GHCR_OWNER != '' }}
+ continue-on-error: true
+ env:
+ LEGACY_OWNER: ${{ vars.LEGACY_GHCR_OWNER }}
+ LEGACY_TOKEN: ${{ secrets.LEGACY_GHCR_TOKEN }}
+ IMAGE_NAME: ${{ matrix.image.image_name }}
+ TAG_SUFFIXES: ${{ inputs.tag_suffixes }}
+ run: |
+ set -euo pipefail
+
+ legacy_owner="${LEGACY_OWNER,,}"
+ if [ "$legacy_owner" = "${GITHUB_REPOSITORY_OWNER,,}" ]; then
+ echo "Legacy owner matches the current owner; nothing to mirror."
+ exit 0
+ fi
+ if [ -z "$LEGACY_TOKEN" ]; then
+ echo "::warning::LEGACY_GHCR_OWNER is set but LEGACY_GHCR_TOKEN is not; skipping the mirror."
+ exit 0
+ fi
+
+ # The pull side needs this login too: it replaces the GITHUB_TOKEN
+ # credential for ghcr.io, and both namespaces are read with it.
+ echo "$LEGACY_TOKEN" | docker login ghcr.io -u "$legacy_owner" --password-stdin
+
+ legacy_image="ghcr.io/${legacy_owner}/${IMAGE_NAME}"
+ while IFS= read -r suffix; do
+ [ -n "$suffix" ] || continue
+ echo "Mirroring ${GHCR_IMAGE}:${suffix} -> ${legacy_image}:${suffix}"
+ docker buildx imagetools create -t "${legacy_image}:${suffix}" "${GHCR_IMAGE}:${suffix}"
+ done <<< "$TAG_SUFFIXES"
diff --git a/.github/workflows/docker-smoke.yml b/.github/workflows/docker-smoke.yml
index 3780959d8bf..5da967f90ef 100644
--- a/.github/workflows/docker-smoke.yml
+++ b/.github/workflows/docker-smoke.yml
@@ -6,6 +6,7 @@ on:
paths:
- '.github/workflows/docker-smoke.yml'
- '.dockerignore'
+ - 'Dockerfile'
- 'Dockerfile.multi'
- 'package.json'
- 'package-lock.json'
@@ -17,27 +18,116 @@ on:
- 'packages/client/**'
- 'packages/data-provider/**'
- 'packages/data-schemas/**'
+ - '!**.md'
permissions:
contents: read
+ pull-requests: read
concurrency:
group: docker-smoke-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
+ # Stage 2 of codegraph gating (stage 1 = backend jest in backend-review.yml). Two of the three
+ # smokes are graph-decidable: the client package build only matters when the change reaches the
+ # client build context, and the production-image boot only when it reaches the api image's build
+ # context (Dockerfile.multi's api-build stage never builds client). Monotone and fail-open: a
+ # smoke is dropped ONLY on an explicit `false`; unavailable/unconfigured/non-synchronize events
+ # run everything. Lock attribution rides along so a dependency bump keeps the image smoke.
+ # Kill switch: repo variable CODEGRAPH_GATING=off.
+ codegraph_select:
+ name: Codegraph select
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ if: >-
+ github.event_name == 'pull_request' &&
+ github.event.action == 'synchronize' &&
+ vars.CODEGRAPH_GATING != 'off'
+ outputs:
+ decided: ${{ steps.sel.outputs.decided }}
+ client_run: ${{ steps.sel.outputs.client_run }}
+ api_run: ${{ steps.sel.outputs.api_run }}
+ steps:
+ - name: Select smokes, fail open on any doubt
+ id: sel
+ env:
+ URL: ${{ secrets.CODEGRAPH_URL }}
+ TOKEN: ${{ secrets.CODEGRAPH_TOKEN }}
+ GH_TOKEN: ${{ github.token }}
+ REPO: ${{ github.repository }}
+ PR: ${{ github.event.pull_request.number }}
+ BASE_SHA: ${{ github.event.pull_request.base.sha }}
+ HEAD_SHA: ${{ github.event.pull_request.head.sha }}
+ CHANGED: ${{ github.event.pull_request.changed_files }}
+ run: |
+ set +e
+ note() { echo "$1" >> "$GITHUB_STEP_SUMMARY"; }
+ note "### Codegraph select — GATING (docker smokes)"
+ if [ -z "$URL" ] || [ -z "$TOKEN" ]; then note "_no codegraph config; running FULL_"; exit 0; fi
+ # A failed or truncated page must not become a shorter file list: the pipeline would hide
+ # gh's exit status behind jq, and a partial list can turn a required lane off. Check the
+ # fetch status AND the count against the PR's own changed_files (Codex P1, #15136).
+ if ! gh api "repos/$REPO/pulls/$PR/files" --paginate \
+ --jq '.[] | {path: .filename, status, patch}' > files.ndjson; then
+ note "_could not fetch changed files; running FULL_"; exit 0
+ fi
+ jq -s . files.ndjson > files.json
+ N=$(jq 'length' files.json)
+ if [ "$N" -eq 0 ] || { [ -n "$CHANGED" ] && [ "$N" -ne "$CHANGED" ]; }; then
+ note "_changed-file list incomplete ($N of ${CHANGED:-?}); running FULL_"; exit 0
+ fi
+ jq -c --arg b "$BASE_SHA" --arg h "$HEAD_SHA" \
+ '{files: ., mode: "safe", lockBaseSha: $b, lockHeadSha: $h}' files.json > body.json
+ # curl's status is checked explicitly: a transfer that times out or truncates after a
+ # parseable body must fail open, not be honoured (Codex P1, #15136). --fail-with-body
+ # also turns HTTP errors into a failure while keeping the error text for the summary.
+ RESP=$(curl -sS --fail-with-body -m 45 -H "Authorization: Bearer $TOKEN" \
+ -H 'content-type: application/json' --data-binary @body.json "$URL/v1/select"); RC=$?
+ if [ "$RC" -ne 0 ] || [ -z "$RESP" ] || ! echo "$RESP" | jq -e '.matrix["docker-smoke"]' >/dev/null 2>&1; then
+ note "_codegraph unavailable (curl exit $RC: ${RESP:0:120}); running FULL_"
+ exit 0
+ fi
+ # A smoke is skipped only on the JSON boolean false — tested inside jq, because `jq -r`
+ # prints the string "false" and the boolean identically (Codex P1, #15136). Anything
+ # else (true, null, a string, missing) runs.
+ if echo "$RESP" | jq -e '.e2e.fail_open == true' >/dev/null 2>&1; then
+ note "_fail-open decision (root/workflow/lockfile change or stale graph): everything runs_"
+ fi
+ note "| smoke | decision |"
+ note "|---|---|"
+ emit() {
+ key="$1"; hint="$2"; label="$3"
+ if echo "$RESP" | jq -e --arg h "$hint" '.matrix["docker-smoke"][$h] == false' >/dev/null 2>&1; then
+ echo "${key}_run=false" >> "$GITHUB_OUTPUT"
+ note "| $label | skip (no reach into its build context) |"
+ else
+ echo "${key}_run=true" >> "$GITHUB_OUTPUT"
+ note "| $label | run |"
+ fi
+ }
+ emit client client_package_target "client package build"
+ emit api api_runtime_smoke "api runtime smoke"
+ echo "codegraph-select: $(echo "$RESP" | jq -c '.matrix["docker-smoke"]')"
+ echo "decided=true" >> "$GITHUB_OUTPUT"
+ note ""
+ note "node image smoke keeps its own path filter · kill switch: repo variable \`CODEGRAPH_GATING=off\` · everything runs on PR open"
+ exit 0
+
client-package-target:
name: Build Docker client package target
+ needs: [codegraph_select]
+ if: ${{ !cancelled() && needs.codegraph_select.outputs.client_run != 'false' }}
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v3
+ uses: docker/setup-buildx-action@v4
- name: Build client package target
- uses: docker/build-push-action@v5
+ uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile.multi
@@ -45,21 +135,61 @@ jobs:
push: false
target: client-package-build
+ # The plain single-stage Dockerfile ships via dev-images/tag-images but had no
+ # PR-time validation. The npm build pipeline itself is already smoked on every
+ # matching PR by the Dockerfile.multi jobs above, so the full build here is
+ # gated to changes of the Dockerfile or the build-context definition.
+ node-image-smoke:
+ name: Node image smoke (plain Dockerfile builds)
+ runs-on: ubuntu-latest
+ timeout-minutes: 25
+ steps:
+ - uses: actions/checkout@v5
+
+ - name: Detect plain Dockerfile changes
+ id: paths
+ if: github.event_name == 'pull_request'
+ uses: dorny/paths-filter@v4
+ with:
+ filters: |
+ dockerfile:
+ - 'Dockerfile'
+ - '.dockerignore'
+ - '.github/workflows/docker-smoke.yml'
+
+ - name: Set up Docker Buildx
+ if: github.event_name == 'workflow_dispatch' || steps.paths.outputs.dockerfile == 'true'
+ uses: docker/setup-buildx-action@v4
+
+ - name: Build node image
+ if: github.event_name == 'workflow_dispatch' || steps.paths.outputs.dockerfile == 'true'
+ uses: docker/build-push-action@v7
+ with:
+ context: .
+ file: Dockerfile
+ platforms: linux/amd64
+ push: false
+ target: node
+ cache-from: type=gha,scope=docker-smoke-node
+ cache-to: type=gha,mode=max,scope=docker-smoke-node
+
api-runtime-smoke:
name: API runtime smoke (production image boots)
+ needs: [codegraph_select]
+ if: ${{ !cancelled() && needs.codegraph_select.outputs.api_run != 'false' }}
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
- name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v3
+ uses: docker/setup-buildx-action@v4
# Build the real production image (final `api-build` stage), which installs
# with `npm ci --omit=dev` — the same prune that, in prod, exposed runtime
# dependencies the tsdown bundle externalizes but were never declared.
- name: Build production image
- uses: docker/build-push-action@v5
+ uses: docker/build-push-action@v7
with:
context: .
file: Dockerfile.multi
diff --git a/.github/workflows/eslint-ci.yml b/.github/workflows/eslint-ci.yml
deleted file mode 100644
index 3ab8528b042..00000000000
--- a/.github/workflows/eslint-ci.yml
+++ /dev/null
@@ -1,128 +0,0 @@
-name: ESLint Code Quality Checks
-
-on:
- pull_request:
- branches:
- - main
- - dev
- - dev-staging
- - release/*
- paths:
- - 'api/**'
- - 'client/**'
- - 'packages/**'
- - '.github/workflows/eslint-ci.yml'
-
-jobs:
- eslint_checks:
- name: Run ESLint Linting
- runs-on: ubuntu-latest
- permissions:
- contents: read
- security-events: write
- actions: read
- steps:
- - name: Checkout repository
- uses: actions/checkout@v4
- with:
- fetch-depth: 0
-
- - name: Set up Node.js 24.16.0
- uses: actions/setup-node@v4
- with:
- node-version: '24.16.0'
- cache: npm
-
- - name: Install dependencies
- run: npm ci
-
- # Run ESLint on changed files within the api/, client/, and packages/ directories.
- - name: Run ESLint on changed files
- run: |
- # Extract the base commit SHA from the pull_request event payload.
- BASE_SHA=$(jq --raw-output .pull_request.base.sha "$GITHUB_EVENT_PATH")
- echo "Base commit SHA: $BASE_SHA"
-
- # Get changed files (only JS/TS files in api/, client/, or packages/)
- mapfile -d '' -t CHANGED_FILES < <(
- git diff -z --name-only --diff-filter=ACMRTUXB "$BASE_SHA" HEAD |
- grep -zE '^(api|client|packages)/.*\.(js|jsx|ts|tsx)$' || true
- )
-
- # Debug output
- echo "Changed files:"
- printf '%s\n' "${CHANGED_FILES[@]}"
-
- # Ensure there are files to lint before running ESLint
- if [[ ${#CHANGED_FILES[@]} -eq 0 ]]; then
- echo "No matching files changed. Skipping ESLint."
- exit 0
- fi
-
- # Run ESLint
- npx eslint --no-error-on-unmatched-pattern \
- --config eslint.config.mjs \
- --max-warnings=0 \
- -- "${CHANGED_FILES[@]}"
-
- # Run Prettier --check on the same set of changed files to catch
- # formatting drift in PRs that bypassed the local pre-commit hook
- # (e.g. GitHub UI edit-and-merge, `git commit --no-verify`).
- - name: Run Prettier --check on changed files
- run: |
- BASE_SHA=$(jq --raw-output .pull_request.base.sha "$GITHUB_EVENT_PATH")
- mapfile -d '' -t CHANGED_FILES < <(
- git diff -z --name-only --diff-filter=ACMRTUXB "$BASE_SHA" HEAD |
- grep -zE '^(api|client|packages)/.*\.(js|jsx|ts|tsx)$' || true
- )
-
- if [[ ${#CHANGED_FILES[@]} -eq 0 ]]; then
- echo "No matching files changed. Skipping Prettier."
- exit 0
- fi
-
- echo "Files to check:"
- printf '%s\n' "${CHANGED_FILES[@]}"
-
- # `prettier --check` exits non-zero if any file would be reformatted.
- # Suggest the local fix in the failure message so contributors aren't
- # left guessing how to resolve.
- if ! npx prettier --check --no-error-on-unmatched-pattern -- "${CHANGED_FILES[@]}"; then
- echo ""
- echo "::error::Prettier formatting drift detected. Fix locally with:"
- echo "::error:: npx prettier --write "
- echo "::error::Or rely on the lint-staged pre-commit hook (do not bypass with --no-verify)."
- exit 1
- fi
-
- # Verify import ordering on the same set of changed files. The script
- # only sorts files under known source roots, so unrelated changed files
- # (configs, etc.) are ignored. Matches the lint-staged pre-commit hook.
- - name: Check import sorting on changed files
- run: |
- BASE_SHA=$(jq --raw-output .pull_request.base.sha "$GITHUB_EVENT_PATH")
- mapfile -d '' -t CHANGED_FILES < <(
- git diff -z --name-only --diff-filter=ACMRTUXB "$BASE_SHA" HEAD |
- grep -zE '^(api|client|packages)/.*\.(js|jsx|ts|tsx)$' || true
- )
-
- if [[ ${#CHANGED_FILES[@]} -eq 0 ]]; then
- echo "No matching files changed. Skipping import-sort check."
- exit 0
- fi
-
- echo "Files to check:"
- printf '%s\n' "${CHANGED_FILES[@]}"
-
- # `--check` lists offending files and exits non-zero without writing.
- if ! node scripts/sort-imports.mts --check "${CHANGED_FILES[@]}"; then
- echo ""
- echo "::error::Import order drift detected. Fix locally with:"
- echo "::error:: npm run sort-imports"
- echo "::error::For specific files:"
- echo "::error:: npm run sort-imports -- packages/api/src/app/metrics.ts packages/api/src/rum/proxy.ts"
- echo "::error::To check without writing files:"
- echo "::error:: npm run sort-imports:check"
- echo "::error::Or rely on the lint-staged pre-commit hook (do not bypass with --no-verify)."
- exit 1
- fi
diff --git a/.github/workflows/frontend-review.yml b/.github/workflows/frontend-review.yml
index a3f31efba63..40ebe5b7221 100644
--- a/.github/workflows/frontend-review.yml
+++ b/.github/workflows/frontend-review.yml
@@ -3,33 +3,175 @@ name: Frontend Unit Tests
on:
pull_request:
paths:
+ - 'e2e/client-build.test.mjs'
- 'client/**'
- 'packages/client/**'
- 'packages/data-provider/**'
+ - 'package.json'
+ - 'package-lock.json'
+ - '.github/workflows/frontend-review.yml'
+ - '!**.md'
+ # Post-merge safety net and full-run baseline for gated selection (same rationale as
+ # backend-review.yml stage 1): every dev merge touching frontend paths runs the full suite.
+ push:
+ branches:
+ - dev
+ paths:
+ - 'e2e/client-build.test.mjs'
+ - 'client/**'
+ - 'packages/client/**'
+ - 'packages/data-provider/**'
+ - 'package.json'
+ - 'package-lock.json'
- '.github/workflows/frontend-review.yml'
permissions:
contents: read
+ pull-requests: read
+
+concurrency:
+ # PR pushes supersede each other (per-PR canceling group). Push events get a PER-COMMIT group:
+ # dev-push runs are the post-merge safety net and the full-run baseline, and with a shared
+ # canceling group closely spaced merges cancel each other's runs — observed live on 2026-08-23,
+ # when three consecutive dev merges cancelled the runs that would have caught #15142's red
+ # (Codex P2 on #15145).
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
+ cancel-in-progress: true
env:
NODE_OPTIONS: '--max-old-space-size=${{ secrets.NODE_MAX_OLD_SPACE_SIZE || 6144 }}'
jobs:
+ client-build-regression:
+ name: Client build recovery regression
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ steps:
+ - uses: actions/checkout@v5
+ - uses: actions/setup-node@v5
+ with:
+ node-version: '24.16.0'
+ cache: npm
+ - run: npm ci
+ - run: google-chrome --version
+ - run: npm run test:client-build
+ env:
+ PLAYWRIGHT_CHANNEL: chrome
+
+ # Stage 1.5 of codegraph gating (stage 1 = backend jest, #15132; stage 2 = matrix lanes,
+ # #15136). Same mechanism, same record: across 604 finalized shadow receipts the frontend
+ # selection has zero structural misses (its one raw MISSED was a chronic flake), over 500
+ # narrowed decisions. Selected (safe mode) on synchronize only; full on PR open/reopen, on
+ # every dev push, and on any doubt. A workspace skips ONLY on an explicit NONE. Kill switch:
+ # repo variable CODEGRAPH_GATING=off. Neither frontend workspace defines
+ # testPathIgnorePatterns, so --runTestsByPath needs no exclude mirroring here.
+ codegraph_select:
+ name: Codegraph select
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ if: >-
+ github.event_name == 'pull_request' &&
+ github.event.action == 'synchronize' &&
+ vars.CODEGRAPH_GATING != 'off'
+ outputs:
+ decided: ${{ steps.sel.outputs.decided }}
+ client_run: ${{ steps.sel.outputs.client_run }}
+ client_files: ${{ steps.sel.outputs.client_files }}
+ clientpkg_run: ${{ steps.sel.outputs.clientpkg_run }}
+ clientpkg_files: ${{ steps.sel.outputs.clientpkg_files }}
+ steps:
+ - name: Select tests, fail open on any doubt
+ id: sel
+ env:
+ URL: ${{ secrets.CODEGRAPH_URL }}
+ TOKEN: ${{ secrets.CODEGRAPH_TOKEN }}
+ GH_TOKEN: ${{ github.token }}
+ REPO: ${{ github.repository }}
+ PR: ${{ github.event.pull_request.number }}
+ BASE_SHA: ${{ github.event.pull_request.base.sha }}
+ HEAD_SHA: ${{ github.event.pull_request.head.sha }}
+ CHANGED: ${{ github.event.pull_request.changed_files }}
+ run: |
+ set +e
+ note() { echo "$1" >> "$GITHUB_STEP_SUMMARY"; }
+ note "### Codegraph select — GATING (frontend jest)"
+ if [ -z "$URL" ] || [ -z "$TOKEN" ]; then note "_no codegraph config; running FULL_"; exit 0; fi
+ if ! gh api "repos/$REPO/pulls/$PR/files" --paginate \
+ --jq '.[] | {path: .filename, status, patch}' > files.ndjson; then
+ note "_could not fetch changed files; running FULL_"; exit 0
+ fi
+ jq -s . files.ndjson > files.json
+ N=$(jq 'length' files.json)
+ if [ "$N" -eq 0 ] || { [ -n "$CHANGED" ] && [ "$N" -ne "$CHANGED" ]; }; then
+ note "_changed-file list incomplete ($N of ${CHANGED:-?}); running FULL_"; exit 0
+ fi
+ jq -c --arg b "$BASE_SHA" --arg h "$HEAD_SHA" \
+ '{files: ., mode: "safe", lockBaseSha: $b, lockHeadSha: $h}' files.json > body.json
+ RESP=$(curl -sS --fail-with-body -m 45 -H "Authorization: Bearer $TOKEN" \
+ -H 'content-type: application/json' --data-binary @body.json "$URL/v1/select"); RC=$?
+ if [ "$RC" -ne 0 ] || [ -z "$RESP" ] || ! echo "$RESP" | jq -e '.selected.client.mode' >/dev/null 2>&1; then
+ note "_codegraph unavailable (curl exit $RC: ${RESP:0:120}); running FULL_"
+ exit 0
+ fi
+ emit() {
+ key="$1"; ws="$2"
+ mode=$(echo "$RESP" | jq -r --arg w "$ws" '.selected[$w].mode')
+ files=""
+ if [ "$mode" = "FILES" ]; then
+ # Only an explicit NONE may skip. FILES with a missing/empty list is a malformed
+ # decision (service/schema skew) and must run FULL (Codex P1 on #15145).
+ raw_n=$(echo "$RESP" | jq -r --arg w "$ws" '.selected[$w].files // [] | length')
+ # Every selected path must live under the workspace: a wrong-prefixed path would
+ # survive ltrimstr, match nothing in the workspace cwd, and --passWithNoTests would
+ # turn "ran nothing" into green — a silent fail-closed (Codex P1 on #15145).
+ misplaced=$(echo "$RESP" | jq -r --arg w "$ws" --arg p "$ws/" '[.selected[$w].files // [] | .[] | select(startswith($p) | not)] | length')
+ if [ "$raw_n" = "0" ] || [ "$misplaced" != "0" ]; then
+ mode="FULL"
+ note "| $ws | malformed FILES decision ($raw_n files, $misplaced outside $ws/); running FULL |"
+ else
+ files=$(echo "$RESP" | jq -r --arg w "$ws" --arg p "$ws/" \
+ '.selected[$w].files // [] | map(select(test(" ") | not)) | map(ltrimstr($p)) | join(" ")')
+ spaced=$(echo "$RESP" | jq -r --arg w "$ws" '[.selected[$w].files // [] | .[] | select(test(" "))] | length')
+ if [ "$spaced" != "0" ]; then mode="FULL"; files=""; fi
+ fi
+ fi
+ if [ "$mode" = "NONE" ]; then
+ echo "${key}_run=false" >> "$GITHUB_OUTPUT"
+ note "| $ws | skip (no reachable tests) |"
+ elif [ "$mode" = "FILES" ]; then
+ n=$(echo "$files" | wc -w | tr -d ' ')
+ echo "${key}_run=true" >> "$GITHUB_OUTPUT"
+ echo "${key}_files=$files" >> "$GITHUB_OUTPUT"
+ note "| $ws | $n selected files |"
+ else
+ echo "${key}_run=true" >> "$GITHUB_OUTPUT"
+ note "| $ws | FULL |"
+ fi
+ }
+ note "| workspace | decision |"
+ note "|---|---|"
+ emit client client
+ emit clientpkg packages/client
+ echo "decided=true" >> "$GITHUB_OUTPUT"
+ note ""
+ note "kill switch: repo variable \`CODEGRAPH_GATING=off\`; full runs remain on PR open and on every dev push"
+ exit 0
+
build:
name: Build packages
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
- name: Use Node.js 24.16.0
- uses: actions/setup-node@v4
+ uses: actions/setup-node@v5
with:
node-version: '24.16.0'
- name: Restore node_modules cache
id: cache-node-modules
- uses: actions/cache@v4
+ uses: actions/cache@v5
with:
path: |
node_modules
@@ -44,10 +186,10 @@ jobs:
- name: Restore data-provider build cache
id: cache-data-provider
- uses: actions/cache@v4
+ uses: actions/cache@v5
with:
path: packages/data-provider/dist
- key: build-data-provider-${{ runner.os }}-${{ hashFiles('packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }}
+ key: build-data-provider-${{ runner.os }}-${{ hashFiles('package.json', 'package-lock.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }}
- name: Build data-provider
if: steps.cache-data-provider.outputs.cache-hit != 'true'
@@ -55,24 +197,24 @@ jobs:
- name: Restore client-package build cache
id: cache-client-package
- uses: actions/cache@v4
+ uses: actions/cache@v5
with:
path: packages/client/dist
- key: build-client-package-${{ runner.os }}-${{ hashFiles('packages/client/src/**', 'packages/client/tsconfig*.json', 'packages/client/tsdown.config.mjs', 'packages/client/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }}
+ key: build-client-package-${{ runner.os }}-${{ hashFiles('package.json', 'package-lock.json', 'packages/client/src/**', 'packages/client/tsconfig*.json', 'packages/client/tsdown.config.mjs', 'packages/client/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }}
- name: Build client-package
if: steps.cache-client-package.outputs.cache-hit != 'true'
run: npm run build:client-package
- name: Upload data-provider build
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v6
with:
name: build-data-provider
path: packages/data-provider/dist
retention-days: 2
- name: Upload client-package build
- uses: actions/upload-artifact@v4
+ uses: actions/upload-artifact@v6
with:
name: build-client-package
path: packages/client/dist
@@ -84,16 +226,16 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
- name: Use Node.js 24.16.0
- uses: actions/setup-node@v4
+ uses: actions/setup-node@v5
with:
node-version: '24.16.0'
- name: Restore node_modules cache
id: cache-node-modules
- uses: actions/cache@v4
+ uses: actions/cache@v5
with:
path: |
node_modules
@@ -107,13 +249,13 @@ jobs:
run: npm ci
- name: Download data-provider build
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v7
with:
name: build-data-provider
path: packages/data-provider/dist
- name: Download client-package build
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v7
with:
name: build-client-package
path: packages/client/dist
@@ -122,26 +264,25 @@ jobs:
run: npm run typecheck
working-directory: client
- test-ubuntu:
- name: 'Tests: Ubuntu (shard ${{ matrix.shard }}/4)'
- needs: build
+ test-packages-client:
+ name: 'Tests: @librechat/client'
+ needs: [build, codegraph_select]
+ if: >-
+ !cancelled() && needs.build.result == 'success' &&
+ needs.codegraph_select.outputs.clientpkg_run != 'false'
runs-on: ubuntu-latest
- timeout-minutes: 15
- strategy:
- fail-fast: false
- matrix:
- shard: [1, 2, 3, 4]
+ timeout-minutes: 10
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
- name: Use Node.js 24.16.0
- uses: actions/setup-node@v4
+ uses: actions/setup-node@v5
with:
node-version: '24.16.0'
- name: Restore node_modules cache
id: cache-node-modules
- uses: actions/cache@v4
+ uses: actions/cache@v5
with:
path: |
node_modules
@@ -155,41 +296,75 @@ jobs:
run: npm ci
- name: Download data-provider build
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v7
with:
name: build-data-provider
path: packages/data-provider/dist
- - name: Download client-package build
- uses: actions/download-artifact@v4
+ - name: Run unit tests
+ env:
+ SELECTED: ${{ needs.codegraph_select.outputs.clientpkg_files }}
+ JEST_JSON: --json --outputFile=${{ github.workspace }}/jest-results/jest-results-packages-client.json
+ run: |
+ mkdir -p "$GITHUB_WORKSPACE/jest-results"
+ # A selected path can be stale in exactly two ways at this checkout (Codex P2, #15145 r6):
+ # deleted on the branch — dropped, which matches full CI (the file runs nowhere) — or
+ # renamed, where the NEW path is a changed test file and is selected independently. If
+ # NOTHING selected exists, the selection is stale wholesale and the suite runs FULL;
+ # --passWithNoTests must never turn "ran nothing" into green.
+ if [ -n "$SELECTED" ]; then
+ KEEP=""
+ for f in $SELECTED; do
+ if [ -f "$f" ]; then KEEP="$KEEP $f"; else echo "dropping selected path absent at HEAD (deleted or renamed): $f"; fi
+ done
+ KEEP="${KEEP# }"
+ if [ -z "$KEEP" ]; then
+ echo "no selected test file exists at HEAD (stale selection); running FULL"
+ npm run test:ci -- $JEST_JSON
+ else
+ echo "codegraph: $(echo $KEEP | wc -w) selected test files (safe mode)"
+ npm run test:ci -- --passWithNoTests --runTestsByPath $KEEP $JEST_JSON
+ fi
+ else
+ npm run test:ci -- $JEST_JSON
+ fi
+ working-directory: packages/client
+ # Per-test results keyed by head SHA (run.head_sha), for the codegraph test-evidence feed.
+ # Never part of the gate: it cannot fail the job, and a re-run overwrites its own artifact.
+ - name: Upload Jest results
+ if: ${{ !cancelled() }}
+ continue-on-error: true
+ uses: actions/upload-artifact@v6
with:
- name: build-client-package
- path: packages/client/dist
+ name: jest-results-packages-client
+ path: jest-results/
+ retention-days: 7
+ if-no-files-found: ignore
+ overwrite: true
- - name: Run unit tests (shard ${{ matrix.shard }}/4)
- run: npm run test:ci -- --shard=${{ matrix.shard }}/4
- working-directory: client
-
- test-windows:
- name: 'Tests: Windows (shard ${{ matrix.shard }}/4)'
- needs: build
- runs-on: windows-latest
- timeout-minutes: 20
+ test-ubuntu:
+ name: 'Tests: Ubuntu (shard ${{ matrix.shard }}/2)'
+ needs: [build, codegraph_select]
+ if: >-
+ !cancelled() && needs.build.result == 'success' &&
+ needs.codegraph_select.outputs.client_run != 'false'
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
strategy:
fail-fast: false
matrix:
- shard: [1, 2, 3, 4]
+ shard: [1, 2]
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
- name: Use Node.js 24.16.0
- uses: actions/setup-node@v4
+ uses: actions/setup-node@v5
with:
node-version: '24.16.0'
- name: Restore node_modules cache
id: cache-node-modules
- uses: actions/cache@v4
+ uses: actions/cache@v5
with:
path: |
node_modules
@@ -203,20 +378,52 @@ jobs:
run: npm ci
- name: Download data-provider build
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v7
with:
name: build-data-provider
path: packages/data-provider/dist
- name: Download client-package build
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v7
with:
name: build-client-package
path: packages/client/dist
- - name: Run unit tests (shard ${{ matrix.shard }}/4)
- run: npm run test:ci -- --shard=${{ matrix.shard }}/4
+ - name: Run unit tests (shard ${{ matrix.shard }}/2)
+ env:
+ SELECTED: ${{ needs.codegraph_select.outputs.client_files }}
+ JEST_JSON: --json --outputFile=${{ github.workspace }}/jest-results/jest-results-client-${{ matrix.shard }}.json
+ run: |
+ mkdir -p "$GITHUB_WORKSPACE/jest-results"
+ if [ -n "$SELECTED" ]; then
+ KEEP=""
+ for f in $SELECTED; do
+ if [ -f "$f" ]; then KEEP="$KEEP $f"; else echo "dropping selected path absent at HEAD (deleted or renamed): $f"; fi
+ done
+ KEEP="${KEEP# }"
+ if [ -z "$KEEP" ]; then
+ echo "no selected test file exists at HEAD (stale selection); running FULL"
+ npm run test:ci -- --shard=${{ matrix.shard }}/2 $JEST_JSON
+ else
+ echo "codegraph: $(echo $KEEP | wc -w) selected test files (safe mode)"
+ npm run test:ci -- --shard=${{ matrix.shard }}/2 --passWithNoTests --runTestsByPath $KEEP $JEST_JSON
+ fi
+ else
+ npm run test:ci -- --shard=${{ matrix.shard }}/2 $JEST_JSON
+ fi
working-directory: client
+ # Per-test results keyed by head SHA (run.head_sha), for the codegraph test-evidence feed.
+ # Never part of the gate: it cannot fail the job, and a re-run overwrites its own artifact.
+ - name: Upload Jest results
+ if: ${{ !cancelled() }}
+ continue-on-error: true
+ uses: actions/upload-artifact@v6
+ with:
+ name: jest-results-client-${{ matrix.shard }}
+ path: jest-results/
+ retention-days: 7
+ if-no-files-found: ignore
+ overwrite: true
build-verify:
name: Vite build verification
@@ -224,16 +431,16 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
- name: Use Node.js 24.16.0
- uses: actions/setup-node@v4
+ uses: actions/setup-node@v5
with:
node-version: '24.16.0'
- name: Restore node_modules cache
id: cache-node-modules
- uses: actions/cache@v4
+ uses: actions/cache@v5
with:
path: |
node_modules
@@ -247,13 +454,13 @@ jobs:
run: npm ci
- name: Download data-provider build
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v7
with:
name: build-data-provider
path: packages/data-provider/dist
- name: Download client-package build
- uses: actions/download-artifact@v4
+ uses: actions/download-artifact@v7
with:
name: build-client-package
path: packages/client/dist
diff --git a/.github/workflows/frontend-windows-nightly.yml b/.github/workflows/frontend-windows-nightly.yml
new file mode 100644
index 00000000000..28c1ea46052
--- /dev/null
+++ b/.github/workflows/frontend-windows-nightly.yml
@@ -0,0 +1,139 @@
+name: Frontend Windows Tests
+
+on:
+ schedule:
+ - cron: '17 3 * * *'
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
+
+env:
+ NODE_OPTIONS: '--max-old-space-size=${{ secrets.NODE_MAX_OLD_SPACE_SIZE || 6144 }}'
+
+jobs:
+ build:
+ name: Build packages
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+ outputs:
+ source-sha: ${{ steps.source.outputs.sha }}
+ steps:
+ - uses: actions/checkout@v5
+ with:
+ ref: ${{ github.event_name == 'schedule' && 'dev' || github.sha }}
+
+ - name: Capture source SHA
+ id: source
+ shell: bash
+ run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
+
+ - name: Use Node.js 24.16.0
+ uses: actions/setup-node@v5
+ with:
+ node-version: '24.16.0'
+
+ - name: Restore node_modules cache
+ id: cache-node-modules
+ uses: actions/cache@v5
+ with:
+ path: |
+ node_modules
+ client/node_modules
+ packages/client/node_modules
+ packages/data-provider/node_modules
+ key: node-modules-frontend-${{ runner.os }}-24.16.0-${{ hashFiles('package-lock.json') }}
+
+ - name: Install dependencies
+ if: steps.cache-node-modules.outputs.cache-hit != 'true'
+ run: npm ci
+
+ - name: Restore data-provider build cache
+ id: cache-data-provider
+ uses: actions/cache@v5
+ with:
+ path: packages/data-provider/dist
+ key: build-data-provider-${{ runner.os }}-${{ hashFiles('package.json', 'package-lock.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }}
+
+ - name: Build data-provider
+ if: steps.cache-data-provider.outputs.cache-hit != 'true'
+ run: npm run build:data-provider
+
+ - name: Restore client-package build cache
+ id: cache-client-package
+ uses: actions/cache@v5
+ with:
+ path: packages/client/dist
+ key: build-client-package-${{ runner.os }}-${{ hashFiles('package.json', 'package-lock.json', 'packages/client/src/**', 'packages/client/tsconfig*.json', 'packages/client/tsdown.config.mjs', 'packages/client/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }}
+
+ - name: Build client-package
+ if: steps.cache-client-package.outputs.cache-hit != 'true'
+ run: npm run build:client-package
+
+ - name: Upload data-provider build
+ uses: actions/upload-artifact@v6
+ with:
+ name: build-data-provider
+ path: packages/data-provider/dist
+ retention-days: 2
+
+ - name: Upload client-package build
+ uses: actions/upload-artifact@v6
+ with:
+ name: build-client-package
+ path: packages/client/dist
+ retention-days: 2
+
+ test-windows:
+ name: 'Tests: Windows (shard ${{ matrix.shard }}/4)'
+ needs: build
+ runs-on: windows-latest
+ timeout-minutes: 20
+ strategy:
+ fail-fast: false
+ matrix:
+ shard: [1, 2, 3, 4]
+ steps:
+ - uses: actions/checkout@v5
+ with:
+ ref: ${{ needs.build.outputs.source-sha }}
+
+ - name: Use Node.js 24.16.0
+ uses: actions/setup-node@v5
+ with:
+ node-version: '24.16.0'
+
+ - name: Restore node_modules cache
+ id: cache-node-modules
+ uses: actions/cache@v5
+ with:
+ path: |
+ node_modules
+ client/node_modules
+ packages/client/node_modules
+ packages/data-provider/node_modules
+ key: node-modules-frontend-${{ runner.os }}-24.16.0-${{ hashFiles('package-lock.json') }}
+
+ - name: Install dependencies
+ if: steps.cache-node-modules.outputs.cache-hit != 'true'
+ run: npm ci
+
+ - name: Download data-provider build
+ uses: actions/download-artifact@v7
+ with:
+ name: build-data-provider
+ path: packages/data-provider/dist
+
+ - name: Download client-package build
+ uses: actions/download-artifact@v7
+ with:
+ name: build-client-package
+ path: packages/client/dist
+
+ - name: Run unit tests (shard ${{ matrix.shard }}/4)
+ run: npm run test:ci -- --shard=${{ matrix.shard }}/4
+ working-directory: client
diff --git a/.github/workflows/generate_embeddings.yml b/.github/workflows/generate_embeddings.yml
deleted file mode 100644
index 3c6f2717c30..00000000000
--- a/.github/workflows/generate_embeddings.yml
+++ /dev/null
@@ -1,23 +0,0 @@
-name: 'generate_embeddings'
-on:
- workflow_dispatch:
- push:
- branches:
- - main
- paths:
- - 'docs/**'
-
-permissions:
- contents: read
-
-jobs:
- generate:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- - uses: supabase/embeddings-generator@v0.0.5
- with:
- supabase-url: ${{ secrets.SUPABASE_URL }}
- supabase-service-role-key: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }}
- openai-key: ${{ secrets.OPENAI_DOC_EMBEDDINGS_KEY }}
- docs-root-path: 'docs'
diff --git a/.github/workflows/gitnexus-cleanup-pr.yml b/.github/workflows/gitnexus-cleanup-pr.yml
deleted file mode 100644
index d3c96283213..00000000000
--- a/.github/workflows/gitnexus-cleanup-pr.yml
+++ /dev/null
@@ -1,91 +0,0 @@
-# Removes a PR's GitNexus index from the droplet when the PR is closed
-# (merged or not). The deploy workflow also prunes stale folders as a
-# safety net, but this gives us immediate cleanup without waiting for
-# the next deploy trigger.
-
-name: GitNexus Cleanup PR
-
-on:
- pull_request:
- types: [closed]
-
-permissions:
- contents: read
- actions: read
-
-concurrency:
- group: gitnexus-cleanup-pr-${{ github.event.pull_request.number }}
- cancel-in-progress: false
-
-jobs:
- cleanup:
- # Skip fork PRs entirely. GitHub withholds repository secrets from
- # pull_request events originating on forks, so an SSH deploy job run
- # from a fork close would fail noisily. The deploy workflow's stale-
- # folder pruning step catches any fork-contributor indexes that
- # actually made it onto the droplet.
- if: github.event.pull_request.head.repo.full_name == github.repository
- runs-on: ubuntu-latest
- timeout-minutes: 5
- steps:
- # Skip the SSH round-trip entirely when no index artifact was ever
- # built for this PR (docs-only PRs, paths-ignored PRs, PRs closed
- # before indexing finished, etc). Eliminates ~95% of no-op SSH
- # sessions on a busy repo.
- - name: Check for index artifact
- id: check
- uses: actions/github-script@v7
- with:
- script: |
- const { data } = await github.rest.actions.listArtifactsForRepo({
- owner: context.repo.owner,
- repo: context.repo.repo,
- name: `gitnexus-index-pr-${context.payload.pull_request.number}`,
- per_page: 1,
- });
- const hasArtifact = data.total_count > 0;
- core.info(`Artifact exists: ${hasArtifact}`);
- core.setOutput('has_artifact', hasArtifact ? 'true' : 'false');
-
- - name: Setup SSH
- if: steps.check.outputs.has_artifact == 'true'
- env:
- SSH_KEY: ${{ secrets.GITNEXUS_DO_SSH_KEY }}
- KNOWN_HOST: ${{ secrets.GITNEXUS_DO_KNOWN_HOST }}
- run: |
- set -e
- mkdir -p ~/.ssh
- chmod 700 ~/.ssh
- printf '%s\n' "$SSH_KEY" > ~/.ssh/deploy_key
- chmod 600 ~/.ssh/deploy_key
- if [ -z "$KNOWN_HOST" ]; then
- echo "::error::GITNEXUS_DO_KNOWN_HOST secret is empty"
- exit 1
- fi
- printf '%s\n' "$KNOWN_HOST" > ~/.ssh/known_hosts
- chmod 600 ~/.ssh/known_hosts
-
- - name: Remove PR index from droplet
- if: steps.check.outputs.has_artifact == 'true'
- env:
- SSH_USER: ${{ secrets.GITNEXUS_DO_USER }}
- SSH_HOST: ${{ secrets.GITNEXUS_DO_HOST }}
- PR_NUM: ${{ github.event.pull_request.number }}
- run: |
- ssh -i ~/.ssh/deploy_key "$SSH_USER@$SSH_HOST" PR_NUM="$PR_NUM" bash <<'REMOTE'
- set -e
- TARGET="/opt/gitnexus/indexes/LibreChat-pr-$PR_NUM"
- if [ -d "$TARGET" ]; then
- echo "Removing $TARGET"
- rm -rf "$TARGET"
- cd /opt/gitnexus
- docker compose up -d --force-recreate gitnexus
- echo "GitNexus restarted without PR #$PR_NUM"
- else
- echo "No index to clean up for PR #$PR_NUM (artifact existed but droplet folder did not)"
- fi
- REMOTE
-
- - name: Cleanup SSH key
- if: always()
- run: rm -f ~/.ssh/deploy_key
diff --git a/.github/workflows/gitnexus-deploy.yml b/.github/workflows/gitnexus-deploy.yml
deleted file mode 100644
index dc62068a6ca..00000000000
--- a/.github/workflows/gitnexus-deploy.yml
+++ /dev/null
@@ -1,583 +0,0 @@
-# Deploys GitNexus indexes to a droplet via SSH + rsync.
-#
-# Architecture:
-# GitHub Actions (deploy)
-# 1. Resolves latest successful index runs for main and dev
-# 2. Downloads each matching .gitnexus/ artifact
-# 3. Rsyncs them into /opt/gitnexus/indexes// on the droplet
-# 4. Removes any stale folders on the droplet that are not main/dev
-# 5. Pulls latest image, force-recreates gitnexus, reloads Caddy,
-# and polls docker health until the container reports healthy
-# The caddy container is untouched — no TLS churn.
-#
-# First-time droplet bootstrap (run once, manually):
-# 1. Create 2GB+ Ubuntu 24.04 droplet, add SSH key
-# 2. Point DNS A record for your subdomain at the droplet IP
-# 3. SSH in and run:
-# curl -fsSL https://get.docker.com | sh
-# systemctl enable --now docker
-# mkdir -p /opt/gitnexus/indexes
-# useradd -m -s /bin/bash deploy
-# usermod -aG docker deploy
-# mkdir -p /home/deploy/.ssh
-# # Add deploy pubkey to /home/deploy/.ssh/authorized_keys
-# chown -R deploy:deploy /home/deploy/.ssh /opt/gitnexus
-# chmod 700 /home/deploy/.ssh
-# ufw allow 22,80,443/tcp
-# ufw --force enable
-# 4. Copy .do/gitnexus/docker-compose.yml and Caddyfile into /opt/gitnexus/
-# 5. Create /opt/gitnexus/.env with: GITNEXUS_DOMAIN=... and API_TOKEN=...
-# 6. cd /opt/gitnexus && docker compose up -d
-#
-# Then capture the droplet's SSH host key from your workstation and
-# save it as the GITNEXUS_DO_KNOWN_HOST secret (below) so CI can pin it:
-# ssh-keyscan -H gitnexus.yourdomain.com
-#
-# GHCR image: the workflow runs `docker login ghcr.io` on the droplet
-# on every deploy using GITHUB_TOKEN, so the package can stay private.
-# If you'd rather not have CI manage droplet auth, make the package
-# public under repo Settings -> Packages.
-#
-# Required GitHub secrets:
-# GITNEXUS_DO_HOST — droplet IP or hostname
-# GITNEXUS_DO_USER — SSH user (e.g. "deploy")
-# GITNEXUS_DO_SSH_KEY — private key matching the authorized pubkey
-# GITNEXUS_DO_KNOWN_HOST — output of `ssh-keyscan -H ` pinning the
-# droplet's host keys (prevents MITM/TOFU risk)
-
-name: GitNexus Deploy
-
-on:
- workflow_run:
- workflows: ['GitNexus Index']
- types: [completed]
- workflow_dispatch:
- inputs:
- pr_number:
- description: 'Optional PR number for status comments from bot-triggered dispatches'
- type: string
- default: ''
-
-permissions:
- actions: read
- contents: read
- pull-requests: write # post status comments on PR command dispatches
-
-# Global serialization. Earlier versions used per-ref concurrency with
-# cancel-in-progress so rapid pushes to the same ref coalesced but deploys
-# targeting different refs ran in parallel. That had a data race: the
-# prune-stale-indexes step computes its active_names up front, so if
-# deploy A is rsyncing /opt/gitnexus/indexes/LibreChat-pr-12580 while
-# deploy B (started slightly later with a different ref) prunes, B can
-# rm -rf a folder A is still uploading into.
-#
-# All deploys now queue behind a single group. cancel-in-progress is
-# false so a running rsync/docker-compose restart never gets killed
-# mid-operation (which would leave the droplet in a partial state).
-# The 20-minute job timeout bounds total queue depth.
-concurrency:
- group: gitnexus-deploy
- cancel-in-progress: false
-
-env:
- GITNEXUS_VERSION: '1.6.7'
- IMAGE_NAME: ghcr.io/${{ github.repository_owner }}/librechat-gitnexus
-
-jobs:
- # Rebuilds the long-lived image only when Dockerfile/entrypoint/extensions
- # change. Skipped on every other run, so index-only deploys are fast.
- build-image:
- if: |
- github.event_name == 'workflow_dispatch' ||
- (
- github.event.workflow_run.conclusion == 'success' &&
- github.event.workflow_run.event == 'push' &&
- (github.event.workflow_run.head_branch == 'main' ||
- github.event.workflow_run.head_branch == 'dev')
- )
- runs-on: ubuntu-latest
- timeout-minutes: 20
- permissions:
- contents: read
- packages: write # push image to GHCR
- outputs:
- image_tag: ${{ steps.tag.outputs.value }}
- steps:
- - name: Checkout
- uses: actions/checkout@v4
- with:
- fetch-depth: 2
-
- - name: Detect image changes
- id: changes
- run: |
- # Default to rebuild when we can't cleanly diff (first commit,
- # workflow_run from a PR branch where HEAD isn't the trigger, etc).
- # Rebuild on miss > skip when we should have rebuilt.
- if git rev-parse --verify HEAD~1 >/dev/null 2>&1 && \
- git diff --quiet HEAD~1 HEAD -- .do/gitnexus/Dockerfile .do/gitnexus/entrypoint.sh .do/gitnexus/install-extensions.js; then
- echo "changed=false" >> "$GITHUB_OUTPUT"
- else
- echo "changed=true" >> "$GITHUB_OUTPUT"
- fi
-
- - name: Compute image tag
- id: tag
- run: echo "value=v${{ env.GITNEXUS_VERSION }}" >> "$GITHUB_OUTPUT"
-
- - name: Set up Docker Buildx
- if: steps.changes.outputs.changed == 'true' || github.event_name == 'workflow_dispatch'
- uses: docker/setup-buildx-action@v3
-
- - name: Log in to GHCR
- if: steps.changes.outputs.changed == 'true' || github.event_name == 'workflow_dispatch'
- uses: docker/login-action@v3
- with:
- registry: ghcr.io
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Build and push image
- if: steps.changes.outputs.changed == 'true' || github.event_name == 'workflow_dispatch'
- uses: docker/build-push-action@v5
- with:
- context: .do/gitnexus
- file: .do/gitnexus/Dockerfile
- push: true
- tags: |
- ${{ env.IMAGE_NAME }}:latest
- ${{ env.IMAGE_NAME }}:${{ steps.tag.outputs.value }}
- build-args: |
- GITNEXUS_VERSION=${{ env.GITNEXUS_VERSION }}
- cache-from: type=gha
- cache-to: type=gha,mode=max
-
- deploy:
- needs: build-image
- runs-on: ubuntu-latest
- timeout-minutes: 20
- permissions:
- actions: read
- contents: read
- pull-requests: write # post deploy-complete comments on PR command dispatches
- steps:
- - name: Checkout deploy config
- uses: actions/checkout@v4
- with:
- sparse-checkout: .do/gitnexus
- fetch-depth: 1
-
- # Resolve every index to serve. All resolutions go through
- # listArtifactsForRepo keyed by the expected artifact name, so a
- # run's branch or event type doesn't matter — we always pick the
- # freshest artifact that actually exists.
- #
- # Why this matters: a /gitnexus index command dispatches
- # gitnexus-index.yml with ref=main and an input pr_number, which
- # produces a run whose head_branch is "main" but whose artifact
- # is gitnexus-index-pr-. listWorkflowRuns(branch='main') would
- # happily return that run, and we'd then try to download a
- # nonexistent gitnexus-index-main artifact from it. Querying by
- # artifact name directly avoids the whole mess.
- - name: Resolve indexes to serve
- id: resolve
- uses: actions/github-script@v7
- with:
- script: |
- const serve = []; // [{ name, artifactName, runId }]
-
- // Helper — pick the newest non-expired artifact matching a name.
- const latestArtifact = async (artifactName) => {
- const { data } = await github.rest.actions.listArtifactsForRepo({
- owner: context.repo.owner,
- repo: context.repo.repo,
- name: artifactName,
- per_page: 10,
- });
- return data.artifacts
- .filter((a) => !a.expired)
- .sort((a, b) => new Date(b.created_at) - new Date(a.created_at))[0];
- };
-
- // --- main and dev branches ---
- for (const [branch, name] of [
- ['main', 'LibreChat'],
- ['dev', 'LibreChat-dev'],
- ]) {
- const artifactName = `gitnexus-index-${branch}`;
- const fresh = await latestArtifact(artifactName);
- if (!fresh) {
- core.warning(`No artifact found for ${branch} (expected ${artifactName})`);
- continue;
- }
- serve.push({
- name,
- artifactName,
- runId: fresh.workflow_run.id,
- });
- core.info(`${branch}: run ${fresh.workflow_run.id} -> ${name}`);
- }
-
- core.info('PR index deploys are paused; serving main and dev only.');
-
- if (!serve.length) {
- core.setFailed('No indexes to serve');
- return;
- }
-
- core.setOutput('matrix', JSON.stringify(serve));
- core.setOutput('active_names', serve.map((s) => s.name).join(','));
-
- - name: Download each index artifact
- env:
- MATRIX: ${{ steps.resolve.outputs.matrix }}
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: |
- set -e
- mkdir -p staging
- # main/dev artifact download failures are fatal — a missing
- # main/dev index is a real deploy failure. PR artifact failures
- # are soft — a PR artifact deleted mid-deploy shouldn't abort
- # the whole deploy and take main/dev down with it.
- echo "$MATRIX" | jq -c '.[]' | while read -r entry; do
- name=$(echo "$entry" | jq -r '.name')
- artifact=$(echo "$entry" | jq -r '.artifactName')
- runId=$(echo "$entry" | jq -r '.runId')
- target="staging/${name}/.gitnexus"
- echo "Downloading $artifact from run $runId -> $target"
- mkdir -p "$target"
- if ! gh run download "$runId" \
- --repo "${{ github.repository }}" \
- --name "$artifact" \
- --dir "$target"; then
- case "$name" in
- LibreChat|LibreChat-dev)
- echo "::error::Failed to download critical artifact $artifact"
- exit 1
- ;;
- *)
- # The name stays in active_names so the prune step
- # won't remove the droplet's existing copy. The old
- # index keeps being served instead of being wiped to
- # nothing — stale beats empty — but observability
- # requires an explicit notice since this path is
- # invisible in the happy-path deploy log.
- echo "::warning::Failed to download PR artifact $artifact — skipping fresh sync; previous index (if any) will continue being served from the droplet"
- rm -rf "staging/${name}"
- ;;
- esac
- fi
- done
- echo ""
- echo "Staged for rsync:"
- du -sh staging/*/.gitnexus/ 2>/dev/null || echo "(none)"
-
- - name: Setup SSH
- env:
- SSH_KEY: ${{ secrets.GITNEXUS_DO_SSH_KEY }}
- KNOWN_HOST: ${{ secrets.GITNEXUS_DO_KNOWN_HOST }}
- run: |
- set -e
- mkdir -p ~/.ssh
- chmod 700 ~/.ssh
- printf '%s\n' "$SSH_KEY" > ~/.ssh/deploy_key
- chmod 600 ~/.ssh/deploy_key
- # Pin the droplet's SSH host key from a repository secret instead
- # of trusting whatever ssh-keyscan returns at deploy time. The
- # secret is populated from `ssh-keyscan -H ` at bootstrap.
- if [ -z "$KNOWN_HOST" ]; then
- echo "::error::GITNEXUS_DO_KNOWN_HOST secret is empty. Run ssh-keyscan -H and paste the output as this secret."
- exit 1
- fi
- printf '%s\n' "$KNOWN_HOST" > ~/.ssh/known_hosts
- chmod 600 ~/.ssh/known_hosts
-
- - name: Authenticate droplet with GHCR
- # GHCR packages pushed by GITHUB_TOKEN start private. The droplet
- # pulls the image on every deploy, so we re-authenticate it here
- # using the same short-lived token. If the package is public, this
- # step is redundant but harmless.
- #
- # The token MUST travel through SSH stdin (not as a command arg)
- # so it's never visible in the droplet's process table via
- # /proc//cmdline. `printf '%s'` is preferred over `echo`
- # so the exact byte sequence sent is explicit — docker login
- # tolerates a trailing newline but `printf` makes the intent
- # obvious and portable across shells.
- env:
- SSH_USER: ${{ secrets.GITNEXUS_DO_USER }}
- SSH_HOST: ${{ secrets.GITNEXUS_DO_HOST }}
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- GH_ACTOR: ${{ github.actor }}
- run: |
- printf '%s' "$GH_TOKEN" | ssh -i ~/.ssh/deploy_key "$SSH_USER@$SSH_HOST" \
- "docker login ghcr.io -u '$GH_ACTOR' --password-stdin"
-
- - name: Upload config files
- env:
- SSH_USER: ${{ secrets.GITNEXUS_DO_USER }}
- SSH_HOST: ${{ secrets.GITNEXUS_DO_HOST }}
- run: |
- rsync -az -e "ssh -i ~/.ssh/deploy_key" \
- .do/gitnexus/docker-compose.yml \
- .do/gitnexus/Caddyfile \
- "$SSH_USER@$SSH_HOST:/opt/gitnexus/"
-
- - name: Prune stale indexes then sync fresh ones
- env:
- SSH_USER: ${{ secrets.GITNEXUS_DO_USER }}
- SSH_HOST: ${{ secrets.GITNEXUS_DO_HOST }}
- ACTIVE_NAMES: ${{ steps.resolve.outputs.active_names }}
- run: |
- set -e
- # ── Step 1: prune FIRST ────────────────────────────────
- # Remove any folders on the droplet that aren't in the active set.
- # This frees disk BEFORE rsyncing new data, which matters on a
- # 10GB disk where each current index is ~400MB.
- echo "Pruning stale indexes (keeping: $ACTIVE_NAMES)"
- ssh -i ~/.ssh/deploy_key "$SSH_USER@$SSH_HOST" \
- ACTIVE_NAMES="$ACTIVE_NAMES" bash <<'REMOTE'
- set -e
- cd /opt/gitnexus/indexes || exit 0
- shopt -s nullglob
- IFS=',' read -ra ACTIVE <<< "$ACTIVE_NAMES"
- for dir in */; do
- dir="${dir%/}"
- keep=false
- for a in "${ACTIVE[@]}"; do
- if [ "$dir" = "$a" ]; then keep=true; break; fi
- done
- if [ "$keep" = false ]; then
- echo "Removing stale index: $dir"
- rm -rf "$dir"
- fi
- done
- echo "Disk after prune:"
- df -h / | tail -1
- REMOTE
-
- # ── Step 2: rsync-then-swap ─────────────────────────────
- # Upload each index to a temp directory, then atomically swap
- # it into place. If rsync fails, the old index survives intact
- # and the partial temp dir is cleaned up — no production data
- # is lost. The brief period where both old + new exist costs
- # ~400MB of extra disk, but the prune step already freed
- # space from evicted indexes so this fits on a 10GB disk.
- for dir in staging/*/; do
- [ -d "$dir" ] || continue
- name=$(basename "$dir")
- echo "Syncing $name (rsync-then-swap)"
- ssh -i ~/.ssh/deploy_key "$SSH_USER@$SSH_HOST" \
- "mkdir -p /opt/gitnexus/indexes/${name}.new"
- if rsync -az -e "ssh -i ~/.ssh/deploy_key" \
- "$dir" \
- "$SSH_USER@$SSH_HOST:/opt/gitnexus/indexes/${name}.new/"; then
- # Swap: remove old, rename new into place
- ssh -i ~/.ssh/deploy_key "$SSH_USER@$SSH_HOST" \
- "rm -rf /opt/gitnexus/indexes/$name && mv /opt/gitnexus/indexes/${name}.new /opt/gitnexus/indexes/$name"
- echo " $name swapped successfully"
- else
- # Clean up the partial temp dir
- ssh -i ~/.ssh/deploy_key "$SSH_USER@$SSH_HOST" \
- "rm -rf /opt/gitnexus/indexes/${name}.new"
- # main/dev are critical — abort the deploy so the failure
- # is visible and the container isn't restarted with stale
- # or missing data. PR indexes are best-effort.
- case "$name" in
- LibreChat|LibreChat-dev)
- echo "::error::rsync failed for critical index $name — aborting deploy"
- exit 1
- ;;
- *)
- echo "::warning::rsync failed for PR index $name — keeping previous index"
- ;;
- esac
- fi
- done
-
- - name: Pull image, restart gitnexus, reload Caddy, wait for healthy
- env:
- SSH_USER: ${{ secrets.GITNEXUS_DO_USER }}
- SSH_HOST: ${{ secrets.GITNEXUS_DO_HOST }}
- run: |
- ssh -i ~/.ssh/deploy_key "$SSH_USER@$SSH_HOST" bash <<'REMOTE'
- set -e
- cd /opt/gitnexus
-
- # ── Disk cleanup ──────────────────────────────────────
- # Docker accumulates old image layers, dangling images, and
- # build cache across deploys. This droplet is only ~8.7GB
- # usable with a 700MB+ gitnexus image, so disk pressure is
- # constant. Prune everything not used by currently-running
- # containers BEFORE pulling the new image so the extract has
- # room; the post-recreate prune below reclaims the old image.
- echo "Disk before cleanup:"
- df -h / | tail -1
- # Omit --volumes: Caddy's caddy-data and caddy-config volumes
- # hold TLS certificates and ACME state. If Caddy happens to be
- # stopped when this runs (the workflow handles that case later),
- # --volumes would wipe them, forcing Let's Encrypt re-issuance
- # and risking rate-limit lockout (5 certs/domain/week).
- docker system prune -af 2>/dev/null || true
- echo "Disk after cleanup:"
- df -h / | tail -1
-
- # Fail fast if disk is critically low even after prune. The
- # gitnexus image is ~700MB and shares most layers with the
- # running one, so an incremental pull needs well under 1GB.
- # 1536MB leaves headroom on this small droplet without the
- # over-conservative 2GB guard aborting on a healthy box.
- AVAIL_MB=$(df --output=avail -m / | tail -1 | tr -d ' ')
- if [ "$AVAIL_MB" -lt 1536 ]; then
- echo "::error::Disk critically low (${AVAIL_MB}MB free). Aborting deploy."
- exit 1
- fi
-
- docker compose pull gitnexus
- docker compose up -d --force-recreate gitnexus
-
- # The previous gitnexus image is now dangling (the running
- # container was recreated onto the freshly pulled image). The
- # pre-pull prune above couldn't touch it because it was still
- # in use at that point. Reclaim it now so the old generation
- # doesn't accumulate — critical on this 10GB droplet.
- docker image prune -f 2>/dev/null || true
-
- # Reload Caddy in-place so a changed Caddyfile takes effect
- # without losing TLS certs or restarting connections. If caddy
- # isn't running yet (first-time bootstrap), bring it up.
- if docker compose ps --status running caddy 2>/dev/null | grep -q caddy; then
- echo "Reloading Caddy config"
- docker compose exec -T caddy caddy reload --config /etc/caddy/Caddyfile || {
- echo "Caddy reload failed — forcing restart"
- docker compose up -d --force-recreate caddy
- }
- else
- echo "Caddy not running — starting"
- docker compose up -d caddy
- fi
-
- # Poll gitnexus health until ready or timeout. Docker's own
- # unhealthy detection takes up to 150s (start_period 60s +
- # retries 3 * interval 30s), so the poll ceiling must clear
- # that to avoid false negatives when gitnexus legitimately
- # takes ~2.5 min to warm up.
- # Max wait = 36 sleeps * 5s = 180s (final iteration exits
- # before its sleep on failure, so 37 iterations is the
- # correct upper bound for a true 180s ceiling).
- echo "Waiting for gitnexus to report healthy..."
- for i in $(seq 1 37); do
- STATUS=$(docker inspect --format='{{.State.Health.Status}}' gitnexus 2>/dev/null || echo unknown)
- echo "[$i/37] gitnexus health: $STATUS"
- if [ "$STATUS" = "healthy" ]; then
- echo "gitnexus is healthy"
- break
- fi
- if [ "$i" -eq 37 ]; then
- echo "ERROR: gitnexus failed to become healthy after 180s"
- docker compose ps
- docker compose logs --tail 80 gitnexus
- exit 1
- fi
- sleep 5
- done
-
- docker compose ps
- echo "--- Caddy logs (last 20 lines) ---"
- docker compose logs --tail 20 caddy || true
- echo "--- GitNexus logs (last 30 lines) ---"
- docker compose logs --tail 30 gitnexus || true
- REMOTE
-
- # When the deploy was triggered by a PR command path, post a
- # terminal status comment on that one PR only. Two sub-cases:
- #
- # 1. workflow_run trigger: the PR's native auto-index run fired
- # workflow_run, so github.event.workflow_run.id is the trigger.
- # Find the matching PR via the matrix entry whose runId matches.
- #
- # 2. workflow_dispatch trigger with inputs.pr_number set: the
- # index workflow's bot-fallback path dispatched us directly
- # because workflow_run is suppressed for GITHUB_TOKEN triggers.
- # Use inputs.pr_number as the comment target.
- #
- # Broadcast-commenting on every active PR would be noise — only the
- # PR that asked for a fresh index gets a reply.
- - name: Comment on PR — deploy complete
- if: always()
- uses: actions/github-script@v7
- env:
- MATRIX: ${{ steps.resolve.outputs.matrix }}
- TRIGGER_RUN_ID: ${{ github.event.workflow_run.id }}
- DISPATCH_PR_NUMBER: ${{ github.event.inputs.pr_number }}
- DEPLOY_STATUS: ${{ job.status }}
- with:
- script: |
- const deployUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
- const matrix = JSON.parse(process.env.MATRIX || '[]');
- let prNum = null;
-
- // Case 1: dispatched directly with pr_number (bot-fallback path)
- if (process.env.DISPATCH_PR_NUMBER && process.env.DISPATCH_PR_NUMBER !== '') {
- const dispatchPrRaw = process.env.DISPATCH_PR_NUMBER;
- if (!/^\d+$/.test(dispatchPrRaw)) {
- core.setFailed(`Invalid PR number: ${dispatchPrRaw}`);
- return;
- }
-
- const dispatchPrNum = Number(dispatchPrRaw);
- const servedPr = matrix.some((m) => m.name === `LibreChat-pr-${dispatchPrNum}`);
-
- if (!servedPr) {
- const body = [
- '### GitNexus: PR deploy skipped',
- '',
- 'PR-specific deploys are paused; only `LibreChat` and `LibreChat-dev` are currently served.',
- `[Deploy run](${deployUrl})`,
- ].join('\n');
- await github.rest.issues.createComment({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: dispatchPrNum,
- body,
- });
- return;
- }
-
- prNum = dispatchPrNum;
- }
- // Case 2: workflow_run trigger from a PR index run
- else if (context.eventName === 'workflow_run') {
- const triggerRunId = Number(process.env.TRIGGER_RUN_ID);
- const match = matrix.find(
- (m) => m.runId === triggerRunId && m.name.startsWith('LibreChat-pr-'),
- );
- if (match) {
- prNum = parseInt(match.name.replace('LibreChat-pr-', ''), 10);
- }
- }
-
- if (!prNum) {
- core.info('No PR to comment on (trigger was not a PR-scoped index); skipping.');
- return;
- }
-
- const ok = process.env.DEPLOY_STATUS === 'success';
- const body = [
- `### GitNexus: ${ok ? '🚀 deployed' : '❌ deploy failed'}`,
- '',
- ok
- ? `The \`LibreChat-pr-${prNum}\` index is now live on the MCP server.`
- : `The deploy failed — the previous index (if any) continues to be served.`,
- `[Deploy run](${deployUrl})`,
- ].join('\n');
- await github.rest.issues.createComment({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: prNum,
- body,
- });
-
- - name: Cleanup SSH key
- if: always()
- run: rm -f ~/.ssh/deploy_key
diff --git a/.github/workflows/gitnexus-index.yml b/.github/workflows/gitnexus-index.yml
deleted file mode 100644
index 89f906f38dd..00000000000
--- a/.github/workflows/gitnexus-index.yml
+++ /dev/null
@@ -1,323 +0,0 @@
-name: GitNexus Index
-
-on:
- # PR branches are NOT auto-indexed — an embeddings run is too slow to
- # spend on every PR push. Only main/dev are indexed automatically;
- # individual PRs are indexed on demand via the /gitnexus command or a
- # manual workflow_dispatch.
- push:
- branches: [main, dev]
- paths-ignore: ['**.md', 'docs/**', 'LICENSE', '.github/**']
- workflow_dispatch:
- inputs:
- embeddings:
- description: 'Enable embedding generation (slow, increases index size)'
- type: boolean
- default: false
- force:
- description: 'Force full re-index'
- type: boolean
- default: false
- # When invoked from the /gitnexus index PR command, the command
- # workflow fills these so the index is built from the PR's head
- # ref and uploaded under the PR-numbered artifact name.
- pr_number:
- description: 'PR number to index (set by /gitnexus command)'
- type: string
- default: ''
- pr_ref:
- description: 'Optional PR head ref to check out; defaults to refs/pull//head when pr_number is set'
- type: string
- default: ''
- deploy_after:
- description: 'Dispatch GitNexus Deploy after a successful index run'
- type: boolean
- default: false
-
-permissions:
- contents: read
-
-concurrency:
- # When triggered by the /gitnexus command, group by PR number so rapid
- # re-runs coalesce. Otherwise group by git ref as before.
- group: gitnexus-${{ inputs.pr_number != '' && format('pr-{0}', inputs.pr_number) || github.ref }}
- cancel-in-progress: true
-
-env:
- GITNEXUS_VERSION: '1.6.7'
-
-jobs:
- index:
- permissions:
- contents: read
- pull-requests: read # read changed files to decide whether embeddings are needed
- # Push + dispatch run unconditionally. The pull_request trigger is
- # disabled (see `on:` above), so this never runs automatically on a
- # PR. PRs are indexed on demand instead:
- # - /gitnexus index (PR comment command, contributor-gated)
- # - workflow_dispatch (manual dispatch from Actions UI)
- # Both arrive as workflow_dispatch. The pull_request guard is kept as
- # a safety net should the trigger ever be re-added.
- if: |
- github.event_name != 'pull_request' ||
- github.event.pull_request.user.login == 'danny-avila'
- runs-on: ubuntu-latest
- # Embedding generation dominates the budget: ~45 min worst case on
- # standard runners since the 1.6.x graph (~23k nodes) doubled vs 1.5.x.
- timeout-minutes: 60
- # Best-effort index: a tool-internal crash must not block PRs. Fail soft on
- # PR events; push/dispatch runs still fail loudly so regressions stay visible.
- continue-on-error: ${{ github.event_name == 'pull_request' }}
- steps:
- - name: Validate dispatch inputs
- if: github.event_name == 'workflow_dispatch'
- env:
- PR_NUMBER: ${{ inputs.pr_number }}
- PR_REF: ${{ inputs.pr_ref }}
- run: |
- set -euo pipefail
- if [ -n "$PR_NUMBER" ]; then
- if [[ ! "$PR_NUMBER" =~ ^[0-9]+$ ]]; then
- echo "::error::pr_number must be numeric"
- exit 1
- fi
- EXPECTED_REF="refs/pull/${PR_NUMBER}/head"
- if [ -n "$PR_REF" ] && [ "$PR_REF" != "$EXPECTED_REF" ]; then
- echo "::error::pr_ref must match ${EXPECTED_REF}"
- exit 1
- fi
- elif [ -n "$PR_REF" ]; then
- echo "::error::pr_ref requires pr_number"
- exit 1
- fi
-
- - name: Resolve GitNexus flags
- id: flags
- env:
- EVENT_NAME: ${{ github.event_name }}
- ENABLE_EMBEDDINGS_INPUT: ${{ inputs.embeddings }}
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- PR_NUM: ${{ github.event.pull_request.number }}
- run: |
- set -euo pipefail
-
- # Decide whether to generate embeddings. Rules:
- # push (main/dev) -> always embed
- # pull_request -> embed ONLY when the PR changes files
- # under paths that also trigger backend
- # or frontend unit tests (api/, client/,
- # packages/). Docs/config-only PRs skip
- # embeddings to save ~3-5 min of CI.
- # workflow_dispatch -> respect the explicit `embeddings` input
- # (default false). This also covers the
- # /gitnexus index [embeddings] command.
- ENABLE_EMBEDDINGS=false
- case "$EVENT_NAME" in
- workflow_dispatch)
- [ "$ENABLE_EMBEDDINGS_INPUT" = "true" ] && ENABLE_EMBEDDINGS=true
- ;;
- push)
- ENABLE_EMBEDDINGS=true
- ;;
- pull_request)
- CHANGED=$(gh api "repos/${{ github.repository }}/pulls/$PR_NUM/files" \
- --paginate --jq '.[].filename' 2>/dev/null || echo "")
- if printf '%s\n' "$CHANGED" | grep -qE '^(api/|client/|packages/)'; then
- echo "PR #$PR_NUM touches unit-test paths (api|client|packages) — enabling embeddings"
- ENABLE_EMBEDDINGS=true
- else
- echo "PR #$PR_NUM does not touch unit-test paths — graph-only index"
- fi
- ;;
- esac
-
- if [ "$ENABLE_EMBEDDINGS" = "true" ]; then
- echo "enable_embeddings=true" >> "$GITHUB_OUTPUT"
- else
- echo "enable_embeddings=false" >> "$GITHUB_OUTPUT"
- fi
-
- - name: Setup Node.js
- uses: actions/setup-node@v4
- with:
- node-version: '24.16.0'
-
- - name: Install GitNexus CLI
- working-directory: ${{ runner.temp }}
- env:
- NPM_CONFIG_AUDIT: false
- NPM_CONFIG_CACHE: ${{ runner.temp }}/gitnexus-npm-cache
- NPM_CONFIG_FUND: false
- NPM_CONFIG_GLOBALCONFIG: ${{ runner.temp }}/gitnexus-cli/global-npmrc
- NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
- NPM_CONFIG_USERCONFIG: ${{ runner.temp }}/gitnexus-cli/.npmrc
- run: |
- set -euo pipefail
- mkdir -p "$RUNNER_TEMP/gitnexus-cli" "$RUNNER_TEMP/gitnexus-npm-cache"
- : > "$RUNNER_TEMP/gitnexus-cli/global-npmrc"
- printf '%s\n' \
- 'registry=https://registry.npmjs.org/' \
- 'audit=false' \
- 'fund=false' \
- > "$RUNNER_TEMP/gitnexus-cli/.npmrc"
- # Keep GitNexus' native DB dependency deterministic in fresh CI installs.
- npm install \
- --prefix "$RUNNER_TEMP/gitnexus-cli" \
- --no-save \
- --no-package-lock \
- "gitnexus@${{ env.GITNEXUS_VERSION }}" \
- "@ladybugdb/core@0.17.1"
- test -x "$RUNNER_TEMP/gitnexus-cli/node_modules/.bin/gitnexus"
-
- - name: Checkout repository
- uses: actions/checkout@v4
- with:
- # When the /gitnexus command dispatches us with a pr_ref, it's
- # a refs/pull//head ref that GitHub mirrors into the base
- # repo for every PR, so checkout works for fork PRs too. When
- # pr_ref is empty (native push/pull_request), fall back to the
- # default ref actions/checkout would use.
- ref: ${{ inputs.pr_ref || (inputs.pr_number != '' && format('refs/pull/{0}/head', inputs.pr_number) || '') }}
- fetch-depth: 1
- persist-credentials: false
-
- # HuggingFace throttles anonymous model downloads from shared GHA
- # runner IPs (429s or stalled transfers). Cache the embedding model
- # across runs so warm runs never touch HF at all.
- - name: Cache HuggingFace embedding model
- if: steps.flags.outputs.enable_embeddings == 'true'
- uses: actions/cache@v4
- with:
- path: ${{ runner.temp }}/hf-cache
- key: hf-model-snowflake-arctic-embed-xs-v1
-
- - name: Run GitNexus Analyze
- working-directory: ${{ runner.temp }}
- env:
- ENABLE_EMBEDDINGS: ${{ steps.flags.outputs.enable_embeddings }}
- FORCE: ${{ inputs.force }}
- GITNEXUS_BIN: ${{ runner.temp }}/gitnexus-cli/node_modules/.bin/gitnexus
- # Fail soft in ~2 min on stalled downloads instead of eating the
- # 25-min job budget; HF_TOKEN lifts the anonymous rate limit on
- # cold-cache runs (empty when the secret is unset — safe no-op).
- HF_DOWNLOAD_TIMEOUT_MS: '60000'
- HF_HOME: ${{ runner.temp }}/hf-cache
- HF_MAX_ATTEMPTS: '2'
- HF_TOKEN: ${{ secrets.HF_TOKEN }}
- NPM_CONFIG_AUDIT: false
- NPM_CONFIG_CACHE: ${{ runner.temp }}/gitnexus-npm-cache
- NPM_CONFIG_FUND: false
- NPM_CONFIG_GLOBALCONFIG: ${{ runner.temp }}/gitnexus-cli/global-npmrc
- NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
- NPM_CONFIG_USERCONFIG: ${{ runner.temp }}/gitnexus-cli/.npmrc
- run: |
- set -euo pipefail
- FLAGS=(--skip-agents-md --verbose)
-
- if [ "$ENABLE_EMBEDDINGS" = "true" ]; then
- FLAGS+=(--embeddings)
- fi
- if [ "$FORCE" = "true" ]; then
- FLAGS+=(--force)
- fi
- "$GITNEXUS_BIN" analyze "$GITHUB_WORKSPACE" "${FLAGS[@]}"
-
- - name: Verify index
- run: |
- if [ ! -d ".gitnexus" ] || [ ! -f ".gitnexus/meta.json" ]; then
- echo "::error::GitNexus index was not created"
- exit 1
- fi
- echo "::group::Index metadata"
- cat .gitnexus/meta.json
- echo ""
- echo "::endgroup::"
-
- - name: Upload GitNexus index
- uses: actions/upload-artifact@v4
- with:
- # Artifact naming order of precedence:
- # 1. /gitnexus command dispatch: inputs.pr_number -> pr-
- # 2. Native pull_request event: github.event.pull_request.number
- # 3. Push or manual dispatch without pr_number: github.ref_name
- name: >-
- gitnexus-index-${{
- inputs.pr_number != ''
- && format('pr-{0}', inputs.pr_number)
- || (github.event_name == 'pull_request'
- && format('pr-{0}', github.event.pull_request.number)
- || github.ref_name)
- }}
- path: .gitnexus/
- include-hidden-files: true
- retention-days: 30
-
- post-index:
- needs: index
- if: |
- always() &&
- (inputs.pr_number != '' ||
- inputs.deploy_after)
- runs-on: ubuntu-latest
- timeout-minutes: 5
- permissions:
- contents: read
- actions: write # dispatch gitnexus-deploy.yml when deploy_after is set
- pull-requests: write # post completion comments for /gitnexus command runs
- steps:
- # GitHub suppresses workflow_run events for workflow runs triggered
- # by GITHUB_TOKEN (to prevent recursive chaining). Dispatches without
- # a PR number can still opt into a deploy by setting deploy_after=true.
- - name: Trigger deploy workflow after non-PR dispatches
- if: inputs.deploy_after && inputs.pr_number == '' && needs.index.result == 'success'
- uses: actions/github-script@v7
- with:
- script: |
- core.info('deploy_after=true; dispatching gitnexus-deploy.yml manually.');
- await github.rest.actions.createWorkflowDispatch({
- owner: context.repo.owner,
- repo: context.repo.repo,
- workflow_id: 'gitnexus-deploy.yml',
- ref: 'main',
- inputs: {
- pr_number: '',
- },
- });
-
- # Reply on the PR when the /gitnexus command path runs so the
- # requester knows the index step finished. This fires when
- # inputs.pr_number is set and reports the index job result.
- - name: Comment on PR — index complete
- if: inputs.pr_number != ''
- uses: actions/github-script@v7
- env:
- EMBEDDINGS_INPUT: ${{ inputs.embeddings }}
- INDEX_RESULT: ${{ needs.index.result }}
- PR_NUMBER: ${{ inputs.pr_number }}
- with:
- script: |
- const indexSucceeded = process.env.INDEX_RESULT === 'success';
- const outcome = indexSucceeded ? '✅ indexed' : '❌ index failed';
- const prNum = parseInt(process.env.PR_NUMBER || '', 10);
- if (!Number.isSafeInteger(prNum)) {
- core.setFailed(`Invalid PR number: ${process.env.PR_NUMBER}`);
- return;
- }
- const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
- const embeddingsFlag = process.env.EMBEDDINGS_INPUT === 'true' ? 'with embeddings' : 'graph-only';
- const body = [
- `### GitNexus: ${outcome}`,
- ``,
- `PR #${prNum} was indexed ${embeddingsFlag}.`,
- `[Index run](${runUrl})`,
- '',
- indexSucceeded
- ? 'PR-specific deploys are paused; only `LibreChat` and `LibreChat-dev` are currently served.'
- : '_Index run failed — the previous index (if any) continues to be served._',
- ].join('\n');
- await github.rest.issues.createComment({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: prNum,
- body,
- });
diff --git a/.github/workflows/gitnexus-pr-command.yml b/.github/workflows/gitnexus-pr-command.yml
deleted file mode 100644
index 214a526897f..00000000000
--- a/.github/workflows/gitnexus-pr-command.yml
+++ /dev/null
@@ -1,141 +0,0 @@
-# Responds to `/gitnexus index` comments on pull requests.
-#
-# Gated to the same author_association roles (OWNER, MEMBER, COLLABORATOR)
-# as the automatic PR index trigger, but applied to the COMMENTER, not
-# the PR author. This intentionally lets a contributor index a PR from
-# a non-contributor / first-time fork author — the contributor takes
-# responsibility for the trust boundary by typing the command.
-#
-# When a matching comment lands on a PR, this workflow dispatches
-# `gitnexus-index.yml` with the PR number and the `refs/pull//head`
-# ref so indexing works for fork PRs too (GitHub mirrors every PR's
-# head ref into the base repo regardless of which fork it originated
-# from, so actions/checkout can always resolve it).
-#
-# Use cases:
-# - Re-index a PR after a rebase without pushing a new commit
-# - Index a docs-only PR that was skipped by paths-ignore
-# - Index a non-contributor (fork) PR that the auto-trigger skipped
-# - Re-run a failed index
-#
-# Supported commands:
-# /gitnexus index — index the PR with embeddings (default)
-# /gitnexus index embeddings — explicit form of the above; same effect
-# /gitnexus index fast — graph-only index (skip embeddings), for
-# a quick re-index without waiting ~5 min
-# of embedding generation
-
-name: GitNexus PR Command
-
-on:
- issue_comment:
- types: [created]
-
-permissions:
- contents: read
- pull-requests: write
- actions: write # needed to dispatch gitnexus-index.yml
-
-concurrency:
- group: gitnexus-pr-command-${{ github.event.issue.number }}
- cancel-in-progress: false
-
-jobs:
- dispatch:
- # Only run for PR comments that start with /gitnexus from trusted
- # commenters. Intentionally checks the COMMENTER's association so a
- # contributor can index a non-contributor's PR on demand.
- if: |
- github.event.issue.pull_request != null &&
- startsWith(github.event.comment.body, '/gitnexus') &&
- (github.event.comment.author_association == 'OWNER' ||
- github.event.comment.author_association == 'MEMBER' ||
- github.event.comment.author_association == 'COLLABORATOR')
- runs-on: ubuntu-latest
- timeout-minutes: 5
- steps:
- - name: Parse command and resolve PR head ref
- id: parse
- uses: actions/github-script@v7
- with:
- script: |
- const body = context.payload.comment.body.trim();
- const match = body.match(/^\/gitnexus\s+(\w+)(?:\s+(\w+))?/);
- if (!match) {
- core.setFailed(`Unrecognized command: ${body}. Try: /gitnexus index [fast]`);
- return;
- }
- const [, subcommand, modifier] = match;
- if (subcommand !== 'index') {
- core.setFailed(`Unknown subcommand: ${subcommand}. Only 'index' is supported.`);
- return;
- }
- // Default to embeddings on — a contributor typing the command
- // has already decided they want a full re-index. The `fast`
- // modifier is the explicit opt-out for graph-only runs.
- // `embeddings` is accepted as a no-op alias for backwards
- // compat with the previous command form.
- let embeddings = 'true';
- if (modifier === 'fast' || modifier === 'graph-only' || modifier === 'no-embeddings') {
- embeddings = 'false';
- }
-
- // Use refs/pull//head instead of the raw head SHA. GitHub
- // mirrors every PR's head into the base repo as this ref, so
- // actions/checkout can always resolve it — even for PRs from
- // forks whose raw SHAs don't exist in the base repo.
- const prNum = context.payload.issue.number;
- core.setOutput('pr_number', String(prNum));
- core.setOutput('pr_ref', `refs/pull/${prNum}/head`);
- core.setOutput('embeddings', embeddings);
- core.info(
- `Dispatching index for PR #${prNum} at refs/pull/${prNum}/head (embeddings=${embeddings}, modifier=${modifier || '(none)'})`,
- );
-
- - name: Dispatch gitnexus-index workflow
- uses: actions/github-script@v7
- env:
- EMBEDDINGS: ${{ steps.parse.outputs.embeddings }}
- PR_NUMBER: ${{ steps.parse.outputs.pr_number }}
- PR_REF: ${{ steps.parse.outputs.pr_ref }}
- with:
- script: |
- const prNumber = process.env.PR_NUMBER || '';
- const prRef = process.env.PR_REF || '';
- const embeddings = process.env.EMBEDDINGS || 'false';
- if (!/^[0-9]+$/.test(prNumber)) {
- core.setFailed(`Invalid PR number: ${prNumber}`);
- return;
- }
- if (prRef !== `refs/pull/${prNumber}/head`) {
- core.setFailed(`Invalid PR ref: ${prRef}`);
- return;
- }
- if (!['true', 'false'].includes(embeddings)) {
- core.setFailed(`Invalid embeddings value: ${embeddings}`);
- return;
- }
- await github.rest.actions.createWorkflowDispatch({
- owner: context.repo.owner,
- repo: context.repo.repo,
- workflow_id: 'gitnexus-index.yml',
- ref: 'main',
- inputs: {
- pr_number: prNumber,
- pr_ref: prRef,
- embeddings,
- force: 'false',
- deploy_after: 'true',
- },
- });
-
- - name: React to the comment
- uses: actions/github-script@v7
- with:
- script: |
- await github.rest.reactions.createForIssueComment({
- owner: context.repo.owner,
- repo: context.repo.repo,
- comment_id: context.payload.comment.id,
- content: 'rocket',
- });
diff --git a/.github/workflows/helmcharts.yml b/.github/workflows/helmcharts.yml
index 9e0308ec727..659c210bec8 100644
--- a/.github/workflows/helmcharts.yml
+++ b/.github/workflows/helmcharts.yml
@@ -18,8 +18,6 @@ jobs:
contents: read
packages: write
runs-on: ubuntu-latest
- env:
- CHART_REPOSITORY: ${{ github.repository_owner }}/librechat-chart
steps:
- name: Resolve chart tag
id: chart-version
@@ -41,14 +39,16 @@ jobs:
exit 1
fi
+ # OCI repository names must be lowercase, and the repository owner is not guaranteed to be.
{
+ printf 'CHART_REPOSITORY=%s/librechat-chart\n' "${GITHUB_REPOSITORY_OWNER,,}"
printf 'CHART_REF=refs/tags/%s\n' "$CHART_TAG"
printf 'CHART_TAG=%s\n' "$CHART_TAG"
printf 'CHART_VERSION=%s\n' "$CHART_VERSION"
} >> "$GITHUB_OUTPUT"
- name: Checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@v5
with:
fetch-depth: 0
persist-credentials: false
@@ -60,20 +60,20 @@ jobs:
git config user.email "$GITHUB_ACTOR@users.noreply.github.com"
- name: Install Helm
- uses: azure/setup-helm@v4
+ uses: azure/setup-helm@v5
env:
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
- name: Build Subchart Deps
run: |
- cd helm/librechat
- helm dependency build
- cd ../librechat-rag-api
- helm dependency build
+ cd helm/librechat-rag-api
+ helm dependency build
+ cd ../librechat
+ helm dependency build
# Log in to GitHub Container Registry
- name: Log in to GitHub Container Registry
- uses: docker/login-action@v3
+ uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
@@ -85,7 +85,7 @@ jobs:
uses: appany/helm-oci-chart-releaser@v0.4.2
with:
name: librechat
- repository: ${{ env.CHART_REPOSITORY }}
+ repository: ${{ steps.chart-version.outputs.CHART_REPOSITORY }}
tag: ${{ steps.chart-version.outputs.CHART_VERSION }}
path: helm/librechat
registry: ghcr.io
@@ -97,7 +97,7 @@ jobs:
uses: appany/helm-oci-chart-releaser@v0.4.2
with:
name: librechat-rag-api
- repository: ${{ env.CHART_REPOSITORY }}
+ repository: ${{ steps.chart-version.outputs.CHART_REPOSITORY }}
tag: ${{ steps.chart-version.outputs.CHART_VERSION }}
path: helm/librechat-rag-api
registry: ghcr.io
diff --git a/.github/workflows/i18n-unused-keys.yml b/.github/workflows/i18n-unused-keys.yml
deleted file mode 100644
index 6341c19d142..00000000000
--- a/.github/workflows/i18n-unused-keys.yml
+++ /dev/null
@@ -1,150 +0,0 @@
-name: Detect Unused i18next Strings
-
-# This workflow checks for unused i18n keys in translation files.
-# It has special handling for:
-# - com_ui_special_var_* keys that are dynamically constructed
-# - com_agents_category_* keys that are stored in the database and used dynamically
-
-on:
- pull_request:
- paths:
- - "client/src/**"
- - "api/**"
- - "packages/data-provider/src/**"
- - "packages/client/**"
- - "packages/data-schemas/src/**"
-
-jobs:
- detect-unused-i18n-keys:
- runs-on: ubuntu-latest
- permissions:
- contents: read
- pull-requests: write
- steps:
- - name: Checkout repository
- uses: actions/checkout@v4
-
- - name: Find unused i18next keys
- id: find-unused
- run: |
- echo "🔍 Scanning for unused i18next keys..."
-
- # Define paths
- I18N_FILE="client/src/locales/en/translation.json"
- SOURCE_DIRS=("client/src" "api" "packages/data-provider/src" "packages/client" "packages/data-schemas/src")
-
- # Check if translation file exists
- if [[ ! -f "$I18N_FILE" ]]; then
- echo "::error title=Missing i18n File::Translation file not found: $I18N_FILE"
- exit 1
- fi
-
- # Extract all keys from the JSON file
- KEYS=$(jq -r 'keys[]' "$I18N_FILE")
-
- # Track unused keys
- UNUSED_KEYS=()
-
- # Check if each key is used in the source code
- for KEY in $KEYS; do
- FOUND=false
-
- # Special case for dynamically constructed special variable keys
- if [[ "$KEY" == com_ui_special_var_* ]]; then
- # Check if TSpecialVarLabel is used in the codebase
- for DIR in "${SOURCE_DIRS[@]}"; do
- if grep -r --include=\*.{js,jsx,ts,tsx} -q "TSpecialVarLabel" "$DIR"; then
- FOUND=true
- break
- fi
- done
-
- # Also check if the key is directly used somewhere
- if [[ "$FOUND" == false ]]; then
- for DIR in "${SOURCE_DIRS[@]}"; do
- if grep -r --include=\*.{js,jsx,ts,tsx} -q "$KEY" "$DIR"; then
- FOUND=true
- break
- fi
- done
- fi
- # Special case for agent category keys that are dynamically used from database
- elif [[ "$KEY" == com_agents_category_* ]]; then
- # Check if agent category localization is being used
- for DIR in "${SOURCE_DIRS[@]}"; do
- # Check for dynamic category label/description usage
- if grep -r --include=\*.{js,jsx,ts,tsx} -E "category\.(label|description).*startsWith.*['\"]com_" "$DIR" > /dev/null 2>&1 || \
- # Check for the method that defines these keys
- grep -r --include=\*.{js,jsx,ts,tsx} "ensureDefaultCategories" "$DIR" > /dev/null 2>&1 || \
- # Check for direct usage in agentCategory.ts
- grep -r --include=\*.ts -E "label:.*['\"]$KEY['\"]" "$DIR" > /dev/null 2>&1 || \
- grep -r --include=\*.ts -E "description:.*['\"]$KEY['\"]" "$DIR" > /dev/null 2>&1; then
- FOUND=true
- break
- fi
- done
-
- # Also check if the key is directly used somewhere
- if [[ "$FOUND" == false ]]; then
- for DIR in "${SOURCE_DIRS[@]}"; do
- if grep -r --include=\*.{js,jsx,ts,tsx} -q "$KEY" "$DIR"; then
- FOUND=true
- break
- fi
- done
- fi
- else
- # Regular check for other keys
- for DIR in "${SOURCE_DIRS[@]}"; do
- if grep -r --include=\*.{js,jsx,ts,tsx} -q "$KEY" "$DIR"; then
- FOUND=true
- break
- fi
- done
- fi
-
- if [[ "$FOUND" == false ]]; then
- UNUSED_KEYS+=("$KEY")
- fi
- done
-
- # Output results
- if [[ ${#UNUSED_KEYS[@]} -gt 0 ]]; then
- echo "🛑 Found ${#UNUSED_KEYS[@]} unused i18n keys:"
- echo "unused_keys=$(echo "${UNUSED_KEYS[@]}" | jq -R -s -c 'split(" ")')" >> $GITHUB_ENV
- for KEY in "${UNUSED_KEYS[@]}"; do
- echo "::warning title=Unused i18n Key::'$KEY' is defined but not used in the codebase."
- done
- else
- echo "✅ No unused i18n keys detected!"
- echo "unused_keys=[]" >> $GITHUB_ENV
- fi
-
- - name: Post verified comment on PR
- if: env.unused_keys != '[]'
- run: |
- PR_NUMBER=$(jq --raw-output .pull_request.number "$GITHUB_EVENT_PATH")
-
- # Format the unused keys list as checkboxes for easy manual checking.
- FILTERED_KEYS=$(echo "$unused_keys" | jq -r '.[]' | grep -v '^\s*$' | sed 's/^/- [ ] `/;s/$/`/' )
-
- COMMENT_BODY=$(cat <&1 | tee lighthouse-ci.log
+ - name: Comment Lighthouse findings
+ if: ${{ failure() && steps.audit.outcome == 'failure' && github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository }}
+ env:
+ GH_TOKEN: ${{ github.token }}
+ PR_NUMBER: ${{ github.event.pull_request.number }}
+ run: |
+ {
+ echo 'Lighthouse CI failed. The last 80 log lines contain the measured budgets and assertion failures.'
+ echo
+ echo '```text'
+ tail -n 80 lighthouse-ci.log
+ echo '```'
+ echo
+ echo "[Open the full run]($GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID)"
+ } > lighthouse-comment.md
+ gh pr comment "$PR_NUMBER" --body-file lighthouse-comment.md
+ - name: Upload Lighthouse reports
+ if: ${{ !cancelled() }}
+ uses: actions/upload-artifact@v6
+ with:
+ name: lighthouse
+ path: |
+ .lighthouse/
+ lighthouse-ci.log
+ include-hidden-files: true
+ retention-days: 7
diff --git a/.github/workflows/locize-i18n-sync.yml b/.github/workflows/locize-i18n-sync.yml
index c0b9af5a5f7..9798de22cb8 100644
--- a/.github/workflows/locize-i18n-sync.yml
+++ b/.github/workflows/locize-i18n-sync.yml
@@ -2,9 +2,16 @@ name: Sync Locize Translations & Create Translation PR
on:
push:
- branches: [main]
+ branches: [dev]
+ paths:
+ - 'client/src/locales/en/**'
repository_dispatch:
types: [locize/versionPublished]
+ workflow_dispatch:
+
+concurrency:
+ group: locize-i18n-sync
+ cancel-in-progress: false
permissions:
contents: read
@@ -15,19 +22,20 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout Repository
- uses: actions/checkout@v4
+ uses: actions/checkout@v5
with:
persist-credentials: false
- name: Set Up Node.js
- uses: actions/setup-node@v4
+ uses: actions/setup-node@v5
with:
node-version: '24.16.0'
- name: Install locize CLI
run: npm install -g locize-cli@12.2.0 --ignore-scripts --no-audit --no-fund
- # Sync translations (Push missing keys & remove deleted ones)
+ # Git owns English source values. Push changed values to Locize without
+ # allowing a stale checkout to delete keys that still exist remotely.
- name: Sync Locize with Repository
if: ${{ github.event_name == 'push' }}
env:
@@ -35,7 +43,7 @@ jobs:
LOCIZE_PROJECT_ID: ${{ secrets.LOCIZE_PROJECT_ID }}
run: |
cd client/src/locales
- locize sync --cdn-type pro --api-key "$LOCIZE_API_KEY" --project-id "$LOCIZE_PROJECT_ID" --language en
+ locize sync --cdn-type pro --api-key "$LOCIZE_API_KEY" --project-id "$LOCIZE_PROJECT_ID" --language en --skip-delete true --update-values true
# When triggered by repository_dispatch, skip sync step.
- name: Skip sync step on non-push events
@@ -44,35 +52,65 @@ jobs:
create-pull-request:
name: Create Translation PR on Version Published
+ if: ${{ github.event_name == 'repository_dispatch' || github.event_name == 'workflow_dispatch' }}
runs-on: ubuntu-latest
needs: sync-translations
permissions:
- contents: read
+ contents: write
+ pull-requests: write
steps:
# 1. Check out the repository.
- name: Checkout Repository
- uses: actions/checkout@v4
+ uses: actions/checkout@v5
with:
+ ref: dev
persist-credentials: false
- # 2. Download translation files from locize.
+ # Keep a baseline so generated changes can be checked before opening a PR.
+ - name: Snapshot Repository Locales
+ run: cp -R client/src/locales "$RUNNER_TEMP/locize-locale-baseline"
+
+ # Download the latest published translation version from Locize.
- name: Download Translations from locize
uses: locize/download@v2
with:
project-id: ${{ secrets.LOCIZE_PROJECT_ID }}
path: "client/src/locales"
+ version: latest
+
+ - name: Preserve Repository Translations Missing from locize
+ run: |
+ node scripts/merge-locize-download.mjs \
+ --base-dir "$RUNNER_TEMP/locize-locale-baseline" \
+ --current-dir client/src/locales
+
+ - name: Restore Repository English Source
+ run: cp "$RUNNER_TEMP/locize-locale-baseline/en/translation.json" client/src/locales/en/translation.json
+
+ - name: Validate Downloaded Translations
+ run: |
+ node scripts/validate-locize-download.mjs \
+ --base-dir "$RUNNER_TEMP/locize-locale-baseline" \
+ --current-dir client/src/locales
+
+ - name: Upload Validated Translations
+ uses: actions/upload-artifact@v4
+ with:
+ name: locize-locales-${{ github.run_id }}
+ path: client/src/locales
+ if-no-files-found: error
+ retention-days: 1
- # 3. Create a Pull Request using a dedicated fine-grained PAT so this
- # workflow does not depend on the global GITHUB_TOKEN PR-creation setting.
+ # 3. Create a Pull Request using this workflow's scoped token.
- name: Create Pull Request
id: create-pull-request
- uses: peter-evans/create-pull-request@v7
+ uses: peter-evans/create-pull-request@v8
with:
- token: ${{ secrets.LOCIZE_PR_TOKEN }}
+ token: ${{ github.token }}
add-paths: |
client/src/locales/**
commit-message: "🌍 i18n: Update translation.json with latest translations"
- base: main
+ base: dev
branch: i18n/locize-translation-update
title: "🌍 i18n: Update translation.json with latest translations"
body: |
@@ -85,7 +123,7 @@ jobs:
- name: Request Reviewer
if: ${{ steps.create-pull-request.outputs.pull-request-number != '' }}
env:
- GH_TOKEN: ${{ secrets.LOCIZE_PR_TOKEN }}
+ GH_TOKEN: ${{ github.token }}
PR_NUMBER: ${{ steps.create-pull-request.outputs.pull-request-number }}
REVIEWER: danny-avila
run: |
diff --git a/.github/workflows/main-image-workflow.yml b/.github/workflows/main-image-workflow.yml
index e5f76fe26ef..69017308c3f 100644
--- a/.github/workflows/main-image-workflow.yml
+++ b/.github/workflows/main-image-workflow.yml
@@ -8,85 +8,53 @@ permissions:
packages: write
jobs:
- build:
+ # Resolved once here rather than per build leg: the tag is an input to the
+ # publish, and the merge job needs it too.
+ resolve-tag:
runs-on: ubuntu-latest
- strategy:
- matrix:
- include:
- - target: api-build
- file: Dockerfile.multi
- image_name: librechat-api
- - target: node
- file: Dockerfile
- image_name: librechat
-
+ timeout-minutes: 10
+ outputs:
+ latest_tag: ${{ steps.tag.outputs.latest_tag }}
+ sha: ${{ steps.tag.outputs.sha }}
steps:
- name: Checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@v5
with:
ref: main
fetch-depth: 0
- name: Fetch tags and set the latest tag
+ id: tag
run: |
set -euo pipefail
git fetch --tags --force
- LATEST_TAG=$(git tag --list 'v[0-9]*' --sort=-v:refname | grep -E '^v[0-9]+[.][0-9]+[.][0-9]+$' | head -n 1)
+ # `|| true` keeps pipefail from killing the step when grep matches
+ # nothing, so the explicit error below is reachable.
+ LATEST_TAG=$(git tag --list 'v[0-9]*' --sort=-v:refname | grep -E '^v[0-9]+[.][0-9]+[.][0-9]+$' | head -n 1 || true)
if [ -z "$LATEST_TAG" ]; then
echo "::error::No stable v tag found"
exit 1
fi
- printf 'LATEST_TAG=%s\n' "$LATEST_TAG" >> "$GITHUB_ENV"
-
- - name: Compute build metadata
- run: |
- printf 'BUILD_COMMIT=%s\n' "$(git rev-parse HEAD)" >> "$GITHUB_ENV"
- printf 'BUILD_BRANCH=main\n' >> "$GITHUB_ENV"
- printf 'BUILD_DATE=%s\n' "$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> "$GITHUB_ENV"
-
- # Set up QEMU
- - name: Set up QEMU
- uses: docker/setup-qemu-action@v3
-
- # Set up Docker Buildx
- - name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v3
-
- # Log in to GitHub Container Registry
- - name: Log in to GitHub Container Registry
- uses: docker/login-action@v3
- with:
- registry: ghcr.io
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
-
- # Login to Docker Hub
- - name: Login to Docker Hub
- uses: docker/login-action@v3
- with:
- username: ${{ secrets.DOCKERHUB_USERNAME }}
- password: ${{ secrets.DOCKERHUB_TOKEN }}
-
- # Prepare the environment
- - name: Prepare environment
- run: |
- cp .env.example .env
-
- # Build and push Docker images for each target
- - name: Build and push Docker images
- uses: docker/build-push-action@v5
- with:
- context: .
- file: ${{ matrix.file }}
- push: true
- tags: |
- ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}:${{ env.LATEST_TAG }}
- ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}:latest
- ${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}:${{ env.LATEST_TAG }}
- ${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}:latest
- platforms: linux/amd64,linux/arm64
- target: ${{ matrix.target }}
- build-args: |
- BUILD_COMMIT=${{ env.BUILD_COMMIT }}
- BUILD_BRANCH=${{ env.BUILD_BRANCH }}
- BUILD_DATE=${{ env.BUILD_DATE }}
+ printf 'latest_tag=%s\n' "$LATEST_TAG" >> "$GITHUB_OUTPUT"
+ # Pin the publish to the commit this job resolved the tag from. `main`
+ # is a moving ref: were each build leg to resolve it independently, the
+ # amd64 and arm64 halves of one release could come from different
+ # commits and still be published under the same tags.
+ printf 'sha=%s\n' "$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
+
+ publish:
+ needs: resolve-tag
+ uses: ./.github/workflows/docker-publish.yml
+ with:
+ images: >-
+ [{"target":"api-build","file":"Dockerfile.multi","image_name":"librechat-api"},
+ {"target":"node","file":"Dockerfile","image_name":"librechat"}]
+ tag_suffixes: |
+ ${{ needs.resolve-tag.outputs.latest_tag }}
+ latest
+ checkout_ref: ${{ needs.resolve-tag.outputs.sha }}
+ build_branch: main
+ secrets:
+ DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
+ DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
+ LEGACY_GHCR_TOKEN: ${{ secrets.LEGACY_GHCR_TOKEN }}
diff --git a/.github/workflows/playwright-bombadil.yml b/.github/workflows/playwright-bombadil.yml
new file mode 100644
index 00000000000..0f37ff1b11c
--- /dev/null
+++ b/.github/workflows/playwright-bombadil.yml
@@ -0,0 +1,183 @@
+name: Bombadil Property Exploration
+
+on:
+ pull_request:
+ paths:
+ - '**'
+ - '!**.md'
+ - '!.github/workflows/**'
+ - '.github/workflows/playwright-bombadil.yml'
+ workflow_dispatch:
+ inputs:
+ reason:
+ description: 'Reason for manual trigger'
+ required: false
+ default: 'Manual Bombadil run'
+
+permissions:
+ contents: read
+
+concurrency:
+ group: playwright-bombadil-${{ github.ref }}
+ cancel-in-progress: true
+
+env:
+ NODE_OPTIONS: '--max-old-space-size=${{ secrets.NODE_MAX_OLD_SPACE_SIZE || 6144 }}'
+ PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1'
+
+jobs:
+ bombadil:
+ if: >-
+ github.event_name == 'workflow_dispatch' ||
+ (github.event_name == 'pull_request' &&
+ github.event.pull_request != null &&
+ contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.pull_request.author_association))
+ continue-on-error: true
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ env:
+ BOMBADIL_TIME_LIMIT: '300s'
+ E2E_CHROMIUM_CHANNEL: chrome
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Use Node.js 24.16.0
+ uses: actions/setup-node@v4
+ with:
+ node-version: '24.16.0'
+
+ - name: Restore node_modules cache
+ id: cache-node-modules
+ uses: actions/cache@v4
+ with:
+ path: |
+ node_modules
+ client/node_modules
+ packages/client/node_modules
+ packages/data-provider/node_modules
+ packages/data-schemas/node_modules
+ packages/api/node_modules
+ api/node_modules
+ key: node-modules-e2e-${{ runner.os }}-24.16.0-${{ hashFiles('package-lock.json') }}
+
+ - name: Install dependencies
+ if: steps.cache-node-modules.outputs.cache-hit != 'true'
+ run: npm ci
+
+ - name: Restore data-provider build cache
+ id: cache-data-provider
+ uses: actions/cache@v4
+ with:
+ path: packages/data-provider/dist
+ key: build-data-provider-${{ runner.os }}-${{ hashFiles('package.json', 'package-lock.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }}
+
+ - name: Build data-provider
+ if: steps.cache-data-provider.outputs.cache-hit != 'true'
+ run: npm run build:data-provider
+
+ - name: Restore data-schemas build cache
+ id: cache-data-schemas
+ uses: actions/cache@v4
+ with:
+ path: packages/data-schemas/dist
+ key: build-data-schemas-${{ runner.os }}-${{ hashFiles('package.json', 'package-lock.json', 'packages/data-schemas/src/**', 'packages/data-schemas/tsconfig*.json', 'packages/data-schemas/tsdown.config.mjs', 'packages/data-schemas/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }}
+
+ - name: Build data-schemas
+ if: steps.cache-data-schemas.outputs.cache-hit != 'true'
+ run: npm run build:data-schemas
+
+ - name: Restore api build cache
+ id: cache-api
+ uses: actions/cache@v4
+ with:
+ path: packages/api/dist
+ key: build-api-${{ runner.os }}-${{ hashFiles('package.json', 'package-lock.json', 'packages/api/src/**', 'packages/api/tsconfig*.json', 'packages/api/tsdown.config.mjs', 'packages/api/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json', 'packages/data-schemas/src/**', 'packages/data-schemas/tsconfig*.json', 'packages/data-schemas/tsdown.config.mjs', 'packages/data-schemas/package.json') }}
+
+ - name: Build api
+ if: steps.cache-api.outputs.cache-hit != 'true'
+ run: npm run build:api
+
+ - name: Restore client-package build cache
+ id: cache-client-package
+ uses: actions/cache@v4
+ with:
+ path: packages/client/dist
+ key: build-client-package-${{ runner.os }}-${{ hashFiles('package.json', 'package-lock.json', 'packages/client/src/**', 'packages/client/tsconfig*.json', 'packages/client/tsdown.config.mjs', 'packages/client/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }}
+
+ - name: Build client-package
+ if: steps.cache-client-package.outputs.cache-hit != 'true'
+ run: npm run build:client-package
+
+ - name: Restore client app build cache
+ id: cache-client-app
+ uses: actions/cache@v4
+ with:
+ path: client/dist
+ key: build-client-app-e2e-${{ runner.os }}-${{ hashFiles('package.json', 'package-lock.json', 'client/src/**', 'client/public/**', 'client/index.html', 'client/package.json', 'client/vite.config.*', 'client/tsconfig*.json', 'client/tailwind.config.*', 'client/postcss.config.*', 'packages/client/src/**', 'packages/client/tailwind.preset.cjs', 'packages/client/tsconfig*.json', 'packages/client/tsdown.config.mjs', 'packages/client/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }}
+
+ - name: Build client app
+ if: steps.cache-client-app.outputs.cache-hit != 'true'
+ run: npm run build:client
+
+ - name: Verify Chrome is present
+ run: google-chrome --version
+
+ # Optional fonts only — see the note in playwright-mock.yml's e2e_shards job.
+ - name: Install optional Playwright font dependencies (best effort)
+ timeout-minutes: 4
+ continue-on-error: true
+ run: .github/scripts/install-playwright-fonts.sh
+
+ - name: Run five-minute Bombadil exploration
+ id: bombadil
+ continue-on-error: true
+ run: |
+ set -o pipefail
+ mkdir -p e2e/.generated
+ npx playwright test \
+ --config=e2e/playwright.config.bombadil.ts \
+ --reporter=line,html \
+ 2>&1 | tee e2e/.generated/bombadil-ci.log
+ env:
+ CI: 'true'
+ PLAYWRIGHT_HTML_OPEN: 'never'
+ PLAYWRIGHT_HTML_OUTPUT_DIR: e2e/playwright-report-bombadil
+
+ - name: Upload Bombadil reproduction trace
+ id: bombadil-reproduction
+ if: steps.bombadil.outcome == 'failure'
+ uses: actions/upload-artifact@v4
+ with:
+ name: bombadil-reproduction-${{ github.run_id }}-${{ github.run_attempt }}
+ path: e2e/.generated/bombadil-output/**
+ include-hidden-files: true
+ retention-days: 7
+ if-no-files-found: warn
+
+ - name: Upload Bombadil diagnostics
+ id: bombadil-diagnostics
+ if: steps.bombadil.outcome == 'failure'
+ uses: actions/upload-artifact@v4
+ with:
+ name: bombadil-diagnostics-${{ github.run_id }}-${{ github.run_attempt }}
+ path: |
+ e2e/.generated/bombadil-ci.log
+ e2e/playwright-report-bombadil/**
+ e2e/specs/.test-results/**
+ include-hidden-files: true
+ retention-days: 7
+ if-no-files-found: warn
+
+ - name: Report non-blocking Bombadil failure
+ if: steps.bombadil.outcome == 'failure'
+ run: |
+ echo "::warning title=Bombadil property violation::The five-minute exploration failed. Download the reproduction and diagnostics artifacts for this run."
+ {
+ echo "### Bombadil property exploration"
+ echo
+ echo "The exploration failed, but this job does not block merge."
+ echo
+ echo "Reproduction: ${{ steps.bombadil-reproduction.outputs.artifact-url }}"
+ echo
+ echo "Diagnostics: ${{ steps.bombadil-diagnostics.outputs.artifact-url }}"
+ } >> "$GITHUB_STEP_SUMMARY"
diff --git a/.github/workflows/playwright-mock.yml b/.github/workflows/playwright-mock.yml
index fc94dc02d64..acb62732b13 100644
--- a/.github/workflows/playwright-mock.yml
+++ b/.github/workflows/playwright-mock.yml
@@ -2,6 +2,13 @@ name: Playwright E2E Tests
on:
pull_request:
+ paths:
+ - '**'
+ - '!**.md'
+ - '!.github/workflows/**'
+ - '.github/workflows/playwright-mock.yml'
+ schedule:
+ - cron: '0 5 * * *'
workflow_dispatch:
inputs:
reason:
@@ -21,22 +28,174 @@ env:
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1'
jobs:
- e2e:
+ # Stage 2 of codegraph gating (stage 1 = backend jest in backend-review.yml). The graph decides
+ # two matrix lanes whose relevance is a reachability question: the redis-transport lane (the ten
+ # stream-boundary specs under a real Redis round-trip) and the MCP list_changed lanes. Memory
+ # shards always run. Monotone and fail-open: a lane is dropped ONLY on an explicit `false` from
+ # the service; unavailable/unconfigured/non-synchronize events keep the full matrix. Lock
+ # attribution rides along so a backend dependency bump (reaches nothing in the graph) still
+ # earns both lanes. Kill switch: repo variable CODEGRAPH_GATING=off.
+ codegraph_select:
+ name: Codegraph select
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ if: >-
+ github.event_name == 'pull_request' &&
+ github.event.action == 'synchronize' &&
+ vars.CODEGRAPH_GATING != 'off' &&
+ github.event.pull_request != null &&
+ contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.pull_request.author_association)
+ outputs:
+ decided: ${{ steps.sel.outputs.decided }}
+ e2e_include: ${{ steps.sel.outputs.e2e_include }}
+ mcp_run: ${{ steps.sel.outputs.mcp_run }}
+ e2e_skip: ${{ steps.sel.outputs.e2e_skip }}
+ steps:
+ - name: Select matrix lanes, fail open on any doubt
+ id: sel
+ env:
+ URL: ${{ secrets.CODEGRAPH_URL }}
+ TOKEN: ${{ secrets.CODEGRAPH_TOKEN }}
+ GH_TOKEN: ${{ github.token }}
+ REPO: ${{ github.repository }}
+ PR: ${{ github.event.pull_request.number }}
+ BASE_SHA: ${{ github.event.pull_request.base.sha }}
+ HEAD_SHA: ${{ github.event.pull_request.head.sha }}
+ CHANGED: ${{ github.event.pull_request.changed_files }}
+ E2E_SKIP_ARMED: ${{ vars.CODEGRAPH_E2E_SKIP }}
+ FULL_INCLUDE: '{"include":[{"name":"memory, shard 1/3","stream_store":"memory","redis_image":"","suite":"full","shard":"1/3","artifact":"memory-1-of-3"},{"name":"memory, shard 2/3","stream_store":"memory","redis_image":"","suite":"full","shard":"2/3","artifact":"memory-2-of-3"},{"name":"memory, shard 3/3","stream_store":"memory","redis_image":"","suite":"full","shard":"3/3","artifact":"memory-3-of-3"},{"name":"redis transport","stream_store":"redis","redis_image":"redis:7-alpine","suite":"transport","shard":"","artifact":"redis-transport"}]}'
+ run: |
+ set +e
+ note() { echo "$1" >> "$GITHUB_STEP_SUMMARY"; }
+ note "### Codegraph select — GATING (Playwright matrix lanes)"
+ if [ -z "$URL" ] || [ -z "$TOKEN" ]; then note "_no codegraph config; running FULL_"; exit 0; fi
+ # A failed or truncated page must not become a shorter file list: the pipeline would hide
+ # gh's exit status behind jq, and a partial list can turn a required lane off. Check the
+ # fetch status AND the count against the PR's own changed_files (Codex P1, #15136).
+ if ! gh api "repos/$REPO/pulls/$PR/files" --paginate \
+ --jq '.[] | {path: .filename, status, patch}' > files.ndjson; then
+ note "_could not fetch changed files; running FULL_"; exit 0
+ fi
+ jq -s . files.ndjson > files.json
+ N=$(jq 'length' files.json)
+ if [ "$N" -eq 0 ] || { [ -n "$CHANGED" ] && [ "$N" -ne "$CHANGED" ]; }; then
+ note "_changed-file list incomplete ($N of ${CHANGED:-?}); running FULL_"; exit 0
+ fi
+ jq -c --arg b "$BASE_SHA" --arg h "$HEAD_SHA" \
+ '{files: ., mode: "safe", lockBaseSha: $b, lockHeadSha: $h}' files.json > body.json
+ # curl's status is checked explicitly: a transfer that times out or truncates after a
+ # parseable body must fail open, not be honoured (Codex P1, #15136). --fail-with-body
+ # also turns HTTP errors into a failure while keeping the error text for the summary.
+ RESP=$(curl -sS --fail-with-body -m 45 -H "Authorization: Bearer $TOKEN" \
+ -H 'content-type: application/json' --data-binary @body.json "$URL/v1/select"); RC=$?
+ if [ "$RC" -ne 0 ] || [ -z "$RESP" ] || ! echo "$RESP" | jq -e '.matrix["playwright-mock"]' >/dev/null 2>&1; then
+ note "_codegraph unavailable (curl exit $RC: ${RESP:0:120}); running FULL_"
+ exit 0
+ fi
+ # A lane is skipped only on the JSON boolean false — tested inside jq, because `jq -r`
+ # prints the string "false" and the boolean identically (Codex P1, #15136). Anything
+ # else (true, null, a string, missing) runs.
+ if echo "$RESP" | jq -e '.e2e.fail_open == true' >/dev/null 2>&1; then
+ note "_fail-open decision (root/workflow/lockfile change or stale graph): everything runs_"
+ fi
+ REDIS=$(echo "$RESP" | jq -c '.matrix["playwright-mock"].redis_transport')
+ MCP=$(echo "$RESP" | jq -c '.matrix["playwright-mock"].mcp_tool_list_changed')
+ REDIS_SKIP=0; MCP_SKIP=0
+ echo "$RESP" | jq -e '.matrix["playwright-mock"].redis_transport == false' >/dev/null 2>&1 && REDIS_SKIP=1
+ echo "$RESP" | jq -e '.matrix["playwright-mock"].mcp_tool_list_changed == false' >/dev/null 2>&1 && MCP_SKIP=1
+ note "| lane | decision |"
+ note "|---|---|"
+ if [ "$REDIS_SKIP" = 1 ]; then
+ INCLUDE=$(echo "$FULL_INCLUDE" | jq -c '.include |= map(select(.suite != "transport"))')
+ note "| redis transport | skip (no reach into the stream boundary) |"
+ else
+ INCLUDE="$FULL_INCLUDE"
+ note "| redis transport | run |"
+ fi
+ if ! echo "$INCLUDE" | jq -e '.include | length >= 3' >/dev/null 2>&1; then
+ note "_matrix assembly failed; running FULL_"
+ exit 0
+ fi
+ echo "e2e_include=$INCLUDE" >> "$GITHUB_OUTPUT"
+ # Graduated per-spec skips are DARK until the operator arms repo variable
+ # CODEGRAPH_E2E_SKIP=on (the election switch — flipped only when the pre-registered
+ # resume condition holds). Even then, act only on a well-typed list from a non-fail-open
+ # decision: every entry must be a pool spec path, or nothing is skipped. The server
+ # already intersects with this PR's skippable tier and applies the streak bars
+ # (2x where history-coupled); see codegraph-poc service/graduate.ts.
+ SKIP=""
+ if [ "$E2E_SKIP_ARMED" = "on" ]; then
+ if echo "$RESP" | jq -e '(.e2e.fail_open != true) and (.e2e.graduated | type == "array" and all(.[]?; type == "string" and test("^e2e/specs/mock/[A-Za-z0-9._/-]+\\.spec\\.ts$") and (contains("..") | not)))' >/dev/null 2>&1; then
+ SKIP=$(echo "$RESP" | jq -r '[.e2e.graduated[] | sub("^e2e/"; "")] | join(" ")')
+ else
+ note "_graduated list absent or malformed; no specs skipped_"
+ fi
+ fi
+ echo "e2e_skip=$SKIP" >> "$GITHUB_OUTPUT"
+ if [ -n "$SKIP" ]; then
+ note "| graduated spec skips | $(echo "$SKIP" | wc -w | tr -d ' ') (armed) |"
+ echo "codegraph-e2e-graduated-skips: $SKIP"
+ fi
+ echo "codegraph-select: redis_transport=$REDIS mcp_tool_list_changed=$MCP matrix_entries=$(echo "$INCLUDE" | jq '.include | length')"
+ if [ "$MCP_SKIP" = 1 ]; then
+ echo "mcp_run=false" >> "$GITHUB_OUTPUT"
+ note "| MCP list_changed | skip (no reach into MCP) |"
+ else
+ echo "mcp_run=true" >> "$GITHUB_OUTPUT"
+ note "| MCP list_changed | run |"
+ fi
+ echo "decided=true" >> "$GITHUB_OUTPUT"
+ note ""
+ note "memory shards always run · kill switch: repo variable \`CODEGRAPH_GATING=off\` · full matrix on PR open and nightly"
+ exit 0
+
+ e2e_shards:
+ name: e2e (${{ matrix.name }})
+ needs: [codegraph_select]
+ if: >-
+ !cancelled() &&
+ (github.event_name == 'schedule' ||
+ github.event_name == 'workflow_dispatch' ||
+ (github.event_name == 'pull_request' &&
+ github.event.pull_request != null &&
+ contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.pull_request.author_association)))
runs-on: ubuntu-latest
timeout-minutes: 30
+ strategy:
+ fail-fast: false
+ matrix: >-
+ ${{
+ (github.event_name == 'pull_request' && needs.codegraph_select.outputs.e2e_include != '' &&
+ fromJSON(needs.codegraph_select.outputs.e2e_include)) ||
+ (github.event_name == 'pull_request' &&
+ fromJSON('{"include":[{"name":"memory, shard 1/3","stream_store":"memory","redis_image":"","suite":"full","shard":"1/3","artifact":"memory-1-of-3"},{"name":"memory, shard 2/3","stream_store":"memory","redis_image":"","suite":"full","shard":"2/3","artifact":"memory-2-of-3"},{"name":"memory, shard 3/3","stream_store":"memory","redis_image":"","suite":"full","shard":"3/3","artifact":"memory-3-of-3"},{"name":"redis transport","stream_store":"redis","redis_image":"redis:7-alpine","suite":"transport","shard":"","artifact":"redis-transport"}]}')) ||
+ fromJSON('{"include":[{"name":"memory, shard 1/2","stream_store":"memory","redis_image":"","suite":"full","shard":"1/2","artifact":"memory-1-of-2"},{"name":"memory, shard 2/2","stream_store":"memory","redis_image":"","suite":"full","shard":"2/2","artifact":"memory-2-of-2"},{"name":"redis, shard 1/2","stream_store":"redis","redis_image":"redis:7-alpine","suite":"full","shard":"1/2","artifact":"redis-1-of-2"},{"name":"redis, shard 2/2","stream_store":"redis","redis_image":"redis:7-alpine","suite":"full","shard":"2/2","artifact":"redis-2-of-2"}]}')
+ }}
+ services:
+ redis:
+ image: ${{ matrix.redis_image }}
+ ports:
+ - 6379:6379
+ options: >-
+ --health-cmd "redis-cli ping"
+ --health-interval 5s
+ --health-timeout 3s
+ --health-retries 10
env:
E2E_CHROMIUM_CHANNEL: chrome
+ E2E_STREAM_STORE: ${{ matrix.stream_store }}
+ REDIS_URI: redis://127.0.0.1:6379
steps:
- - uses: actions/checkout@v4
+ - uses: actions/checkout@v5
- name: Use Node.js 24.16.0
- uses: actions/setup-node@v4
+ uses: actions/setup-node@v5
with:
node-version: '24.16.0'
- name: Restore node_modules cache
id: cache-node-modules
- uses: actions/cache@v4
+ uses: actions/cache@v5
with:
path: |
node_modules
@@ -54,10 +213,10 @@ jobs:
- name: Restore data-provider build cache
id: cache-data-provider
- uses: actions/cache@v4
+ uses: actions/cache@v5
with:
path: packages/data-provider/dist
- key: build-data-provider-${{ runner.os }}-${{ hashFiles('package-lock.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }}
+ key: build-data-provider-${{ runner.os }}-${{ hashFiles('package.json', 'package-lock.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }}
- name: Build data-provider
if: steps.cache-data-provider.outputs.cache-hit != 'true'
@@ -65,10 +224,10 @@ jobs:
- name: Restore data-schemas build cache
id: cache-data-schemas
- uses: actions/cache@v4
+ uses: actions/cache@v5
with:
path: packages/data-schemas/dist
- key: build-data-schemas-${{ runner.os }}-${{ hashFiles('package-lock.json', 'packages/data-schemas/src/**', 'packages/data-schemas/tsconfig*.json', 'packages/data-schemas/tsdown.config.mjs', 'packages/data-schemas/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }}
+ key: build-data-schemas-${{ runner.os }}-${{ hashFiles('package.json', 'package-lock.json', 'packages/data-schemas/src/**', 'packages/data-schemas/tsconfig*.json', 'packages/data-schemas/tsdown.config.mjs', 'packages/data-schemas/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }}
- name: Build data-schemas
if: steps.cache-data-schemas.outputs.cache-hit != 'true'
@@ -76,10 +235,10 @@ jobs:
- name: Restore api build cache
id: cache-api
- uses: actions/cache@v4
+ uses: actions/cache@v5
with:
path: packages/api/dist
- key: build-api-${{ runner.os }}-${{ hashFiles('package-lock.json', 'packages/api/src/**', 'packages/api/tsconfig*.json', 'packages/api/tsdown.config.mjs', 'packages/api/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json', 'packages/data-schemas/src/**', 'packages/data-schemas/tsconfig*.json', 'packages/data-schemas/tsdown.config.mjs', 'packages/data-schemas/package.json') }}
+ key: build-api-${{ runner.os }}-${{ hashFiles('package.json', 'package-lock.json', 'packages/api/src/**', 'packages/api/tsconfig*.json', 'packages/api/tsdown.config.mjs', 'packages/api/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json', 'packages/data-schemas/src/**', 'packages/data-schemas/tsconfig*.json', 'packages/data-schemas/tsdown.config.mjs', 'packages/data-schemas/package.json') }}
- name: Build api
if: steps.cache-api.outputs.cache-hit != 'true'
@@ -87,10 +246,10 @@ jobs:
- name: Restore client-package build cache
id: cache-client-package
- uses: actions/cache@v4
+ uses: actions/cache@v5
with:
path: packages/client/dist
- key: build-client-package-${{ runner.os }}-${{ hashFiles('package-lock.json', 'packages/client/src/**', 'packages/client/tsconfig*.json', 'packages/client/tsdown.config.mjs', 'packages/client/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }}
+ key: build-client-package-${{ runner.os }}-${{ hashFiles('package.json', 'package-lock.json', 'packages/client/src/**', 'packages/client/tsconfig*.json', 'packages/client/tsdown.config.mjs', 'packages/client/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }}
- name: Build client-package
if: steps.cache-client-package.outputs.cache-hit != 'true'
@@ -98,31 +257,260 @@ jobs:
- name: Restore client app build cache
id: cache-client-app
- uses: actions/cache@v4
+ uses: actions/cache@v5
with:
path: client/dist
- key: build-client-app-e2e-${{ runner.os }}-${{ hashFiles('package-lock.json', 'client/src/**', 'client/public/**', 'client/scripts/post-build.cjs', 'client/index.html', 'client/package.json', 'client/vite.config.*', 'client/tsconfig*.json', 'client/tailwind.config.*', 'client/postcss.config.*', 'packages/client/src/**', 'packages/client/tsconfig*.json', 'packages/client/tsdown.config.mjs', 'packages/client/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }}
+ key: build-client-app-e2e-${{ runner.os }}-${{ hashFiles('package.json', 'package-lock.json', 'client/src/**', 'client/public/**', 'client/index.html', 'client/package.json', 'client/vite.config.*', 'client/tsconfig*.json', 'client/tailwind.config.*', 'client/postcss.config.*', 'packages/client/src/**', 'packages/client/tailwind.preset.cjs', 'packages/client/tsconfig*.json', 'packages/client/tsdown.config.mjs', 'packages/client/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }}
- name: Build client app
if: steps.cache-client-app.outputs.cache-hit != 'true'
run: npm run build:client
- - name: Install Playwright runtime dependencies
- timeout-minutes: 5
+ - name: Verify Chrome is present
+ run: google-chrome --version
+
+ # `video: 'on-first-retry'` needs ffmpeg; without it the first retry dies in
+ # browserContext.newPage before the test body runs, so a flaky test loses the
+ # retry that would have recovered it.
+ #
+ # This step used to burn its full 90s bound on every job. Playwright's bundled
+ # extractor hangs on Node 24.16.0 (a yauzl/extract-zip regression fixed in
+ # Playwright 1.60.0): the 2.3MB download finished in under a second, then
+ # extraction stalled and the timeout reaped it, leaving a truncated binary and
+ # no INSTALLATION_COMPLETE marker — so ffmpeg was never actually installed and
+ # retries never got video. With Playwright bumped past the fix the install
+ # takes about a second, and a restored cache skips it outright.
+ #
+ # The cache is only saved once the binary is verified to run, so a partial
+ # extraction can never be promoted into a cache every later job restores.
+ # Kept non-fatal: retry video is a debugging aid, not something CI asserts on.
+ - name: Resolve Playwright version
+ id: playwright-version
+ run: |
+ version=$(node -p "require('./package-lock.json').packages['node_modules/playwright-core'].version")
+ echo "version=${version}" >> "$GITHUB_OUTPUT"
+
+ - name: Restore Playwright ffmpeg cache
+ id: cache-ffmpeg
+ uses: actions/cache/restore@v5
+ with:
+ path: ~/.cache/ms-playwright
+ key: playwright-ffmpeg-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}
+
+ - name: Install Playwright ffmpeg (best effort)
+ id: install-ffmpeg
+ if: steps.cache-ffmpeg.outputs.cache-hit != 'true'
+ timeout-minutes: 3
+ continue-on-error: true
+ run: |
+ timeout -k 10 60 npx playwright install ffmpeg
+ .github/scripts/verify-playwright-ffmpeg.sh
+
+ - name: Save Playwright ffmpeg cache
+ if: steps.install-ffmpeg.outcome == 'success'
+ continue-on-error: true
+ uses: actions/cache/save@v5
+ with:
+ path: ~/.cache/ms-playwright
+ key: playwright-ffmpeg-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}
+
+ # The runner's Chrome is an apt package, so its real library dependencies are
+ # already satisfied; all `install-deps` adds here are optional CJK/Thai/Cyrillic
+ # font packages (~21MB from azure.archive.ubuntu.com). Nothing in CI asserts on
+ # them — visual baselines are opt-in via E2E_VISUAL_SNAPSHOTS — so a stalled
+ # Ubuntu mirror must never be able to fail the suite.
+ - name: Install optional Playwright font dependencies (best effort)
+ timeout-minutes: 4
+ continue-on-error: true
+ run: .github/scripts/install-playwright-fonts.sh
+
+ - name: Run full mock-LLM Tier-1 e2e
+ if: matrix.suite == 'full'
+ env:
+ CI: 'true'
+ E2E_SKIP: ${{ needs.codegraph_select.outputs.e2e_skip }}
run: |
- google-chrome --version
- npx playwright install-deps chrome
+ set +e
+ # Graduated-spec skipping (dark until repo var CODEGRAPH_E2E_SKIP=on upstream): subtract
+ # the earned skips from a run list derived from the tree itself, so an unknown or stale
+ # name in the skip list simply matches nothing. If subtraction would drop everything —
+ # or drops nothing — run the full shard exactly as before. Skipped specs still execute
+ # post-merge in every full-suite vote run, which is the net that catches a wrong skip.
+ RUN_ARGS=""
+ if [ -n "$E2E_SKIP" ]; then
+ KEEP=""; DROP=0
+ for spec in $(git ls-files 'e2e/specs/mock/*.spec.ts' 'e2e/specs/mock/**/*.spec.ts' | sed 's|^e2e/||' | sort -u); do
+ case "$spec" in *" "*) KEEP="$KEEP $spec"; continue;; esac
+ case " $E2E_SKIP " in
+ *" $spec "*) DROP=$((DROP+1));;
+ *) KEEP="$KEEP $spec";;
+ esac
+ done
+ if [ "$DROP" -gt 0 ] && [ -n "$KEEP" ]; then
+ RUN_ARGS="$KEEP"
+ echo "codegraph-e2e-skip: dropped $DROP graduated specs from this shard's pool"
+ fi
+ fi
+ set -e
+ npx playwright test --config=e2e/playwright.config.mock.ts --shard=${{ matrix.shard }} $RUN_ARGS
- - name: Run mock-LLM Tier-1 e2e
- run: npx playwright test --config=e2e/playwright.config.mock.ts
+ - name: Run Redis stream transport e2e
+ if: matrix.suite == 'transport'
+ run: npx playwright test --config=e2e/playwright.config.redis.ts
env:
CI: 'true'
+ - name: Upload Playwright HTML report
+ if: ${{ !cancelled() }}
+ uses: actions/upload-artifact@v6
+ with:
+ name: playwright-report-${{ matrix.artifact }}
+ path: e2e/playwright-report/**
+ retention-days: 7
+ if-no-files-found: ignore
+
+ - name: Upload traces & screenshots
+ if: failure()
+ uses: actions/upload-artifact@v6
+ with:
+ name: playwright-test-results-${{ matrix.artifact }}
+ path: e2e/specs/.test-results/**
+ retention-days: 7
+ if-no-files-found: ignore
+
+ mcp_tool_list_changed:
+ name: MCP list_changed (replica count ${{ matrix.replicas }})
+ needs: [codegraph_select]
+ if: >-
+ !cancelled() &&
+ needs.codegraph_select.outputs.mcp_run != 'false' &&
+ (github.event_name == 'schedule' ||
+ github.event_name == 'workflow_dispatch' ||
+ (github.event_name == 'pull_request' &&
+ github.event.pull_request != null &&
+ contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.pull_request.author_association)))
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+ strategy:
+ fail-fast: false
+ matrix:
+ replicas: [1, 2]
+ env:
+ CI: 'true'
+ E2E_CHROMIUM_CHANNEL: chrome
+ E2E_MCP_LIST_CHANGED: 'true'
+ E2E_REPLICAS: ${{ matrix.replicas }}
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Use Node.js 24.16.0
+ uses: actions/setup-node@v4
+ with:
+ node-version: '24.16.0'
+
+ - name: Restore node_modules cache
+ id: cache-node-modules
+ uses: actions/cache@v4
+ with:
+ path: |
+ node_modules
+ client/node_modules
+ packages/client/node_modules
+ packages/data-provider/node_modules
+ packages/data-schemas/node_modules
+ packages/api/node_modules
+ api/node_modules
+ key: node-modules-e2e-${{ runner.os }}-24.16.0-${{ hashFiles('package-lock.json') }}
+
+ - name: Install dependencies
+ if: steps.cache-node-modules.outputs.cache-hit != 'true'
+ run: npm ci
+
+ - name: Build e2e dependencies
+ run: npm run e2e:prepare
+
+ - name: Verify Chrome is present
+ run: google-chrome --version
+
+ # ffmpeg for retry video — see the note in the e2e_shards job.
+ - name: Resolve Playwright version
+ id: playwright-version
+ run: |
+ version=$(node -p "require('./package-lock.json').packages['node_modules/playwright-core'].version")
+ echo "version=${version}" >> "$GITHUB_OUTPUT"
+
+ - name: Restore Playwright ffmpeg cache
+ id: cache-ffmpeg
+ uses: actions/cache/restore@v5
+ with:
+ path: ~/.cache/ms-playwright
+ key: playwright-ffmpeg-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}
+
+ - name: Install Playwright ffmpeg (best effort)
+ id: install-ffmpeg
+ if: steps.cache-ffmpeg.outputs.cache-hit != 'true'
+ timeout-minutes: 3
+ continue-on-error: true
+ run: |
+ timeout -k 10 60 npx playwright install ffmpeg
+ .github/scripts/verify-playwright-ffmpeg.sh
+
+ - name: Save Playwright ffmpeg cache
+ if: steps.install-ffmpeg.outcome == 'success'
+ continue-on-error: true
+ uses: actions/cache/save@v5
+ with:
+ path: ~/.cache/ms-playwright
+ key: playwright-ffmpeg-${{ runner.os }}-${{ steps.playwright-version.outputs.version }}
+
+ # This job deliberately skips the optional font install: its bounded
+ # Playwright apt process can outlive the wrapper on a slow mirror and
+ # retain the package-manager lock needed by the required Redis install.
+ # The MCP suite does not enable visual snapshot assertions.
+
+ # Redis is a hard requirement for this job, so this step stays fatal.
+ - name: Install Redis runtime dependencies
+ timeout-minutes: 5
+ run: |
+ sudo apt-get -o DPkg::Lock::Timeout=300 update
+ sudo apt-get -o DPkg::Lock::Timeout=300 install -y redis-server redis-tools
+
+ - name: Start standalone Redis and Redis Cluster
+ run: |
+ redis-server --daemonize yes --port 6379
+ redis-cli -p 6379 ping
+ chmod +x redis-config/start-cluster.sh redis-config/stop-cluster.sh
+ ./redis-config/start-cluster.sh
+ redis-cli -p 7001 cluster info
+
+ - name: Test MCP notifications with in-memory cache
+ env:
+ E2E_STREAM_STORE: memory
+ run: >-
+ npx playwright test --config=e2e/playwright.config.mock.ts
+ mcp-tool-list-changed.spec.ts --retries=0
+
+ - name: Test MCP notifications with standalone Redis cache
+ env:
+ E2E_STREAM_STORE: redis
+ REDIS_URI: redis://127.0.0.1:6379
+ run: >-
+ npx playwright test --config=e2e/playwright.config.mock.ts
+ mcp-tool-list-changed.spec.ts --retries=0
+
+ - name: Test MCP notifications with Redis Cluster cache
+ env:
+ E2E_STREAM_STORE: redis-cluster
+ REDIS_URI: redis://127.0.0.1:7001,redis://127.0.0.1:7002,redis://127.0.0.1:7003
+ run: >-
+ npx playwright test --config=e2e/playwright.config.mock.ts
+ mcp-tool-list-changed.spec.ts --retries=0
+
- name: Upload Playwright HTML report
if: ${{ !cancelled() }}
uses: actions/upload-artifact@v4
with:
- name: playwright-report
+ name: playwright-mcp-list-changed-${{ matrix.replicas }}-replicas
path: e2e/playwright-report/**
retention-days: 7
if-no-files-found: ignore
@@ -131,7 +519,38 @@ jobs:
if: failure()
uses: actions/upload-artifact@v4
with:
- name: playwright-test-results
+ name: playwright-mcp-list-changed-results-${{ matrix.replicas }}-replicas
path: e2e/specs/.test-results/**
retention-days: 7
if-no-files-found: ignore
+
+ - name: Stop Redis processes
+ if: always()
+ run: |
+ ./redis-config/stop-cluster.sh || true
+ redis-cli -p 6379 shutdown || true
+
+ e2e:
+ name: e2e
+ # `!cancelled()`, not `always()`: the gate has to survive a failed shard to report it, but a
+ # run superseded by `cancel-in-progress` has nothing to adjudicate. Under `always()` it was
+ # still dispatched onto a fresh runner while every other job went `cancelled`, then read
+ # `needs.e2e_shards.result == 'cancelled'` and exited 1 — so each superseded push left this
+ # required check red instead of cancelled. Matches the two lanes it gates.
+ if: >-
+ !cancelled() &&
+ (github.event_name == 'schedule' ||
+ github.event_name == 'workflow_dispatch' ||
+ (github.event_name == 'pull_request' &&
+ github.event.pull_request != null &&
+ contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.pull_request.author_association)))
+ needs: [codegraph_select, e2e_shards, mcp_tool_list_changed]
+ runs-on: ubuntu-latest
+ steps:
+ # A codegraph-skipped MCP lane reports `skipped`; that is a decision, not a failure.
+ - name: Verify every Playwright job passed
+ if: >-
+ needs.e2e_shards.result != 'success' ||
+ (needs.mcp_tool_list_changed.result != 'success' &&
+ !(needs.mcp_tool_list_changed.result == 'skipped' && needs.codegraph_select.outputs.mcp_run == 'false'))
+ run: exit 1
diff --git a/.github/workflows/pr-retarget-dev.yml b/.github/workflows/pr-retarget-dev.yml
new file mode 100644
index 00000000000..da4cf02638a
--- /dev/null
+++ b/.github/workflows/pr-retarget-dev.yml
@@ -0,0 +1,79 @@
+name: Retarget PRs to dev
+
+on:
+ pull_request_target:
+ types: [opened, reopened, synchronize]
+ branches: [main]
+ workflow_dispatch:
+ inputs:
+ dry_run:
+ description: 'Report what would change without editing any pull request'
+ type: boolean
+ default: true
+ pr_numbers:
+ description: 'Space-separated PR numbers (default: every open pull request based on main)'
+ required: false
+ default: ''
+
+permissions:
+ contents: read
+ pull-requests: write
+
+# Per-pull-request for the hook so a burst of openings runs in parallel; GitHub keeps only one
+# pending job per group, so a single shared group would cancel queued retargets. Every sweep shares
+# one group so two of them cannot process the same pull request at once.
+concurrency:
+ group: pr-retarget-dev-${{ github.event.pull_request.number || 'sweep' }}
+ cancel-in-progress: false
+
+jobs:
+ on-open:
+ name: Retarget on open
+ if: github.event_name == 'pull_request_target'
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ steps:
+ - uses: actions/checkout@v5
+ with:
+ # pull_request_target runs with a write token: check out the trusted base
+ # commit only, never the pull request head.
+ ref: ${{ github.event.pull_request.base.sha }}
+ persist-credentials: false
+ sparse-checkout: .github/scripts
+
+ - name: Retarget onto dev
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ REPO: ${{ github.repository }}
+ PR_NUMBER: ${{ github.event.pull_request.number }}
+ run: .github/scripts/retarget-prs.sh "$PR_NUMBER"
+
+ sweep:
+ name: Sweep open pull requests
+ if: github.event_name == 'workflow_dispatch'
+ runs-on: ubuntu-latest
+ timeout-minutes: 60
+ steps:
+ - uses: actions/checkout@v5
+ with:
+ persist-credentials: false
+ sparse-checkout: .github/scripts
+
+ - name: Retarget onto dev
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ REPO: ${{ github.repository }}
+ DRY_RUN: ${{ inputs.dry_run }}
+ PR_NUMBERS: ${{ inputs.pr_numbers }}
+ THROTTLE_SECONDS: '2'
+ run: |
+ numbers="$PR_NUMBERS"
+ if [ -z "$numbers" ]; then
+ numbers="$(gh api --paginate "repos/$REPO/pulls?base=main&state=open&per_page=100" --jq '.[].number')"
+ fi
+ if [ -z "$numbers" ]; then
+ echo "No open pull requests based on main."
+ exit 0
+ fi
+ # shellcheck disable=SC2086
+ .github/scripts/retarget-prs.sh $numbers
diff --git a/.github/workflows/static-checks.yml b/.github/workflows/static-checks.yml
new file mode 100644
index 00000000000..fe3c49e9ab6
--- /dev/null
+++ b/.github/workflows/static-checks.yml
@@ -0,0 +1,905 @@
+name: Static Checks
+
+on:
+ pull_request:
+ paths:
+ - 'api/**'
+ - 'client/**'
+ - 'config/**'
+ - 'packages/**'
+ - 'scripts/**'
+ - 'package.json'
+ - 'package-lock.json'
+ - 'eslint.config.mjs'
+ - '.github/workflows/static-checks.yml'
+ - '!**.md'
+
+permissions:
+ contents: read
+ pull-requests: read
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+env:
+ NODE_ENV: CI
+ NODE_OPTIONS: '--max-old-space-size=${{ secrets.NODE_MAX_OLD_SPACE_SIZE || 6144 }}'
+
+jobs:
+ static-checks:
+ name: Static checks
+ runs-on: ubuntu-latest
+ timeout-minutes: 30
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v5
+ with:
+ # Full history is load-bearing: changed-file steps diff against the
+ # PR base and the ESLint regression gate reads the base ref's
+ # config via git show — a shallow checkout breaks both.
+ fetch-depth: 0
+
+ # scripts/static-checks.mts mirrors these filters to run the same checks
+ # locally on a commit's diff — keep the two in sync.
+ - name: Detect affected checks
+ id: paths
+ uses: dorny/paths-filter@v4
+ with:
+ predicate-quantifier: 'some-with-excludes'
+ filters: |
+ eslint:
+ - 'api/**'
+ - 'client/**'
+ - 'packages/**'
+ - 'eslint.config.mjs'
+ - '.github/workflows/static-checks.yml'
+ - '!**.md'
+ eslint_config:
+ - 'eslint.config.mjs'
+ - '.github/workflows/static-checks.yml'
+ config:
+ - 'api/**'
+ - 'config/**'
+ - 'packages/**'
+ - '.github/workflows/static-checks.yml'
+ - '!**.md'
+ i18n:
+ - 'api/**'
+ - 'client/src/**'
+ - 'packages/client/**'
+ - 'packages/data-provider/src/**'
+ - 'packages/data-schemas/src/**'
+ - '.github/workflows/static-checks.yml'
+ - '!**.md'
+ runner:
+ - 'scripts/static-checks.mts'
+ - '.github/workflows/static-checks.yml'
+ unused_packages:
+ - 'api/**'
+ - 'client/**'
+ - 'packages/api/**'
+ - 'packages/client/**'
+ # Every workspace manifest the JSON validation step covers, plus
+ # the ones whose dependencies feed the unused-package calculation
+ # through api/package.json's @librechat/data-schemas entry.
+ - 'packages/data-provider/package.json'
+ - 'packages/data-schemas/package.json'
+ - 'package.json'
+ - 'package-lock.json'
+ - '.github/workflows/static-checks.yml'
+ - '!**.md'
+
+ - name: Set up Node.js 24.16.0
+ uses: actions/setup-node@v5
+ with:
+ node-version: '24.16.0'
+ cache: npm
+
+ - name: Install dependencies
+ id: install_dependencies
+ continue-on-error: true
+ run: npm ci
+
+ # Run ESLint on changed files within the api/, client/, and packages/ directories.
+ - name: Run ESLint on changed files
+ id: eslint
+ if: always() && steps.paths.outputs.eslint == 'true'
+ continue-on-error: true
+ run: |
+ # Extract the base commit SHA from the pull_request event payload.
+ BASE_SHA=$(jq --raw-output .pull_request.base.sha "$GITHUB_EVENT_PATH")
+ echo "Base commit SHA: $BASE_SHA"
+
+ # Get changed files (only JS/TS files in api/, client/, or packages/)
+ mapfile -d '' -t CHANGED_FILES < <(
+ git diff -z --name-only --diff-filter=ACMRTUXB "$BASE_SHA" HEAD |
+ grep -zE '^(api|client|packages)/.*\.(js|jsx|ts|tsx)$' || true
+ )
+
+ # Debug output
+ echo "Changed files:"
+ printf '%s\n' "${CHANGED_FILES[@]}"
+
+ # Ensure there are files to lint before running ESLint
+ if [[ ${#CHANGED_FILES[@]} -eq 0 ]]; then
+ echo "No matching files changed. Skipping ESLint."
+ exit 0
+ fi
+
+ # Run ESLint
+ # --no-warn-ignored: changed files under config-ignored paths
+ # (e.g. packages/data-schemas/misc/**) must not fail --max-warnings=0
+ # Invoke the installed binary, not `npx`: npm exec joins the whole
+ # command into one shell string, and Linux rejects a single argv
+ # string over 128 KiB (MAX_ARG_STRLEN) — past ~2,200 changed files
+ # npx dies with exit 249 and no output.
+ node_modules/.bin/eslint --no-error-on-unmatched-pattern \
+ --config eslint.config.mjs \
+ --no-warn-ignored \
+ --max-warnings=0 \
+ -- "${CHANGED_FILES[@]}"
+
+ # Run Prettier --check on the same set of changed files to catch
+ # formatting drift in PRs that bypassed the local pre-commit hook
+ # (e.g. GitHub UI edit-and-merge, `git commit --no-verify`).
+ - name: Run Prettier --check on changed files
+ id: prettier
+ if: always() && steps.paths.outputs.eslint == 'true'
+ continue-on-error: true
+ run: |
+ BASE_SHA=$(jq --raw-output .pull_request.base.sha "$GITHUB_EVENT_PATH")
+ mapfile -d '' -t CHANGED_FILES < <(
+ git diff -z --name-only --diff-filter=ACMRTUXB "$BASE_SHA" HEAD |
+ grep -zE '^(api|client|packages)/.*\.(js|jsx|ts|tsx)$' || true
+ )
+
+ if [[ ${#CHANGED_FILES[@]} -eq 0 ]]; then
+ echo "No matching files changed. Skipping Prettier."
+ exit 0
+ fi
+
+ echo "Files to check:"
+ printf '%s\n' "${CHANGED_FILES[@]}"
+
+ # `prettier --check` exits non-zero if any file would be reformatted.
+ # Suggest the local fix in the failure message so contributors aren't
+ # left guessing how to resolve.
+ if ! node_modules/.bin/prettier --check --no-error-on-unmatched-pattern -- "${CHANGED_FILES[@]}"; then
+ echo ""
+ echo "::error::Prettier formatting drift detected. Fix locally with:"
+ echo "::error:: npx prettier --write "
+ echo "::error::Or rely on the lint-staged pre-commit hook (do not bypass with --no-verify)."
+ exit 1
+ fi
+
+ # Verify import ordering on the same set of changed files. The script
+ # only sorts files under known source roots, so unrelated changed files
+ # (configs, etc.) are ignored. Matches the lint-staged pre-commit hook.
+ - name: Check import sorting on changed files
+ id: import_sort
+ if: always() && steps.paths.outputs.eslint == 'true'
+ continue-on-error: true
+ run: |
+ BASE_SHA=$(jq --raw-output .pull_request.base.sha "$GITHUB_EVENT_PATH")
+ mapfile -d '' -t CHANGED_FILES < <(
+ git diff -z --name-only --diff-filter=ACMRTUXB "$BASE_SHA" HEAD |
+ grep -zE '^(api|client|packages)/.*\.(js|jsx|ts|tsx)$' || true
+ )
+
+ if [[ ${#CHANGED_FILES[@]} -eq 0 ]]; then
+ echo "No matching files changed. Skipping import-sort check."
+ exit 0
+ fi
+
+ echo "Files to check:"
+ printf '%s\n' "${CHANGED_FILES[@]}"
+
+ # `--check` lists offending files and exits non-zero without writing.
+ if ! node scripts/sort-imports.mts --check "${CHANGED_FILES[@]}"; then
+ echo ""
+ echo "::error::Import order drift detected. Fix locally with:"
+ echo "::error:: npm run sort-imports"
+ echo "::error::For specific files:"
+ echo "::error:: npm run sort-imports -- packages/api/src/app/metrics.ts packages/api/src/rum/proxy.ts"
+ echo "::error::To check without writing files:"
+ echo "::error:: npm run sort-imports:check"
+ echo "::error::Or rely on the lint-staged pre-commit hook (do not bypass with --no-verify)."
+ exit 1
+ fi
+
+ # The changed-file lint above never loads a changed root config: a
+ # config-only PR matches no lintable files, so even a malformed
+ # eslint.config.mjs would pass. When the config changes, gate on it
+ # loading and applying cleanly to representative sources, then run the
+ # full-tree regression gate below.
+ # Directory args, not `npm run lint`: the root brace-expansion@^5
+ # override breaks minimatch@3's brace expansion, so that script's
+ # braced glob crashes on a clean install; dir args never brace-expand.
+ # scripts/static-checks.mts runs these same checks locally from the
+ # pre-commit hook, and nothing else in this job loads it: ESLint has no
+ # flat-config match for scripts/**/*.mts. Run it against the PR's own
+ # diff so a syntax error or a broken filter fails here rather than in
+ # every contributor's next commit.
+ - name: Smoke the local static-checks runner
+ id: runner
+ if: always() && steps.paths.outputs.runner == 'true'
+ continue-on-error: true
+ run: |
+ BASE_SHA=$(jq --raw-output .pull_request.base.sha "$GITHUB_EVENT_PATH")
+ node scripts/static-checks.mts --against "$BASE_SHA" --list
+ # An explicit target, because --list never executes a check and a
+ # script-only PR activates no group — so neither would exercise the
+ # execution path this step exists to protect.
+ node scripts/static-checks.mts package.json --only json
+
+ - name: Validate ESLint config on config changes
+ id: eslint_config
+ if: always() && steps.paths.outputs.eslint_config == 'true'
+ continue-on-error: true
+ run: |
+ node_modules/.bin/eslint --config eslint.config.mjs \
+ api/server/index.js client/src/main.jsx packages/api/src/index.ts
+
+ - name: Restore data-provider build cache
+ if: always() && steps.paths.outputs.config == 'true'
+ id: cache-data-provider
+ continue-on-error: true
+ uses: actions/cache@v5
+ with:
+ path: packages/data-provider/dist
+ key: build-data-provider-${{ runner.os }}-${{ hashFiles('package.json', 'package-lock.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }}
+
+ - name: Build data-provider
+ id: config_data_provider
+ if: always() && steps.paths.outputs.config == 'true' && steps.cache-data-provider.outputs.cache-hit != 'true'
+ continue-on-error: true
+ run: npm run build:data-provider
+
+ - name: Restore data-schemas build cache
+ if: always() && steps.paths.outputs.config == 'true'
+ id: cache-data-schemas
+ continue-on-error: true
+ uses: actions/cache@v5
+ with:
+ path: packages/data-schemas/dist
+ key: build-data-schemas-${{ runner.os }}-${{ hashFiles('package.json', 'package-lock.json', 'packages/data-schemas/src/**', 'packages/data-schemas/tsconfig*.json', 'packages/data-schemas/tsdown.config.mjs', 'packages/data-schemas/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }}
+
+ - name: Build data-schemas
+ id: config_data_schemas
+ if: always() && steps.paths.outputs.config == 'true' && steps.cache-data-schemas.outputs.cache-hit != 'true'
+ continue-on-error: true
+ run: npm run build:data-schemas
+
+ - name: Restore api build cache
+ if: always() && steps.paths.outputs.config == 'true'
+ id: cache-api
+ continue-on-error: true
+ uses: actions/cache@v5
+ with:
+ path: packages/api/dist
+ key: build-api-${{ runner.os }}-${{ hashFiles('package.json', 'package-lock.json', 'packages/api/src/**', 'packages/api/tsconfig*.json', 'packages/api/tsdown.config.mjs', 'packages/api/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json', 'packages/data-schemas/src/**', 'packages/data-schemas/tsconfig*.json', 'packages/data-schemas/tsdown.config.mjs', 'packages/data-schemas/package.json') }}
+
+ - name: Build api
+ id: config_api
+ if: always() && steps.paths.outputs.config == 'true' && steps.cache-api.outputs.cache-hit != 'true'
+ continue-on-error: true
+ run: npm run build:api
+
+ - name: Create empty auth.json file
+ id: config_auth
+ if: always() && steps.paths.outputs.config == 'true'
+ continue-on-error: true
+ run: |
+ mkdir -p api/data
+ echo '{}' > api/data/auth.json
+
+ - name: Prepare .env.test file
+ id: config_env
+ if: always() && steps.paths.outputs.config == 'true'
+ continue-on-error: true
+ run: cp api/test/.env.test.example api/test/.env.test
+
+ - name: Run config migration tests
+ id: config_tests
+ if: always() && steps.paths.outputs.config == 'true'
+ continue-on-error: true
+ run: npm run test:config
+
+ - name: Find unused i18next keys
+ id: find_unused_i18n
+ if: always() && steps.paths.outputs.i18n == 'true'
+ continue-on-error: true
+ run: |
+ echo "🔍 Scanning for unused i18next keys..."
+
+ # Define paths
+ I18N_FILE="client/src/locales/en/translation.json"
+ SOURCE_DIRS=("client/src" "api" "packages/data-provider/src" "packages/client" "packages/data-schemas/src")
+
+ # Check if translation file exists
+ if [[ ! -f "$I18N_FILE" ]]; then
+ echo "::error title=Missing i18n File::Translation file not found: $I18N_FILE"
+ exit 1
+ fi
+
+ # Extract all keys from the JSON file
+ KEYS=$(jq -r 'keys[]' "$I18N_FILE")
+
+ # Track unused keys
+ UNUSED_KEYS=()
+
+ # Check if each key is used in the source code
+ for KEY in $KEYS; do
+ FOUND=false
+
+ # Special case for dynamically constructed special variable keys
+ if [[ "$KEY" == com_ui_special_var_* ]]; then
+ # Check if TSpecialVarLabel is used in the codebase
+ for DIR in "${SOURCE_DIRS[@]}"; do
+ if grep -r --include=\*.{js,jsx,ts,tsx} -q "TSpecialVarLabel" "$DIR"; then
+ FOUND=true
+ break
+ fi
+ done
+
+ # Also check if the key is directly used somewhere
+ if [[ "$FOUND" == false ]]; then
+ for DIR in "${SOURCE_DIRS[@]}"; do
+ if grep -r --include=\*.{js,jsx,ts,tsx} -q "$KEY" "$DIR"; then
+ FOUND=true
+ break
+ fi
+ done
+ fi
+ # Special case for agent category keys that are dynamically used from database
+ elif [[ "$KEY" == com_agents_category_* ]]; then
+ # Check if agent category localization is being used
+ for DIR in "${SOURCE_DIRS[@]}"; do
+ # Check for dynamic category label/description usage
+ if grep -r --include=\*.{js,jsx,ts,tsx} -E "category\.(label|description).*startsWith.*['\"]com_" "$DIR" > /dev/null 2>&1 || \
+ # Check for the method that defines these keys
+ grep -r --include=\*.{js,jsx,ts,tsx} "ensureDefaultCategories" "$DIR" > /dev/null 2>&1 || \
+ # Check for direct usage in agentCategory.ts
+ grep -r --include=\*.ts -E "label:.*['\"]$KEY['\"]" "$DIR" > /dev/null 2>&1 || \
+ grep -r --include=\*.ts -E "description:.*['\"]$KEY['\"]" "$DIR" > /dev/null 2>&1; then
+ FOUND=true
+ break
+ fi
+ done
+
+ # Also check if the key is directly used somewhere
+ if [[ "$FOUND" == false ]]; then
+ for DIR in "${SOURCE_DIRS[@]}"; do
+ if grep -r --include=\*.{js,jsx,ts,tsx} -q "$KEY" "$DIR"; then
+ FOUND=true
+ break
+ fi
+ done
+ fi
+ else
+ # Regular check for other keys
+ for DIR in "${SOURCE_DIRS[@]}"; do
+ if grep -r --include=\*.{js,jsx,ts,tsx} -q "$KEY" "$DIR"; then
+ FOUND=true
+ break
+ fi
+ done
+ fi
+
+ if [[ "$FOUND" == false ]]; then
+ UNUSED_KEYS+=("$KEY")
+ fi
+ done
+
+ # Output results
+ if [[ ${#UNUSED_KEYS[@]} -gt 0 ]]; then
+ echo "🛑 Found ${#UNUSED_KEYS[@]} unused i18n keys:"
+ echo "unused_keys=$(echo "${UNUSED_KEYS[@]}" | jq -R -s -c 'split(" ")')" >> $GITHUB_ENV
+ for KEY in "${UNUSED_KEYS[@]}"; do
+ echo "::warning title=Unused i18n Key::'$KEY' is defined but not used in the codebase."
+ done
+ else
+ echo "✅ No unused i18n keys detected!"
+ echo "unused_keys=[]" >> $GITHUB_ENV
+ fi
+
+ - name: Fail workflow if unused keys found
+ id: i18n
+ if: >
+ always() &&
+ steps.paths.outputs.i18n == 'true' &&
+ (steps.find_unused_i18n.outcome == 'failure' || env.unused_keys != '[]')
+ continue-on-error: true
+ run: exit 1
+
+ - name: Install depcheck
+ id: install_depcheck
+ if: always() && steps.paths.outputs.unused_packages == 'true'
+ continue-on-error: true
+ run: npm install -g depcheck
+
+ - name: Validate JSON files
+ id: validate_package_json
+ if: always() && steps.paths.outputs.unused_packages == 'true'
+ continue-on-error: true
+ run: |
+ for FILE in package.json client/package.json api/package.json packages/api/package.json packages/client/package.json packages/data-provider/package.json packages/data-schemas/package.json; do
+ if [[ -f "$FILE" ]]; then
+ jq empty "$FILE" || (echo "::error title=Invalid JSON::$FILE is invalid" && exit 1)
+ fi
+ done
+
+ - name: Extract Dependencies Used in Scripts
+ if: always() && steps.paths.outputs.unused_packages == 'true'
+ id: extract-used-scripts
+ continue-on-error: true
+ run: |
+ extract_deps_from_scripts() {
+ local package_file=$1
+ if [[ -f "$package_file" ]]; then
+ jq -r '.scripts | to_entries[].value' "$package_file" | \
+ grep -oE '([a-zA-Z0-9_-]+)' | sort -u > used_scripts.txt
+ else
+ touch used_scripts.txt
+ fi
+ }
+
+ extract_deps_from_scripts "package.json"
+ mv used_scripts.txt root_used_deps.txt
+
+ extract_deps_from_scripts "client/package.json"
+ mv used_scripts.txt client_used_deps.txt
+
+ extract_deps_from_scripts "api/package.json"
+ mv used_scripts.txt api_used_deps.txt
+
+ - name: Extract Dependencies Used in Source Code
+ if: always() && steps.paths.outputs.unused_packages == 'true'
+ id: extract-used-code
+ continue-on-error: true
+ run: |
+ extract_deps_from_code() {
+ local folder=$1
+ local output_file=$2
+
+ # Initialize empty output file
+ > "$output_file"
+
+ if [[ -d "$folder" ]]; then
+ # Extract require() statements (use explicit includes for portability)
+ grep -rEho "require\\(['\"]([a-zA-Z0-9@/._-]+)['\"]\\)" "$folder" \
+ --include='*.js' --include='*.ts' --include='*.tsx' --include='*.jsx' --include='*.mjs' --include='*.cjs' 2>/dev/null | \
+ sed -E "s/require\\(['\"]([a-zA-Z0-9@/._-]+)['\"]\\)/\1/" >> "$output_file" || true
+
+ # Extract ES6 imports - import x from 'module'
+ grep -rEho "import .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]" "$folder" \
+ --include='*.js' --include='*.ts' --include='*.tsx' --include='*.jsx' --include='*.mjs' --include='*.cjs' 2>/dev/null | \
+ sed -E "s/import .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]/\1/" >> "$output_file" || true
+
+ # import 'module' (side-effect imports)
+ grep -rEho "import ['\"]([a-zA-Z0-9@/._-]+)['\"]" "$folder" \
+ --include='*.js' --include='*.ts' --include='*.tsx' --include='*.jsx' --include='*.mjs' --include='*.cjs' 2>/dev/null | \
+ sed -E "s/import ['\"]([a-zA-Z0-9@/._-]+)['\"]/\1/" >> "$output_file" || true
+
+ # export { x } from 'module' or export * from 'module'
+ grep -rEho "export .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]" "$folder" \
+ --include='*.js' --include='*.ts' --include='*.tsx' --include='*.jsx' --include='*.mjs' --include='*.cjs' 2>/dev/null | \
+ sed -E "s/export .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]/\1/" >> "$output_file" || true
+
+ # import type { x } from 'module' (TypeScript)
+ grep -rEho "import type .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]" "$folder" \
+ --include='*.ts' --include='*.tsx' 2>/dev/null | \
+ sed -E "s/import type .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]/\1/" >> "$output_file" || true
+
+ # Remove subpath imports but keep the base package
+ # For scoped packages: '@scope/pkg/subpath' -> '@scope/pkg'
+ # For regular packages: 'pkg/subpath' -> 'pkg'
+ # Scoped packages (must keep @scope/package, strip anything after)
+ sed -i -E 's|^(@[a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+)/.*|\1|' "$output_file" 2>/dev/null || true
+ # Non-scoped packages (keep package name, strip subpath)
+ sed -i -E 's|^([a-zA-Z0-9_-]+)/.*|\1|' "$output_file" 2>/dev/null || true
+
+ sort -u "$output_file" -o "$output_file"
+ fi
+ }
+
+ extract_deps_from_code "." root_used_code.txt
+ extract_deps_from_code "client" client_used_code.txt
+ extract_deps_from_code "api" api_used_code.txt
+
+ # Extract dependencies used by workspace packages
+ # These packages are used in the workspace but dependencies are provided by parent package.json
+ extract_deps_from_code "packages/client" packages_client_used_code.txt
+ extract_deps_from_code "packages/api" packages_api_used_code.txt
+
+ - name: Get @librechat/client dependencies
+ if: always() && steps.paths.outputs.unused_packages == 'true'
+ id: get-librechat-client-deps
+ continue-on-error: true
+ run: |
+ if [[ -f "packages/client/package.json" ]]; then
+ # Get all dependencies from @librechat/client (dependencies, devDependencies, and peerDependencies)
+ DEPS=$(jq -r '.dependencies // {} | keys[]' packages/client/package.json 2>/dev/null || echo "")
+ DEV_DEPS=$(jq -r '.devDependencies // {} | keys[]' packages/client/package.json 2>/dev/null || echo "")
+ PEER_DEPS=$(jq -r '.peerDependencies // {} | keys[]' packages/client/package.json 2>/dev/null || echo "")
+
+ # Combine all dependencies
+ echo "$DEPS" > librechat_client_deps.txt
+ echo "$DEV_DEPS" >> librechat_client_deps.txt
+ echo "$PEER_DEPS" >> librechat_client_deps.txt
+
+ # Also include dependencies that are imported in packages/client
+ cat packages_client_used_code.txt >> librechat_client_deps.txt
+
+ # Remove empty lines and sort
+ grep -v '^$' librechat_client_deps.txt | sort -u > temp_deps.txt
+ mv temp_deps.txt librechat_client_deps.txt
+ else
+ touch librechat_client_deps.txt
+ fi
+
+ - name: Get @librechat/api dependencies
+ if: always() && steps.paths.outputs.unused_packages == 'true'
+ id: get-librechat-api-deps
+ continue-on-error: true
+ run: |
+ if [[ -f "packages/api/package.json" ]]; then
+ # Get all dependencies from @librechat/api (dependencies, devDependencies, and peerDependencies)
+ DEPS=$(jq -r '.dependencies // {} | keys[]' packages/api/package.json 2>/dev/null || echo "")
+ DEV_DEPS=$(jq -r '.devDependencies // {} | keys[]' packages/api/package.json 2>/dev/null || echo "")
+ PEER_DEPS=$(jq -r '.peerDependencies // {} | keys[]' packages/api/package.json 2>/dev/null || echo "")
+
+ # Combine all dependencies
+ echo "$DEPS" > librechat_api_deps.txt
+ echo "$DEV_DEPS" >> librechat_api_deps.txt
+ echo "$PEER_DEPS" >> librechat_api_deps.txt
+
+ # Also include dependencies that are imported in packages/api
+ cat packages_api_used_code.txt >> librechat_api_deps.txt
+
+ # Remove empty lines and sort
+ grep -v '^$' librechat_api_deps.txt | sort -u > temp_deps.txt
+ mv temp_deps.txt librechat_api_deps.txt
+ else
+ touch librechat_api_deps.txt
+ fi
+
+ - name: Extract Workspace Dependencies
+ if: always() && steps.paths.outputs.unused_packages == 'true'
+ id: extract-workspace-deps
+ continue-on-error: true
+ run: |
+ # Function to get dependencies from a workspace package that are used by another package
+ get_workspace_package_deps() {
+ local package_json=$1
+ local output_file=$2
+
+ # Get all workspace dependencies (starting with @librechat/)
+ if [[ -f "$package_json" ]]; then
+ local workspace_deps=$(jq -r '.dependencies // {} | to_entries[] | select(.key | startswith("@librechat/")) | .key' "$package_json" 2>/dev/null || echo "")
+
+ # For each workspace dependency, get its dependencies
+ for dep in $workspace_deps; do
+ # Convert @librechat/api to packages/api
+ local workspace_path=$(echo "$dep" | sed 's/@librechat\//packages\//')
+ local workspace_package_json="${workspace_path}/package.json"
+
+ if [[ -f "$workspace_package_json" ]]; then
+ # Extract all dependencies from the workspace package
+ jq -r '.dependencies // {} | keys[]' "$workspace_package_json" 2>/dev/null >> "$output_file"
+ # Also extract peerDependencies
+ jq -r '.peerDependencies // {} | keys[]' "$workspace_package_json" 2>/dev/null >> "$output_file"
+ fi
+ done
+ fi
+
+ if [[ -f "$output_file" ]]; then
+ sort -u "$output_file" -o "$output_file"
+ else
+ touch "$output_file"
+ fi
+ }
+
+ # Get workspace dependencies for each package
+ get_workspace_package_deps "package.json" root_workspace_deps.txt
+ get_workspace_package_deps "client/package.json" client_workspace_deps.txt
+ get_workspace_package_deps "api/package.json" api_workspace_deps.txt
+
+ - name: Run depcheck for root package.json
+ if: always() && steps.paths.outputs.unused_packages == 'true'
+ id: check-root
+ continue-on-error: true
+ run: |
+ if [[ -f "package.json" ]]; then
+ UNUSED=$(depcheck --json | jq -r '.dependencies | join("\n")' || echo "")
+ # Exclude dependencies used in scripts, code, and workspace packages
+ UNUSED=$(comm -23 <(echo "$UNUSED" | sort) <(cat root_used_deps.txt root_used_code.txt root_workspace_deps.txt | sort) || echo "")
+ echo "ROOT_UNUSED<> $GITHUB_ENV
+ echo "$UNUSED" >> $GITHUB_ENV
+ echo "EOF" >> $GITHUB_ENV
+ fi
+
+ - name: Run depcheck for client/package.json
+ if: always() && steps.paths.outputs.unused_packages == 'true'
+ id: check-client
+ continue-on-error: true
+ run: |
+ if [[ -f "client/package.json" ]]; then
+ chmod -R 755 client
+ cd client
+ UNUSED=$(depcheck --json | jq -r '.dependencies | join("\n")' || echo "")
+ # Exclude dependencies used in scripts, code, workspace packages, and @librechat/client imports
+ UNUSED=$(comm -23 <(echo "$UNUSED" | sort) <(cat ../client_used_deps.txt ../client_used_code.txt ../client_workspace_deps.txt ../packages_client_used_code.txt ../librechat_client_deps.txt 2>/dev/null | sort -u) || echo "")
+ # Filter out false positives
+ UNUSED=$(echo "$UNUSED" | grep -v "^micromark-extension-llm-math$" || echo "")
+ echo "CLIENT_UNUSED<> $GITHUB_ENV
+ echo "$UNUSED" >> $GITHUB_ENV
+ echo "EOF" >> $GITHUB_ENV
+ cd ..
+ fi
+
+ - name: Run depcheck for api/package.json
+ if: always() && steps.paths.outputs.unused_packages == 'true'
+ id: check-api
+ continue-on-error: true
+ run: |
+ if [[ -f "api/package.json" ]]; then
+ chmod -R 755 api
+ cd api
+ UNUSED=$(depcheck --json | jq -r '.dependencies | join("\n")' || echo "")
+ # Exclude dependencies used in scripts, code, workspace packages, and @librechat/api imports
+ UNUSED=$(comm -23 <(echo "$UNUSED" | sort) <(cat ../api_used_deps.txt ../api_used_code.txt ../api_workspace_deps.txt ../packages_api_used_code.txt ../librechat_api_deps.txt 2>/dev/null | sort -u) || echo "")
+ echo "API_UNUSED<> $GITHUB_ENV
+ echo "$UNUSED" >> $GITHUB_ENV
+ echo "EOF" >> $GITHUB_ENV
+ cd ..
+ fi
+
+ - name: Fail workflow if unused dependencies found
+ id: unused_packages
+ if: >
+ always() &&
+ steps.paths.outputs.unused_packages == 'true' &&
+ (env.ROOT_UNUSED != '' || env.CLIENT_UNUSED != '' || env.API_UNUSED != '')
+ continue-on-error: true
+ run: exit 1
+
+ - name: Summarize static check failures
+ if: always()
+ env:
+ INSTALL_DEPENDENCIES_OUTCOME: ${{ steps.install_dependencies.outcome }}
+ ESLINT_OUTCOME: ${{ steps.eslint.outcome }}
+ ESLINT_CONFIG_OUTCOME: ${{ steps.eslint_config.outcome }}
+ PRETTIER_OUTCOME: ${{ steps.prettier.outcome }}
+ IMPORT_SORT_OUTCOME: ${{ steps.import_sort.outcome }}
+ RUNNER_OUTCOME: ${{ steps.runner.outcome }}
+ CACHE_DATA_PROVIDER_OUTCOME: ${{ steps.cache-data-provider.outcome }}
+ CONFIG_DATA_PROVIDER_OUTCOME: ${{ steps.config_data_provider.outcome }}
+ CACHE_DATA_SCHEMAS_OUTCOME: ${{ steps.cache-data-schemas.outcome }}
+ CONFIG_DATA_SCHEMAS_OUTCOME: ${{ steps.config_data_schemas.outcome }}
+ CACHE_API_OUTCOME: ${{ steps.cache-api.outcome }}
+ CONFIG_API_OUTCOME: ${{ steps.config_api.outcome }}
+ CONFIG_AUTH_OUTCOME: ${{ steps.config_auth.outcome }}
+ CONFIG_ENV_OUTCOME: ${{ steps.config_env.outcome }}
+ CONFIG_TESTS_OUTCOME: ${{ steps.config_tests.outcome }}
+ FIND_I18N_OUTCOME: ${{ steps.find_unused_i18n.outcome }}
+ I18N_OUTCOME: ${{ steps.i18n.outcome }}
+ INSTALL_DEPCHECK_OUTCOME: ${{ steps.install_depcheck.outcome }}
+ VALIDATE_PACKAGE_JSON_OUTCOME: ${{ steps.validate_package_json.outcome }}
+ EXTRACT_USED_SCRIPTS_OUTCOME: ${{ steps.extract-used-scripts.outcome }}
+ EXTRACT_USED_CODE_OUTCOME: ${{ steps.extract-used-code.outcome }}
+ GET_CLIENT_DEPS_OUTCOME: ${{ steps.get-librechat-client-deps.outcome }}
+ GET_API_DEPS_OUTCOME: ${{ steps.get-librechat-api-deps.outcome }}
+ EXTRACT_WORKSPACE_DEPS_OUTCOME: ${{ steps.extract-workspace-deps.outcome }}
+ CHECK_ROOT_OUTCOME: ${{ steps.check-root.outcome }}
+ CHECK_CLIENT_OUTCOME: ${{ steps.check-client.outcome }}
+ CHECK_API_OUTCOME: ${{ steps.check-api.outcome }}
+ UNUSED_PACKAGES_OUTCOME: ${{ steps.unused_packages.outcome }}
+ run: |
+ failures=()
+
+ record_failure() {
+ if [[ "$2" == "failure" ]]; then
+ failures+=("$1")
+ fi
+ }
+
+ record_failure "Dependency installation" "$INSTALL_DEPENDENCIES_OUTCOME"
+ record_failure "ESLint" "$ESLINT_OUTCOME"
+ record_failure "ESLint config validation" "$ESLINT_CONFIG_OUTCOME"
+ record_failure "Prettier" "$PRETTIER_OUTCOME"
+ record_failure "Import sorting" "$IMPORT_SORT_OUTCOME"
+ record_failure "Local static-checks runner" "$RUNNER_OUTCOME"
+ record_failure "Config data-provider cache" "$CACHE_DATA_PROVIDER_OUTCOME"
+ record_failure "Config data-provider build" "$CONFIG_DATA_PROVIDER_OUTCOME"
+ record_failure "Config data-schemas cache" "$CACHE_DATA_SCHEMAS_OUTCOME"
+ record_failure "Config data-schemas build" "$CONFIG_DATA_SCHEMAS_OUTCOME"
+ record_failure "Config API cache" "$CACHE_API_OUTCOME"
+ record_failure "Config API build" "$CONFIG_API_OUTCOME"
+ record_failure "Config auth preparation" "$CONFIG_AUTH_OUTCOME"
+ record_failure "Config environment preparation" "$CONFIG_ENV_OUTCOME"
+ record_failure "Config migration tests" "$CONFIG_TESTS_OUTCOME"
+ record_failure "Unused i18n scan" "$FIND_I18N_OUTCOME"
+ record_failure "Unused i18n keys" "$I18N_OUTCOME"
+ record_failure "depcheck installation" "$INSTALL_DEPCHECK_OUTCOME"
+ record_failure "Package JSON validation" "$VALIDATE_PACKAGE_JSON_OUTCOME"
+ record_failure "Package script dependency extraction" "$EXTRACT_USED_SCRIPTS_OUTCOME"
+ record_failure "Source dependency extraction" "$EXTRACT_USED_CODE_OUTCOME"
+ record_failure "Client dependency collection" "$GET_CLIENT_DEPS_OUTCOME"
+ record_failure "API dependency collection" "$GET_API_DEPS_OUTCOME"
+ record_failure "Workspace dependency extraction" "$EXTRACT_WORKSPACE_DEPS_OUTCOME"
+ record_failure "Root depcheck" "$CHECK_ROOT_OUTCOME"
+ record_failure "Client depcheck" "$CHECK_CLIENT_OUTCOME"
+ record_failure "API depcheck" "$CHECK_API_OUTCOME"
+ record_failure "Unused NPM packages" "$UNUSED_PACKAGES_OUTCOME"
+
+ if [[ "$UNUSED_PACKAGES_OUTCOME" == "failure" ]]; then
+ [[ -n "$ROOT_UNUSED" ]] && printf 'Root unused dependencies:\n%s\n' "$ROOT_UNUSED"
+ [[ -n "$CLIENT_UNUSED" ]] && printf 'Client unused dependencies:\n%s\n' "$CLIENT_UNUSED"
+ [[ -n "$API_UNUSED" ]] && printf 'API unused dependencies:\n%s\n' "$API_UNUSED"
+ fi
+
+ if [[ ${#failures[@]} -eq 0 ]]; then
+ echo "All affected static checks passed."
+ exit 0
+ fi
+
+ echo "::error::Static checks failed:"
+ printf ' - %s\n' "${failures[@]}"
+ exit 1
+
+ # Runs as its own job rather than a step inside `static-checks`. Two full
+ # type-aware sweeps of api+client+packages cost more than the rest of that
+ # job combined, and sharing one 30-minute budget with ~20 later steps meant a
+ # slow sweep starved config-migration, i18n and depcheck — the job then
+ # reported nothing at all, which is strictly worse than not running the gate.
+ eslint-sweep:
+ name: ESLint config regression sweep
+ runs-on: ubuntu-latest
+ timeout-minutes: 45
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v5
+ with:
+ # fetch-depth: 0 is load-bearing — the gate reads the base ref's
+ # config via `git show`, which a shallow checkout cannot resolve.
+ fetch-depth: 0
+
+ - name: Detect config changes
+ id: paths
+ uses: dorny/paths-filter@v4
+ with:
+ predicate-quantifier: 'some-with-excludes'
+ filters: |
+ eslint_config:
+ - 'eslint.config.mjs'
+ - '.github/workflows/static-checks.yml'
+
+ - name: Set up Node.js 24.16.0
+ if: steps.paths.outputs.eslint_config == 'true'
+ uses: actions/setup-node@v5
+ with:
+ node-version: '24.16.0'
+ cache: npm
+
+ - name: Install dependencies
+ if: steps.paths.outputs.eslint_config == 'true'
+ run: npm ci
+
+ # Full-tree sweep that gates on regression, not cleanliness: the tree
+ # carries a pre-existing lint backlog (70 errors at time of wiring), so
+ # requiring a clean sweep would fail config PRs on unrelated debt.
+ # Instead, lint the same tree under the PR's config and under the base
+ # ref's config and fail when the PR's config (a) stops linting files
+ # the base config covered — the signature of a mis-scoped ignores — or
+ # (b) produces more diagnostics for some (file, rule, severity) triple.
+ # On an identical tree, any delta is attributable to the config change
+ # alone. Severity is part of the key so a warn->error escalation must
+ # land with the tree clean for that rule; downgrades and fixes are
+ # never penalized.
+ - name: ESLint full-sweep regression gate on config changes
+ id: eslint_sweep
+ if: steps.paths.outputs.eslint_config == 'true'
+ env:
+ # A sweep that outruns this budget yields a notice, not a failure:
+ # the gate is advisory about config scope, and an unfinished sweep is
+ # no evidence of a regression. Bounding it also keeps a pathological
+ # config from burning the whole job timeout with nothing to show.
+ ESLINT_SWEEP_BUDGET_SECONDS: '900'
+ run: |
+ run_sweep() {
+ set +e
+ # Two rules are switched off for the sweep only: `prettier/prettier`
+ # reformats every file (formatting drift is caught per changed file
+ # by the Static checks job and is not a config regression), and
+ # `import/no-cycle` walks the whole import graph from every file
+ # while config/circular-deps.mjs already owns cycle detection.
+ # Together they were ~85% of a full-tree lint.
+ timeout -k 15 "$ESLINT_SWEEP_BUDGET_SECONDS" \
+ node_modules/.bin/eslint --config "$1" api client packages -f json -o "$2" \
+ --rule 'prettier/prettier: off' \
+ --rule 'import/no-cycle: off'
+ local status=$?
+ set -e
+ # 124 = timeout sent TERM; 137 = it escalated to KILL.
+ if [ "$status" -eq 124 ] || [ "$status" -eq 137 ]; then
+ return 124
+ fi
+ return 0
+ }
+
+ if ! run_sweep eslint.config.mjs "$RUNNER_TEMP/eslint-head.json"; then
+ echo "::notice title=ESLint sweep::Head sweep exceeded ${ESLINT_SWEEP_BUDGET_SECONDS}s; skipping the regression gate for this run."
+ exit 0
+ fi
+ if [ ! -s "$RUNNER_TEMP/eslint-head.json" ]; then
+ echo "::error title=ESLint sweep::Head-config sweep produced no report — ESLint likely crashed under the new config."
+ exit 1
+ fi
+
+ BASE_SHA=$(jq --raw-output .pull_request.base.sha "$GITHUB_EVENT_PATH")
+ if ! git cat-file -e "$BASE_SHA^{commit}" 2>/dev/null; then
+ echo "::error title=ESLint sweep::Base commit is unavailable — this gate requires the checkout above to keep fetch-depth: 0."
+ exit 1
+ fi
+ # The base config is written to the repo root, not $RUNNER_TEMP:
+ # flat-config files/ignores patterns and plugin imports resolve
+ # relative to the config's own directory, so a temp-dir copy would
+ # scope to nothing and the comparison would pass vacuously.
+ # An unchanged config cannot regress: the head sweep above already
+ # proved this workflow still runs it, so a second identical sweep
+ # would only double the job's runtime.
+ if git diff --quiet "$BASE_SHA" HEAD -- eslint.config.mjs; then
+ echo "::notice title=ESLint sweep::eslint.config.mjs is unchanged from base; skipping the regression comparison."
+ exit 0
+ fi
+ trap 'rm -f eslint.config.base.mjs' EXIT
+ if ! git show "$BASE_SHA:eslint.config.mjs" > eslint.config.base.mjs 2>/dev/null; then
+ echo "::notice title=ESLint sweep::No eslint.config.mjs at base ref; skipping regression comparison."
+ exit 0
+ fi
+ if ! run_sweep eslint.config.base.mjs "$RUNNER_TEMP/eslint-base.json"; then
+ echo "::notice title=ESLint sweep::Base sweep exceeded ${ESLINT_SWEEP_BUDGET_SECONDS}s; skipping the regression comparison."
+ exit 0
+ fi
+ if [ ! -s "$RUNNER_TEMP/eslint-base.json" ]; then
+ echo "::notice title=ESLint sweep::Base config cannot run against this tree; skipping regression comparison."
+ exit 0
+ fi
+
+ jq -r '.[].filePath' "$RUNNER_TEMP/eslint-head.json" | sort > "$RUNNER_TEMP/head.files"
+ jq -r '.[].filePath' "$RUNNER_TEMP/eslint-base.json" | sort > "$RUNNER_TEMP/base.files"
+ LOST=$(comm -23 "$RUNNER_TEMP/base.files" "$RUNNER_TEMP/head.files")
+ if [ -n "$LOST" ]; then
+ LOST_COUNT=$(printf '%s\n' "$LOST" | wc -l)
+ echo "::error title=ESLint coverage regression::The config change stops linting $LOST_COUNT file(s) the base config covered (showing up to 20):"
+ printf '%s\n' "$LOST" | head -20
+ exit 1
+ fi
+
+ fingerprints() {
+ jq -r '.[] | .filePath as $f | .messages[] | "\($f)\t\(.ruleId // "parse-error")\t\(.severity)"' "$1" |
+ sort | uniq -c | sed -E 's/^ *([0-9]+) /\1\t/'
+ }
+ fingerprints "$RUNNER_TEMP/eslint-head.json" > "$RUNNER_TEMP/head.fp"
+ fingerprints "$RUNNER_TEMP/eslint-base.json" > "$RUNNER_TEMP/base.fp"
+
+ REGRESSIONS=$(awk -F'\t' '
+ NR==FNR { base[$2 FS $3 FS $4] = $1; next }
+ {
+ if ($1 > base[$2 FS $3 FS $4] + 0) {
+ sev = ($4 == 2) ? "error" : "warn"
+ printf "%s %s (%s): %d -> %d\n", $2, $3, sev, base[$2 FS $3 FS $4] + 0, $1
+ }
+ }
+ ' "$RUNNER_TEMP/base.fp" "$RUNNER_TEMP/head.fp")
+
+ if [ -n "$REGRESSIONS" ]; then
+ echo "::error title=ESLint config regression::The config change introduces new diagnostics (file rule (severity): base -> head):"
+ echo "$REGRESSIONS"
+ exit 1
+ fi
+ echo "No coverage loss and no new diagnostics versus the base config."
diff --git a/.github/workflows/sync-helm-chart-tags.yml b/.github/workflows/sync-helm-chart-tags.yml
index bde4c2f49a1..13add36fcf5 100644
--- a/.github/workflows/sync-helm-chart-tags.yml
+++ b/.github/workflows/sync-helm-chart-tags.yml
@@ -4,6 +4,8 @@ on:
push:
branches:
- main
+ paths-ignore:
+ - '**.md'
workflow_dispatch:
inputs:
release_existing_tag:
diff --git a/.github/workflows/tag-images.yml b/.github/workflows/tag-images.yml
index 3b4dfc0cd0b..af7c14d939f 100644
--- a/.github/workflows/tag-images.yml
+++ b/.github/workflows/tag-images.yml
@@ -10,22 +10,19 @@ permissions:
packages: write
jobs:
- build:
+ # Validates the release tag and resolves which tags the images should carry.
+ # `latest` is only applied when this tag is both stable and the newest stable
+ # tag in the repository, so re-cutting an older patch cannot move `latest`.
+ resolve-tags:
runs-on: ubuntu-latest
- strategy:
- matrix:
- include:
- - target: api-build
- file: Dockerfile.multi
- image_name: librechat-api
- - target: node
- file: Dockerfile
- image_name: librechat
-
+ timeout-minutes: 10
+ outputs:
+ tag_suffixes: ${{ steps.tags.outputs.tag_suffixes }}
steps:
- # Check out the repository
- name: Checkout
- uses: actions/checkout@v4
+ uses: actions/checkout@v5
+ with:
+ fetch-depth: 0
- name: Validate release tag
id: release-tag
@@ -46,45 +43,9 @@ jobs:
echo "is_stable=false" >> "$GITHUB_OUTPUT"
fi
- # Set up QEMU
- - name: Set up QEMU
- uses: docker/setup-qemu-action@v3
-
- # Set up Docker Buildx
- - name: Set up Docker Buildx
- uses: docker/setup-buildx-action@v3
-
- # Log in to GitHub Container Registry
- - name: Log in to GitHub Container Registry
- uses: docker/login-action@v3
- with:
- registry: ghcr.io
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
-
- # Login to Docker Hub
- - name: Login to Docker Hub
- uses: docker/login-action@v3
- with:
- username: ${{ secrets.DOCKERHUB_USERNAME }}
- password: ${{ secrets.DOCKERHUB_TOKEN }}
-
- # Prepare the environment
- - name: Prepare environment
- run: |
- cp .env.example .env
-
- - name: Compute build metadata
- run: |
- echo "BUILD_COMMIT=${{ github.sha }}" >> $GITHUB_ENV
- echo "BUILD_BRANCH=${{ github.ref_name }}" >> $GITHUB_ENV
- echo "BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_ENV
-
- name: Resolve image tags
- id: image-tags
+ id: tags
env:
- DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
- IMAGE_NAME: ${{ matrix.image_name }}
IMAGE_TAG: ${{ steps.release-tag.outputs.image_tag }}
IS_STABLE: ${{ steps.release-tag.outputs.is_stable }}
run: |
@@ -92,27 +53,24 @@ jobs:
git fetch --tags --force
LATEST_STABLE_TAG=$(git tag --list 'v[0-9]*' --sort=-v:refname | grep -E '^v[0-9]+[.][0-9]+[.][0-9]+$' | head -n 1 || true)
{
- echo 'tags<> "$GITHUB_OUTPUT"
- # Build and push Docker images for each target
- - name: Build and push Docker images
- uses: docker/build-push-action@v5
- with:
- context: .
- file: ${{ matrix.file }}
- push: true
- tags: ${{ steps.image-tags.outputs.tags }}
- platforms: linux/amd64,linux/arm64
- target: ${{ matrix.target }}
- build-args: |
- BUILD_COMMIT=${{ env.BUILD_COMMIT }}
- BUILD_BRANCH=${{ env.BUILD_BRANCH }}
- BUILD_DATE=${{ env.BUILD_DATE }}
+ publish:
+ needs: resolve-tags
+ uses: ./.github/workflows/docker-publish.yml
+ with:
+ images: >-
+ [{"target":"api-build","file":"Dockerfile.multi","image_name":"librechat-api"},
+ {"target":"node","file":"Dockerfile","image_name":"librechat"}]
+ tag_suffixes: ${{ needs.resolve-tags.outputs.tag_suffixes }}
+ build_branch: ${{ github.ref_name }}
+ secrets:
+ DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
+ DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
+ LEGACY_GHCR_TOKEN: ${{ secrets.LEGACY_GHCR_TOKEN }}
diff --git a/.github/workflows/unused-packages.yml b/.github/workflows/unused-packages.yml
deleted file mode 100644
index 5401d37d5b5..00000000000
--- a/.github/workflows/unused-packages.yml
+++ /dev/null
@@ -1,282 +0,0 @@
-name: Detect Unused NPM Packages
-
-on:
- pull_request:
- paths:
- - 'package.json'
- - 'package-lock.json'
- - 'client/**'
- - 'api/**'
- - 'packages/client/**'
- - 'packages/api/**'
-
-jobs:
- detect-unused-packages:
- runs-on: ubuntu-latest
- permissions:
- contents: read
- pull-requests: write
-
- steps:
- - uses: actions/checkout@v4
-
- - name: Use Node.js 24.16.0
- uses: actions/setup-node@v4
- with:
- node-version: '24.16.0'
- cache: 'npm'
-
- - name: Install depcheck
- run: npm install -g depcheck
-
- - name: Validate JSON files
- run: |
- for FILE in package.json client/package.json api/package.json packages/client/package.json; do
- if [[ -f "$FILE" ]]; then
- jq empty "$FILE" || (echo "::error title=Invalid JSON::$FILE is invalid" && exit 1)
- fi
- done
-
- - name: Extract Dependencies Used in Scripts
- id: extract-used-scripts
- run: |
- extract_deps_from_scripts() {
- local package_file=$1
- if [[ -f "$package_file" ]]; then
- jq -r '.scripts | to_entries[].value' "$package_file" | \
- grep -oE '([a-zA-Z0-9_-]+)' | sort -u > used_scripts.txt
- else
- touch used_scripts.txt
- fi
- }
-
- extract_deps_from_scripts "package.json"
- mv used_scripts.txt root_used_deps.txt
-
- extract_deps_from_scripts "client/package.json"
- mv used_scripts.txt client_used_deps.txt
-
- extract_deps_from_scripts "api/package.json"
- mv used_scripts.txt api_used_deps.txt
-
- - name: Extract Dependencies Used in Source Code
- id: extract-used-code
- run: |
- extract_deps_from_code() {
- local folder=$1
- local output_file=$2
-
- # Initialize empty output file
- > "$output_file"
-
- if [[ -d "$folder" ]]; then
- # Extract require() statements (use explicit includes for portability)
- grep -rEho "require\\(['\"]([a-zA-Z0-9@/._-]+)['\"]\\)" "$folder" \
- --include='*.js' --include='*.ts' --include='*.tsx' --include='*.jsx' --include='*.mjs' --include='*.cjs' 2>/dev/null | \
- sed -E "s/require\\(['\"]([a-zA-Z0-9@/._-]+)['\"]\\)/\1/" >> "$output_file" || true
-
- # Extract ES6 imports - import x from 'module'
- grep -rEho "import .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]" "$folder" \
- --include='*.js' --include='*.ts' --include='*.tsx' --include='*.jsx' --include='*.mjs' --include='*.cjs' 2>/dev/null | \
- sed -E "s/import .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]/\1/" >> "$output_file" || true
-
- # import 'module' (side-effect imports)
- grep -rEho "import ['\"]([a-zA-Z0-9@/._-]+)['\"]" "$folder" \
- --include='*.js' --include='*.ts' --include='*.tsx' --include='*.jsx' --include='*.mjs' --include='*.cjs' 2>/dev/null | \
- sed -E "s/import ['\"]([a-zA-Z0-9@/._-]+)['\"]/\1/" >> "$output_file" || true
-
- # export { x } from 'module' or export * from 'module'
- grep -rEho "export .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]" "$folder" \
- --include='*.js' --include='*.ts' --include='*.tsx' --include='*.jsx' --include='*.mjs' --include='*.cjs' 2>/dev/null | \
- sed -E "s/export .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]/\1/" >> "$output_file" || true
-
- # import type { x } from 'module' (TypeScript)
- grep -rEho "import type .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]" "$folder" \
- --include='*.ts' --include='*.tsx' 2>/dev/null | \
- sed -E "s/import type .* from ['\"]([a-zA-Z0-9@/._-]+)['\"]/\1/" >> "$output_file" || true
-
- # Remove subpath imports but keep the base package
- # For scoped packages: '@scope/pkg/subpath' -> '@scope/pkg'
- # For regular packages: 'pkg/subpath' -> 'pkg'
- # Scoped packages (must keep @scope/package, strip anything after)
- sed -i -E 's|^(@[a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+)/.*|\1|' "$output_file" 2>/dev/null || true
- # Non-scoped packages (keep package name, strip subpath)
- sed -i -E 's|^([a-zA-Z0-9_-]+)/.*|\1|' "$output_file" 2>/dev/null || true
-
- sort -u "$output_file" -o "$output_file"
- fi
- }
-
- extract_deps_from_code "." root_used_code.txt
- extract_deps_from_code "client" client_used_code.txt
- extract_deps_from_code "api" api_used_code.txt
-
- # Extract dependencies used by workspace packages
- # These packages are used in the workspace but dependencies are provided by parent package.json
- extract_deps_from_code "packages/client" packages_client_used_code.txt
- extract_deps_from_code "packages/api" packages_api_used_code.txt
-
- - name: Get @librechat/client dependencies
- id: get-librechat-client-deps
- run: |
- if [[ -f "packages/client/package.json" ]]; then
- # Get all dependencies from @librechat/client (dependencies, devDependencies, and peerDependencies)
- DEPS=$(jq -r '.dependencies // {} | keys[]' packages/client/package.json 2>/dev/null || echo "")
- DEV_DEPS=$(jq -r '.devDependencies // {} | keys[]' packages/client/package.json 2>/dev/null || echo "")
- PEER_DEPS=$(jq -r '.peerDependencies // {} | keys[]' packages/client/package.json 2>/dev/null || echo "")
-
- # Combine all dependencies
- echo "$DEPS" > librechat_client_deps.txt
- echo "$DEV_DEPS" >> librechat_client_deps.txt
- echo "$PEER_DEPS" >> librechat_client_deps.txt
-
- # Also include dependencies that are imported in packages/client
- cat packages_client_used_code.txt >> librechat_client_deps.txt
-
- # Remove empty lines and sort
- grep -v '^$' librechat_client_deps.txt | sort -u > temp_deps.txt
- mv temp_deps.txt librechat_client_deps.txt
- else
- touch librechat_client_deps.txt
- fi
-
- - name: Get @librechat/api dependencies
- id: get-librechat-api-deps
- run: |
- if [[ -f "packages/api/package.json" ]]; then
- # Get all dependencies from @librechat/api (dependencies, devDependencies, and peerDependencies)
- DEPS=$(jq -r '.dependencies // {} | keys[]' packages/api/package.json 2>/dev/null || echo "")
- DEV_DEPS=$(jq -r '.devDependencies // {} | keys[]' packages/api/package.json 2>/dev/null || echo "")
- PEER_DEPS=$(jq -r '.peerDependencies // {} | keys[]' packages/api/package.json 2>/dev/null || echo "")
-
- # Combine all dependencies
- echo "$DEPS" > librechat_api_deps.txt
- echo "$DEV_DEPS" >> librechat_api_deps.txt
- echo "$PEER_DEPS" >> librechat_api_deps.txt
-
- # Also include dependencies that are imported in packages/api
- cat packages_api_used_code.txt >> librechat_api_deps.txt
-
- # Remove empty lines and sort
- grep -v '^$' librechat_api_deps.txt | sort -u > temp_deps.txt
- mv temp_deps.txt librechat_api_deps.txt
- else
- touch librechat_api_deps.txt
- fi
-
- - name: Extract Workspace Dependencies
- id: extract-workspace-deps
- run: |
- # Function to get dependencies from a workspace package that are used by another package
- get_workspace_package_deps() {
- local package_json=$1
- local output_file=$2
-
- # Get all workspace dependencies (starting with @librechat/)
- if [[ -f "$package_json" ]]; then
- local workspace_deps=$(jq -r '.dependencies // {} | to_entries[] | select(.key | startswith("@librechat/")) | .key' "$package_json" 2>/dev/null || echo "")
-
- # For each workspace dependency, get its dependencies
- for dep in $workspace_deps; do
- # Convert @librechat/api to packages/api
- local workspace_path=$(echo "$dep" | sed 's/@librechat\//packages\//')
- local workspace_package_json="${workspace_path}/package.json"
-
- if [[ -f "$workspace_package_json" ]]; then
- # Extract all dependencies from the workspace package
- jq -r '.dependencies // {} | keys[]' "$workspace_package_json" 2>/dev/null >> "$output_file"
- # Also extract peerDependencies
- jq -r '.peerDependencies // {} | keys[]' "$workspace_package_json" 2>/dev/null >> "$output_file"
- fi
- done
- fi
-
- if [[ -f "$output_file" ]]; then
- sort -u "$output_file" -o "$output_file"
- else
- touch "$output_file"
- fi
- }
-
- # Get workspace dependencies for each package
- get_workspace_package_deps "package.json" root_workspace_deps.txt
- get_workspace_package_deps "client/package.json" client_workspace_deps.txt
- get_workspace_package_deps "api/package.json" api_workspace_deps.txt
-
- - name: Run depcheck for root package.json
- id: check-root
- run: |
- if [[ -f "package.json" ]]; then
- UNUSED=$(depcheck --json | jq -r '.dependencies | join("\n")' || echo "")
- # Exclude dependencies used in scripts, code, and workspace packages
- UNUSED=$(comm -23 <(echo "$UNUSED" | sort) <(cat root_used_deps.txt root_used_code.txt root_workspace_deps.txt | sort) || echo "")
- echo "ROOT_UNUSED<> $GITHUB_ENV
- echo "$UNUSED" >> $GITHUB_ENV
- echo "EOF" >> $GITHUB_ENV
- fi
-
- - name: Run depcheck for client/package.json
- id: check-client
- run: |
- if [[ -f "client/package.json" ]]; then
- chmod -R 755 client
- cd client
- UNUSED=$(depcheck --json | jq -r '.dependencies | join("\n")' || echo "")
- # Exclude dependencies used in scripts, code, workspace packages, and @librechat/client imports
- UNUSED=$(comm -23 <(echo "$UNUSED" | sort) <(cat ../client_used_deps.txt ../client_used_code.txt ../client_workspace_deps.txt ../packages_client_used_code.txt ../librechat_client_deps.txt 2>/dev/null | sort -u) || echo "")
- # Filter out false positives
- UNUSED=$(echo "$UNUSED" | grep -v "^micromark-extension-llm-math$" || echo "")
- echo "CLIENT_UNUSED<> $GITHUB_ENV
- echo "$UNUSED" >> $GITHUB_ENV
- echo "EOF" >> $GITHUB_ENV
- cd ..
- fi
-
- - name: Run depcheck for api/package.json
- id: check-api
- run: |
- if [[ -f "api/package.json" ]]; then
- chmod -R 755 api
- cd api
- UNUSED=$(depcheck --json | jq -r '.dependencies | join("\n")' || echo "")
- # Exclude dependencies used in scripts, code, workspace packages, and @librechat/api imports
- UNUSED=$(comm -23 <(echo "$UNUSED" | sort) <(cat ../api_used_deps.txt ../api_used_code.txt ../api_workspace_deps.txt ../packages_api_used_code.txt ../librechat_api_deps.txt 2>/dev/null | sort -u) || echo "")
- echo "API_UNUSED<> $GITHUB_ENV
- echo "$UNUSED" >> $GITHUB_ENV
- echo "EOF" >> $GITHUB_ENV
- cd ..
- fi
-
- - name: Post comment on PR if unused dependencies are found
- if: env.ROOT_UNUSED != '' || env.CLIENT_UNUSED != '' || env.API_UNUSED != ''
- run: |
- PR_NUMBER=$(jq --raw-output .pull_request.number "$GITHUB_EVENT_PATH")
-
- ROOT_LIST=$(echo "$ROOT_UNUSED" | awk '{print "- `" $0 "`"}')
- CLIENT_LIST=$(echo "$CLIENT_UNUSED" | awk '{print "- `" $0 "`"}')
- API_LIST=$(echo "$API_UNUSED" | awk '{print "- `" $0 "`"}')
-
- COMMENT_BODY=$(cat <.
---
## Workspace Boundaries
- **All new backend code must be TypeScript** in `/packages/api`.
-- Keep `/api` changes to the absolute minimum (thin JS wrappers calling into `/packages/api`).
+- **`/api` holds wiring, not behavior.** When a change would add logic to a CJS file under `/api` —
+ a branch, a helper, a validation step, a new service call — that logic goes in `/packages/api`,
+ and the JS file keeps only what wires it up: requires, route registration, request plumbing, and
+ the call into the TS module. `api/server/services/MCPRequestContext.js` is the shape, at thirteen
+ lines of re-export. "Minimum" describes how much behavior `/api` gains, not how small the diff is:
+ lifting a function into `/packages/api` and calling it is the larger diff and the correct one.
+ Editing an existing CJS file is the common case and the rule applies there, not only to new files.
- Database-specific shared logic goes in `/packages/data-schemas`.
- Frontend/backend shared API logic (endpoints, types, data-service) goes in `/packages/data-provider`.
- Build data-provider from project root: `npm run build:data-provider`.
+- **Database contracts stay inside `/packages/data-schemas`.** A Mongoose type in an exported
+ signature — `FilterQuery`, `Types.ObjectId`, `Document`, `HydratedDocument` — makes the storage
+ engine part of that module's public API, and every consumer then depends on Mongo instead of on
+ the data it needs. Take and return plain typed objects, and express the query behind a
+ data-schemas method. The boundary already leaks across dozens of files in `/packages/api`, so the
+ rule is to stop widening it rather than to rewrite what exists; `/client` carries none of it and
+ must stay that way.
+- **New levers ship configurable.** A limit, timeout, toggle or capability introduced in code earns
+ a field on `configSchema` (`packages/data-provider/src/config.ts`) so an operator can set it in
+ `librechat.yaml`, with a default that reproduces today's behavior. Hard-coded constants and
+ env-only switches need a reason. The schema is also what keeps one definition of the value instead
+ of a constant, a fallback and a doc line that drift apart.
+- **A backend module takes its dependencies, it does not reach for them.** Code in `/packages/api`
+ should receive its config, database methods and clients from the caller the way
+ `createModels(mongoose)` receives the app's connection, rather than importing app singletons or
+ reading global state. A module the caller constructs can be tested without a running app and moved
+ to another workspace without a rewrite; one that calls `getInstance()` can do neither. This is the
+ backend half of "Client State Ownership" — pass it in, do not reach for it. The static singletons
+ under `packages/api/src/mcp` are the shape to stop extending, not a pattern to copy.
+- **Integrations arrive through an interface the caller supplies.** A provider SDK, storage backend,
+ vector store or OAuth server is injected, so a second implementation is a new argument instead of
+ a new branch in shared code, and a test can exercise the real logic against a substitute at the
+ boundary rather than mocking the module that holds it.
+
+---
+
+## Branching and Pull Requests
+
+- **Branch off `dev`, and target `dev` with every pull request.** All work lands on `dev` first.
+- **`main` is the released branch.** It is kept as a fast-forward of `dev` and synced as-is, so it
+ is always an ancestor of `dev` — equal to it right after a sync, behind it otherwise. It never
+ carries a commit that `dev` does not have.
+- **Never open a backport pull request to `main`.** Anything merged to `dev` reaches `main` at the
+ next sync; a second pull request for the same change is redundant.
+- **The repository's default branch is `main`**, so `gh pr create` and the GitHub UI target it
+ unless told otherwise — always pass `--base dev` explicitly.
+- Pull requests opened against `main` are retargeted to `dev` automatically by
+ `.github/workflows/pr-retarget-dev.yml`. The `target: main` label exempts one, as do release-bound
+ upstream branches (`dev`, `release/*`, `hotfix/*`). Backport branches are deliberately not exempt —
+ a backport merged straight to `main` is what breaks the fast-forward invariant.
+- **`Fixes #N` does not close the issue.** GitHub honors closing keywords only when a pull request
+ merges into the default branch (`main`). Merging to `dev` does not close anything, and the later
+ fast-forward of `main` is not a merge event either — close linked issues by hand.
+- **Git worktrees share one stash stack.** `refs/stash` lives in the common `.git` directory, so a
+ bare `git stash pop` in one worktree can take work stashed in another. Prefer a throwaway WIP
+ commit; if you must stash, `git stash push -m ` and `apply` that specific entry.
+- **Write the description for a reader who has not followed the branch.** Say what breaks, what
+ triggers it, and how it behaves after the change, then show the mechanism with whichever one or
+ two views make it reviewable — a focused diff, a call tree, a shallow file tree, or a Mermaid
+ sequence — keeping only the calls, files and state the change actually carries. Describe the code
+ as it stands: do not narrate what earlier commits tried or what a review round changed. Naming the
+ merged pull request that caused the bug is different — that is history the reader needs.
+ `.github/pull_request_template.md` carries the formats and examples.
+
+---
+
+## Review and Completion
+
+### AI review cycles
+
+The reviewer, its trigger phrase and its cadence all change; this subsection is the fluid one, so
+rewrite it when they do. What survives a change of tool: a review counts only for the exact commit
+it ran on, its findings are judged against the code rather than accepted or dismissed wholesale, and
+findings that keep arriving mean the subsystem needs a sweep, not another patch.
+
+- **Inline review threads are the source of truth.** A summary comment, a check name or a
+ notification list omits findings — read the threads on the pull request itself.
+- Audit every finding against the current code. Fix the valid ones; reject the obsolete or wrong
+ ones in a reply that says why.
+- After each round of fixes, run the focused tests and `npx tsc --noEmit` for every workspace you
+ changed, push, read the pull request's remote head (`gh pr view --json headRefOid`), and
+ request the next review naming that exact SHA. **A clean review of an earlier head says nothing
+ about what you just pushed.** Do not wait for CI before asking — review and CI run on their own
+ clocks.
+- Reply on each thread you resolved with the commit that resolved it and the coverage that proves
+ it.
+- **After two actionable rounds** — or sooner, when each fix uncovers an adjacent defect — stop
+ answering threads one at a time and read the subsystem by invariant: identity, ownership,
+ authorization, persistence, retry, replay, abort, cleanup, expiry, rollout. Follow producers,
+ consumers, adapters, alternate write paths, and the final consumer of every limit; check
+ mixed-version behavior in both directions; read the whole base-to-head diff with the callers and
+ tests around it; then add transition or failure-injection coverage at the deepest boundary that
+ owns the behavior.
+- The cycle ends when the exact pushed head draws no major findings, or only repeats ones already
+ resolved. A clean review is one completion signal, not the definition of done.
+
+### Definition of done
+
+- **Ship the observable experience, not the reported path.** Where they apply, cover loading, empty,
+ success, failure, cancellation, retry and restored-session behavior.
+- **A backend capability with no frontend entry point is unfinished**, and so is a control with no
+ validation, persistence, error handling or authorization behind it.
+- Localize every visible string through `useLocalize()`, keep semantic HTML, keyboard behavior and
+ ARIA intact, and compose shared primitives and semantic theme roles before adding local styling
+ (see "Frontend Rules"). Custom styling that proves unavoidable still supports light/dark and
+ reduced motion.
+- Preserve existing defaults, configuration compatibility, stored data, and mixed-version behavior,
+ and expose any new lever through `configSchema` rather than a constant (see "Workspace
+ Boundaries").
+- Make the fix the smallest one consistent with the patterns already in the file, and test the
+ behavior that was missed rather than the line a reviewer pointed at.
+- **Report what you actually ran**: the pushed head, the local checks from "Testing" and
+ "Typechecking", CI state, the review result at that head, and any finding you rejected with the
+ reasoning. Name the checks you could not run instead of implying coverage.
---
@@ -59,6 +170,19 @@ The source code for `@librechat/agents` (major backend dependency, same team) is
- Avoid unnecessary object creation; consider space-time tradeoffs.
- Prevent memory leaks: careful with closures, dispose resources/event listeners, no circular references.
+### Backend Database Performance
+
+- On request startup and first page load paths, watch for serial database reads.
+ Multiple round trips to MongoDB can add significant latency when the database
+ is far from the app server.
+- Prefer passing already-loaded request/user/config data through helper
+ functions instead of re-reading the same user, role, tenant, or principal data.
+- When two reads are independent, start them in parallel and gate the response
+ on the authorization or validation result before returning data.
+- Keep authorization, permission, and tenant checks semantically identical when
+ parallelizing reads. Speculative reads must remain scoped to the authenticated
+ user or tenant and must not write to the response before validation succeeds.
+
### Type Safety
- **Never use `any`**. Explicit types for all parameters, return values, and variables.
@@ -108,12 +232,64 @@ Multi-line imports count total character length across all lines. Consolidate va
- Group related components in feature directories (e.g., `SidePanel/Memories/`).
- Use index files for clean exports.
+### Theming and styling
+
+- **Compose before styling.** Search `@librechat/client` for an existing primitive, semantic
+ variant, or composition before adding feature-local classes or CSS.
+- **Use semantic roles.** Colors and shared appearance values must come from the semantic
+ Tailwind/theme roles. Do not add raw palette utilities, hard-coded hex/RGB/HSL colors, or
+ light/dark-specific values in feature components.
+- **Deepen the system when the need is reusable.** Add a focused variant to a shared primitive or
+ extend the canonical, versioned theme-token registry when multiple screens should share the
+ same design decision. Do not create shallow local wrappers that merely relocate class strings.
+- **Themes are data, not arbitrary CSS.** Theme definitions may select semantic colors and shared
+ appearance roles. They must not contain selectors, arbitrary CSS, application behavior, or
+ alternate feature layouts. Preserve existing environment and stored-theme compatibility when
+ changing the theme engine.
+- **Keep layout and behavior local.** Feature structure, responsive layout, state-driven
+ transitions, and specialized visualization may remain feature-owned. Expose a theme role only
+ when it represents a stable, reusable appearance decision; do not turn every measurement into a
+ global token.
+- **Treat custom CSS as an exception.** Use it only when shared primitives and semantic utilities
+ cannot express the requirement. Keep it narrowly scoped, consume theme variables where
+ applicable, support light/dark and reduced motion, and add a brief code or PR explanation of why
+ the exception is necessary.
+- **Preserve defaults and prove variability.** New theme-aware variants must reproduce the current
+ default appearance unless a redesign is explicitly requested. Test semantic-token use and, when
+ extending theme capabilities, include a deliberately different reference theme to prove that
+ components adapt without feature-specific overrides.
+
### Data Management
- Feature hooks: `client/src/data-provider/[Feature]/queries.ts` → `[Feature]/index.ts` → `client/src/data-provider/index.ts`.
- React Query (`@tanstack/react-query`) for all API interactions; proper query invalidation on mutations.
- QueryKeys and MutationKeys in `packages/data-provider/src/keys.ts`.
+### Client State Ownership
+
+The client is migrating from Recoil to Jotai. **New state is always Jotai**, including inside a file
+that already imports Recoil. For existing state, the unit of conversion is one atom together with
+every file that reads or writes it: the two libraries hold different atom objects, so an atom cannot
+be half converted, and many files already import both — mixed imports are not a signal that either
+choice is fine here. Convert the areas you touch rather than migrating wholesale, and split the work
+by who owns the state:
+
+- **Feature-owned state** — atoms a single feature both writes and reads. Convert these to
+ Jotai as you touch them, with all of their consumers, and keep them inside the feature.
+ `client/src/store/jotai-utils.ts` carries the equivalents for persisted atoms
+ (`createStorageAtom`, `createStorageAtomWithEffect`, `createTabIsolatedAtom`), so a Recoil atom
+ with a localStorage effect has a direct port.
+- **App-global state** — preferences and shell state a feature merely consumes
+ (`maximizeChatSpace`, `showScrollButton`, `enterToSend`, artifact visibility). A feature
+ that could plausibly be extracted must not reach into `~/store` for these; accept them
+ through props or a small context the host supplies. When a consumer sits outside the feature you
+ are changing, leave the atom on Recoil and pass it in — do not convert the shell to make one
+ feature tidy.
+
+Passing app-global state in — rather than reaching for it — is what lets a feature move to
+its own workspace later without a rewrite, and it keeps the Jotai conversion scoped to the
+state a feature actually owns instead of dragging the global migration forward early.
+
### Data-Provider Integration
- Endpoints: `packages/data-provider/src/api-endpoints.ts`
@@ -130,6 +306,16 @@ Multi-line imports count total character length across all lines. Consolidate va
---
+## Backend Rules (`api/**`, `packages/api/**`)
+
+### Auth cache invalidation
+
+When adding or changing code that mutates user documents, invalidate the auth user document cache
+for the affected users. This covers single-user updates as well as bulk role and user mutations.
+Without it, OpenID JWT request burst caching can serve a stale `req.user` until its TTL expires.
+
+---
+
## Development Commands
| Command | Purpose |
@@ -156,6 +342,20 @@ Multi-line imports count total character length across all lines. Consolidate va
- Frontend tests: `__tests__` directories alongside components; use `test/layout-test-utils` for rendering.
- Cover loading, success, and error states for UI/data flows.
+### Typechecking
+
+- **A green build is not a typecheck.** `packages/api`, `packages/client` and `packages/data-schemas`
+ build with `tsdown` alone, which emits without checking types. Only `packages/data-provider` runs
+ `tsc` as part of its build.
+- Run `npx tsc --noEmit` in the workspace you changed before calling it done. `client` also exposes
+ it as `npm run typecheck`.
+- `packages/client/tsconfig.json` excludes `*.spec.ts(x)` and `*.test.ts(x)`, so test files there are
+ never typechecked — a type error in a spec surfaces only when the test runs.
+- `npm run static-checks` runs the Static Checks CI job locally against your staged files;
+ `npm run static-checks -- --against origin/dev` reproduces what CI sees for a pull request, and
+ `npm run static-checks:full` adds the slow gates (TypeScript, config migration tests, unused i18n
+ keys, unused npm packages).
+
### Philosophy
- **Real logic over mocks.** Exercise actual code paths with real dependencies. Mocking is a last resort.
@@ -170,3 +370,7 @@ Multi-line imports count total character length across all lines. Consolidate va
## Formatting
Fix all formatting lint errors (trailing spaces, tabs, newlines, indentation) using auto-fix when available. All TypeScript/ESLint warnings and errors **must** be resolved.
+
+`npm run sort-imports` with no arguments rewrites every file under `api/`, `client/src` and the four
+`packages/*/src` roots — far beyond what you touched. Always pass explicit paths:
+`npm run sort-imports -- path/to/file.ts`.
diff --git a/CONTEXT.md b/CONTEXT.md
new file mode 100644
index 00000000000..62ad3558459
--- /dev/null
+++ b/CONTEXT.md
@@ -0,0 +1,32 @@
+# Domain language
+
+- **Scheduled run admission**: The claimed-occurrence phase that rehydrates the owner, validates current schedule policy and agent reachability, resolves files and MCP readiness, and only then competes for durable generation capacity. It owns cancellation and lease revalidation until a generation slot is reserved; a slow or failed readiness check never occupies generation capacity.
+
+- **Attached code environment**: A principal- or deployment-authorized stateful workspace owned by an outbound `librechat-code` worker on a user-chosen machine or VM. LibreChat selects it and enforces approval policy, Code API authenticates and dispatches to it, and the worker's local sandbox and capability flags remain the final execution ceiling. The environment interface is runtime-neutral: native SRT, WSL2, Docker/NsJail, and future adapters expose the same workspace operations without leaking host paths or runtime configuration into agent tools.
+- **Conversation code-environment decision**: The immutable choice established by a conversation's first accepted submission between validated attached workspaces and continuing without an attached environment. Agent defaults and recent workspace preferences may suggest a draft choice, but only the persisted conversation decision authorizes attached workspace tool registration; later turns, retries, resumes, and alternate ingresses cannot upgrade or replace it. Runs persist a decision only for a conversation that does not store one yet, so no run writes its run-start decision back over a stored one. The one exception to immutability is the owner's explicit move, available where `statefulCodeSessions.conversationMoves.enabled` allows it: when an agent is pointed at a different attached environment after the chat was sealed, the owner may replace the decision's environments with the ones the agents now use, validated against the live worker. A move never changes the workspace of an environment the decision already covers, never upgrades a conversation that continues without an attached environment, and is refused while a generation is running, awaiting approval, or saving its response.
+- **Agent run envelope**: the versioned, JSON-safe request contract created after ingress authentication and protocol validation but before agent, provider, tool, or MCP initialization. It carries only the validated protocol payload and the minimum trusted principal identifiers. The execution host rehydrates all runtime state from those identifiers.
+- **Agent execution context**: runtime-only, transport-free state rehydrated beside an Agent run envelope. It contains the authenticated user, application configuration, normalized request metadata, and resolved conversation facts needed by initialization, but never Express request/response objects or serialized credentials.
+- **Agent execution host**: the protocol-neutral module that owns run admission, disconnect cancellation, provider-start fencing, and terminal settlement. Protocol implementations execute behind its callback interface; HTTP adapters retain validation and final stream rendering.
+- **Agent execution enrollment**: the durable, protocol-neutral lifecycle authority for an admitted Agent run. It is created under the authenticated user and tenant before user-owned initialization, rechecks the shared owner-deletion admission fence after registration, exposes the only provider abort signal, fences exact provider start, terminalizes the run, waits for every trailing usage, artifact, and stored-response write, and acknowledges provider drain last. A transient terminalization failure is reconciled after trailing writes; provider drain is never acknowledged while the exact job remains nonterminal. Delete-all holds the owner fence, drains every owner run before selecting its first persistence snapshot, and repeats both the drain and an idempotent owner-persistence sweep after any recovered fence lapse before releasing admission. Exact-conversation deletion additionally performs an unconditional idempotent cleanup over its immutable deleted-ID set because a fully drained run may leave the active index after racing the first delete; only the explicit empty result is benign, while storage failures remain fatal. Chat Completions, Responses, Channels, and future ingress adapters share this authority without moving LibreChat persistence policy into the Agents SDK.
+- **Agent turn execution plan**: the immutable, request-local decision compiled once after authentication, agent resolution, and tool initialization. It records the trusted turn origin, conversation lineage, pause capability, binding/action context, and the preferred checkpoint, history, or fresh state-loading strategy without executing the model or owning persistence. Checkpoint failure falls back to durable history within the same Agents lifecycle.
+- **Turn delivery routing**: the per-agent, request-local value that decides how each attachment reaches the model on one turn (`provider`, `text`, or `none`). Initialization settles it once, after the provider swap and the Responses API decision, under the endpoint's own name and the media dialect its config declares. Every reader of a turn route consumes that one value rather than deriving it from the agent. A stored route is an upload-time inference that this value resolves again for the turn; a destination the user chose stands.
+- **Effective agent selection**: the resolved endpoint and agent identity after an enforced model spec is applied. Authorization and agent loading must consume this same identity before the Agent run envelope is initialized.
+- **MCP runtime request body**: trusted chat identifiers supplied only while an MCP server handles an agent request. It enables request-scoped header placeholders without retaining user-specific request data on a shared server definition.
+- **MCP direct OpenID bearer**: an operator-trusted remote MCP credential mode that resolves the logged-in user's live OpenID access token into an Authorization header. It may replace one rejected connection after a forced session refresh, but it never replays the rejected tool invocation automatically.
+- **MCP OAuth prompt projection**: the client-safe, generation-scoped view of authorization prompts that remain actionable in a resumable Agent stream. It is derived from durable OAuth step state, carries no tokens or flow internals, and lets reconnecting clients render current state without interpreting the replay log.
+- **Caller Capability Projection**: the versioned, SDK-owned classification of currently active tools by direct and programmatic callers. Event-driven execution transports this projection as data; LibreChat intersects it with its trusted registry and never recomputes deferred-tool discovery policy or treats the projection as authorization.
+- **Subagent thread**: a durable, view-only child conversation owned by one parent conversation and subagent identity. A parent agent may continue it by stable `threadId`; each continuation uses a fresh execution lease restored from the canonical child transcript. It is not an ordinary human-writable chat.
+- **Live subagent task owner**: the one API process holding a detached child execution, its abort controller, and its bounded control queue. Redis may route trusted poll/control envelopes to that owner, but it does not migrate or persist the executor; Mongo persists only the logical child thread and its continuation fence.
+- **Subagent completion wakeup**: a durable internal `continue` trigger pre-registered before detached child execution so a process crash cannot lose the wakeup. Delivery defers until the child's terminal transcript is persisted, targets the initiating agent and exact parent response branch, carries task metadata rather than child output, waits for the parent generation to settle, and starts the parent turn that collects the result through the existing task store.
+- **Agent continuation preparation**: the single source-dispatch seam that resolves a durable `continue` delivery immediately before admission. Bound Event Actor work selects its binding adapter; internal completion work selects an adapter by stable source identity. Preparation may resolve authoritative input and branch state or settle already-consumed work, but it does not own source result truth, delivery ordering, or generation execution.
+- **Warm terminal steer continuation**: a queued steer accepted before a generation's terminal boundary may continue the same SDK `Run` without creating a replacement generation. After parallel Stop hooks fold, the serialized StopFinalize phase tells the job store whether another continuation is already planned or terminal progress is forbidden. The store atomically chooses among claiming the current protocol-v2 FIFO batch, keeping empty admission open for an already-planned segment, and sealing admission so every racing or later message becomes an ordinary follow-up. Claimed steer receipts remain the crash-recovery authority; protocol-v1 generations always seal because they cannot recover an ambiguous terminal claim. Tool-batch, preemption, and terminal boundaries share one durable apply-and-inject adapter, while the SDK owns the bounded Stop-continuation loop.
+- **Agent queued turn**: a server-owned ordinary follow-up accepted while an Agent generation is active. Its Mongo row is the sole FIFO, payload, and lifecycle authority; the trigger delivery is only a replayable wakeup and Agent execution enrollment is only an execution adapter. The lifecycle reserves the deterministic delivery identity before publication, admits only after the captured branch has a clean durable predecessor outcome, and commits the source-owned generation receipt only after provider invocation has been enrolled. An accepted or deduplicated loopback response requires that exact receipt; a process death before it remains explicit admission-indeterminate evidence instead of silently consuming the text. Admission reconciliation is leased, backoff-scheduled work: exact generation evidence may repair legacy ambiguity, while current source-owned ambiguity never infers provider outcome from transient job state. Aborted or failed predecessors and exhausted admission attempts remain visible terminal rows. Transport-ambiguous enqueue outcomes stay non-resendable until exact request-identity reconciliation succeeds; an elapsed client observation window may hide the warning locally but never converts ambiguity into permission to submit again. Conversation deletion cancels the rows, retires their deliveries, and removes their payloads before deleting the conversation wave.
+- **Subagent activity stream**: an observational, task-scoped live projection of bounded child progress for the currently open private panel. It may cross API replicas through Redis, never carries hidden reasoning text, and never controls or settles execution. The durable child thread remains canonical and its existing polling view is the fallback for missed or unavailable live events.
+- **Agent event handling outcome**: the durable, generation-fenced result of a previously accepted event delivery. `started` proves generation admission; terminal states distinguish verified tool application, clean completion without action, failure, and cancellation. Transport success remains separate so an accepted event cannot masquerade as completed work.
+- **Agent event expected action**: an optional source-declared tool name and bounded argument subset evaluated against host-observed completed run steps. It is evidence policy, not authorization and not a model-authored success claim.
+- **Event actor head**: the private, durable pointer on an event-bound child conversation to its latest committed LangGraph checkpoint, plus one previous checkpoint for safe cleanup. Only a qualifying applied action advances it through compare-and-swap; failed, cancelled, or no-action invocations leave it unchanged. A legacy-path event marks the head for a cold rebuild from durable message history before fork mode can resume. Every applied commit conflict, unverified commit, or post-commit persistence failure is retained in a private reconciliation journal that blocks later actor turns instead of continuing from stale state; an exact marker can be cleared only after its checkpoint is verified authoritative, its history is repaired, or its external action is explicitly compensated.
+- **Event actor invocation fork**: a delivery-owned checkpoint namespace copied from the event actor head. Every bound Event Actor enters one turn module; `fresh`, `history`, and `checkpoint` are internal state-loading adapters selected automatically from actor state and immutable request capability, never from operator configuration. The history adapter owns its durable turn fence and token ordering, while immutable protocol-v1 tokens remain read-compatible until their jobs drain. A warm invocation receives only the new trusted event, then commits its terminal checkpoint when the expected action is observed or deletes the fork otherwise. When the invocation pauses for approval or Ask User, the SDK emits signed, versioned suspension evidence. The child Conversation is the canonical one-shot suspension authority; the generation job carries only a versioned projection for UI, rolling-deploy routing, and the existing resume endpoint. Current Event Actor hosts select generation protocol v2 automatically; a trusted pre-capability producer remains on history only for the mixed-version drain. A resume shares one identity between its Conversation claim and provider-owner CAS, clears the predecessor projection, and publishes a successor only after a re-pause is canonical. A pending interrupt takes precedence over expected-action evidence from the same segment; if that segment already applied the expected action, publishing its successor pause cold-marks the prior head until a later applied commit replaces it. An ambiguous projection write is accepted only after reading back the exact generation, action, and suspension. The provider-start CAS is written only after client reconstruction and immediately before the continuation gate opens, then retains its exact execution identity after drain, so terminal recovery can compensate a projected claim only when that identity proves execution never began. Durable approval projection is exposed before the persistence barrier opens, preventing a resolved action from being announced afterward. Terminal no-action retirement cancels or settles the exact suspension and releases its delivery-side action admission before public settlement; if retention already removed the child Conversation, the delivery remains authoritative for its exact admission identity. Resume, re-pause, cancellation, and expiry claim or replace that exact suspension before touching its job projection, so later mailbox deliveries stay blocked until terminal history and handling evidence settle.
+- **Event actor receipt**: the private, terminal proof stored on the authoritative `AgentTriggerDelivery` row for one bound actor invocation. Its unique delivery identity, terminal resolution, exact checkpoint, and bounded action identity provide replay and recovery for the retention window without storing prompts, events, tool arguments, tool output, or conversation history. It does not own the actor checkpoint; the conversation keeps only the actor head and any active unresolved reconciliation until this receipt is durable.
+- **Agent event actor mailbox**: the automatic durable delivery-ordering lane for one authenticated source binding. It keeps later deliveries queued after transport admission until the current child turn records an authoritative terminal handling outcome. It serializes existing coalesced batches and individual events without becoming a second execution controller or actor checkpoint store.
+- **Agent trigger capability shield**: the durable mixed-version representation for internal trigger work that only a capability-aware worker may execute. Mongo uses an old-publishable `staging` shell; a queued `leased` shell without an owner or deadline, which old workers cannot claim but can use for bounded lane rechecks; a private lease only during execution; and a legacy-terminal `capability_dead` shell once dead. Private capability fields own current claiming, retry, and dead-letter truth. Redis uses a versioned fail-closed terminal status and recovery index that old replacement scripts and sweepers cannot consume. The shield is an implementation detail at the storage seam, never a deployment switch or user-configured product mode.
+- **Theme definition**: a versioned, data-only description of LibreChat semantic colors and shared appearance roles, optionally specialized by light or dark mode. The theme module validates and resolves partial definitions against bundled defaults before adapters apply them. A theme definition does not contain arbitrary CSS, application behavior, or alternate feature layouts.
diff --git a/Dockerfile b/Dockerfile
index 9e5d41b5d6c..9dc166e9439 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,4 +1,4 @@
-# v0.8.7
+# v0.8.8-rc4
# Base node image
FROM node:24.16.0-alpine AS node
@@ -9,6 +9,8 @@ RUN apk add --no-cache python3 py3-pip uv
# Set environment variable to use jemalloc
ENV LD_PRELOAD=/usr/lib/libjemalloc.so.2
+# Disable dependency installation analytics before any npm lifecycle scripts run.
+ENV SCARF_ANALYTICS=false
# Add `uv` for extended MCP support
COPY --from=ghcr.io/astral-sh/uv:0.9.5-python3.12-alpine /usr/local/bin/uv /usr/local/bin/uvx /bin/
@@ -35,7 +37,8 @@ RUN \
# Allow mounting of these files, which have no default
touch .env ; \
# Create directories for the volumes to inherit the correct permissions
- mkdir -p /app/client/public/images /app/logs /app/uploads /app/skill ; \
+ mkdir -p /app/client/public/images /app/logs /app/uploads /app/skill /app/data ; \
+ chmod 1777 /app/data ; \
npm config set fetch-retry-maxtimeout 600000 ; \
npm config set fetch-retries 5 ; \
npm config set fetch-retry-mintimeout 15000 ; \
diff --git a/Dockerfile.multi b/Dockerfile.multi
index ce429c02bb1..976fab71dcc 100644
--- a/Dockerfile.multi
+++ b/Dockerfile.multi
@@ -1,5 +1,5 @@
# Dockerfile.multi
-# v0.8.7
+# v0.8.8-rc4
# Set configurable max-old-space-size with default
ARG NODE_MAX_OLD_SPACE_SIZE=6144
@@ -17,6 +17,8 @@ RUN apk upgrade --no-cache
RUN apk add --no-cache jemalloc
# Set environment variable to use jemalloc
ENV LD_PRELOAD=/usr/lib/libjemalloc.so.2
+# Disable dependency installation analytics before any npm lifecycle scripts run.
+ENV SCARF_ANALYTICS=false
WORKDIR /app
RUN apk --no-cache add curl
@@ -83,6 +85,7 @@ COPY client ./
COPY --from=data-provider-build /app/packages/data-provider/dist /app/packages/data-provider/dist
COPY --from=client-package-build /app/packages/client/dist /app/packages/client/dist
COPY --from=client-package-build /app/packages/client/src /app/packages/client/src
+COPY --from=client-package-build /app/packages/client/tailwind.preset.cjs /app/packages/client/tailwind.preset.cjs
ARG NODE_MAX_OLD_SPACE_SIZE
ENV NODE_OPTIONS="--max-old-space-size=${NODE_MAX_OLD_SPACE_SIZE}"
RUN npm run build
@@ -91,6 +94,7 @@ RUN npm run build
FROM base-min AS api-build
ARG NPM_CI_TIMEOUT_SECONDS=1500
ARG NPM_CI_ATTEMPTS=2
+RUN mkdir -p /app/data && chmod 1777 /app/data
# Add `uv` for extended MCP support
COPY --from=ghcr.io/astral-sh/uv:0.6.13 /uv /uvx /bin/
RUN uv --version
diff --git a/README.md b/README.md
index 54bf286e853..9aabda45e46 100644
--- a/README.md
+++ b/README.md
@@ -51,6 +51,17 @@
+## 🚀 What's New in v0.8.8-rc4
+
+- **Public Agents API docs:** Serve an OpenAPI specification and interactive Swagger UI for inference, events, Agent management, and Skill management.
+- **Attached workspaces (highly experimental):** Isolate workspaces by conversation, load repository instructions, and use bounded queue waits and command timeouts.
+- **Trace Viewer:** Inspect model conversations as ordered steps with roles, Agent identity, tool rounds, previews, and cost.
+- **Skills:** Author or import a Skill and invoke it in the same Agent run, with safer rollback for failed imports.
+- **Agent activity:** Render system events as distinct turns and hold live activity to one stable row.
+- **MCP reliability:** Send per-request headers without hiding tools, coordinate OAuth refresh across replicas, and preserve credentials through provider outages.
+- **Performance:** Stream Markdown incrementally, virtualize model search, and reduce completed Agent message rendering work.
+
+Read the [full v0.8.8-rc4 changelog](https://www.librechat.ai/changelog/v0.8.8-rc4).
# ✨ Features
@@ -60,7 +71,7 @@
- Anthropic (Claude), AWS Bedrock, OpenAI, Azure OpenAI, Google, Vertex AI, OpenAI Responses API (incl. Azure)
- [Custom Endpoints](https://www.librechat.ai/docs/quick_start/custom_endpoints): Use any OpenAI-compatible API with LibreChat, no proxy required
- Compatible with [Local & Remote AI Providers](https://www.librechat.ai/docs/configuration/librechat_yaml/ai_endpoints):
- - Ollama, groq, Cohere, Mistral AI, Apple MLX, koboldcpp, together.ai,
+ - Ollama, [AMD Lemonade](https://lemonade-server.ai/), groq, Cohere, Mistral AI, Apple MLX, koboldcpp, together.ai,
- OpenRouter, Helicone, Perplexity, ShuttleAI, Deepseek, Qwen, and more
- 🔧 **[Code Interpreter API](https://www.librechat.ai/docs/features/code_interpreter)**:
@@ -76,7 +87,10 @@
- Collaborative Sharing: Share agents with specific users and groups
- Flexible & Extensible: Use MCP Servers, tools, file search, code execution, and more
- [Skills](https://www.librechat.ai/docs/features/skills): Create reusable `SKILL.md` instruction bundles for manual, automatic, or always-on agent workflows
+ - [Agent Plugins](https://www.librechat.ai/docs/features/agent_plugins): Experimentally bundle deployment Skills and MCP servers into startup-loaded packages
- [Subagents](https://www.librechat.ai/docs/features/subagents): Delegate focused work to isolated child agent runs with their own context windows
+ - Agent Management API: Automate Agent, file, and Skill management with deployment-bound OIDC clients
+ - Attached Code Workspaces: Let Agents inspect, search, edit, and run commands in managed or personal workspaces (highly experimental)
- Compatible with Custom Endpoints, OpenAI, Azure, Anthropic, AWS Bedrock, Google, Vertex AI, Responses API, and more
- [Model Context Protocol (MCP) Support](https://modelcontextprotocol.io/clients#librechat) for Tools
@@ -87,7 +101,8 @@
- **[Learn More →](https://www.librechat.ai/docs/features/web_search)**
- 🪄 **Generative UI with Code Artifacts**:
- - [Code Artifacts](https://youtu.be/GfTj7O4gmd0?si=WJbdnemZpJzBrJo3) allow creation of React, HTML, and Mermaid diagrams directly in chat
+ - [Code Artifacts](https://youtu.be/GfTj7O4gmd0?si=WJbdnemZpJzBrJo3) create React, HTML, and Mermaid content directly in chat
+ - Open previews fullscreen and export Mermaid diagrams as SVG or PNG
- 🎨 **Image Generation & Editing**
- Text-to-image and image-to-image with [GPT-Image-1](https://www.librechat.ai/docs/features/image_gen#1--openai-image-tools-recommended)
@@ -100,10 +115,12 @@
- Edit, Resubmit, and Continue Messages with Conversation branching
- Create and share prompts with specific users and groups
- [Fork Messages & Conversations](https://www.librechat.ai/docs/features/fork) for Advanced Context control
+ - Compact long conversations on demand while preserving recent context
- 💬 **Multimodal & File Interactions**:
- Upload and analyze images with Claude 3, GPT-4.5, GPT-4o, o1, Llama-Vision, and Gemini 📸
- Chat with Files using Custom Endpoints, OpenAI, Azure, Anthropic, AWS Bedrock, & Google 🗃️
+ - Copy messages as formatted rich text for documents, email, and collaboration apps
- 🌎 **Multilingual UI**:
- English, 中文 (简体), 中文 (繁體), العربية, Deutsch, Español, Français, Italiano
@@ -116,6 +133,10 @@
- 🎨 **Customizable Interface**:
- Customizable Dropdown & Interface that adapts to both power users and newcomers
+ - Light, dark, system, and high-contrast appearance modes
+
+- 📈 **Observability**:
+ - Export traces and logs with OpenTelemetry and connect Langfuse for Agent and model insights
- 🌊 **[Resumable Streams](https://www.librechat.ai/docs/features/resumable_streams)**:
- Never lose a response: AI responses automatically reconnect and resume if your connection drops
@@ -190,10 +211,15 @@ Keep up with the latest updates by visiting the releases page and notes:
## ⭐ Star History