* feat(cloudfront): add requireSignedAccess to enforce strict signed access
Introduces cloudfront.requireSignedAccess (default false). When enabled,
initializeCloudFront requires both CLOUDFRONT_KEY_PAIR_ID and
CLOUDFRONT_PRIVATE_KEY, rejects the unimplemented imageSigning="url"
mode, and initializeFileStorage throws to block startup on any
CloudFront init failure. OSS path is unchanged: missing keys still
log-and-continue when requireSignedAccess is false.
Adds low-noise startup and cookie-issuance logs without leaking signed
URLs, policies, signatures, private keys, or cookie values.
* fix(cloudfront): reject requireSignedAccess unless imageSigning is "cookies"
Previously requireSignedAccess=true was accepted with imageSigning="none"
or "url", but setCloudFrontCookies() only runs for "cookies" — leaving
strict mode toothless: CloudFront stayed publicly accessible, or image
delivery broke on a distribution that actually requires signed access.
Adds a Zod refinement plus a runtime guard in initializeCloudFront so
the only currently-functional strict configuration is imageSigning
"cookies". Signed URL mode can lift this restriction once implemented.
* fix(cloudfront): resolve strict access type checks
* chore(cloudfront): reduce strict startup log noise
---------
Co-authored-by: Danny Avila <danny@librechat.ai>
* 📦 chore: Update @librechat/agents to version 3.1.85 in package-lock.json and package.json files
* 📦 chore: Update mermaid to version 11.15.0 in package.json and package-lock.json
`config/set-balance.js` calls `getBalanceConfig()` without an argument,
so it cannot read the parsed `balance` section from librechat.yaml.
As a result the script always errors with "Balance is not enabled.
Use librechat.yaml to enable it" — even when balance is enabled.
`config/add-balance.js` already follows the correct pattern (fetch
`appConfig` via `getAppConfig()` and pass it into `getBalanceConfig`),
introduced in #9234. This applies the same pattern to set-balance.js.
Co-authored-by: Odrec <Odrec@users.noreply.github.com>
* refactor: Streamline batch upload error handling in `uploadCodeEnvFile`
* refactor: Enhance session info error logging in `getSessionInfo`
* refactor: Update error logging to use `logAxiosError` in various agent handlers and skill file processing functions
* refactor: Consolidate missing resource checks in `createToolExecuteHandler` for better clarity
* fix: skip OpenAI model fetch if using user-provided key
There was a check present (via `opts.userProvidedOpenAI`), but it wasn't
working because `loadDefaultModels()` doesn't provide that parameter. As a
result, the server would repeatedly try to request models from OpenAI and get
401 errors in return.
We now check the env var directly, which matches how
`getAnthropicModels()` works.
* chore: remove unused OpenAI model option
* fix: honor explicit OpenAI key for model fetch
* fix: fall back from empty OpenAI option key
---------
Co-authored-by: Dan Lew <daniel@mightyacorn.com>
* 🛟 fix: Allow empty modelSpecs.list to unstick admin-panel saves
The unconditional `.min(1)` on `specsConfigSchema.list` rejected an empty
list even when `enforce: false`, leaving admin panels (which save fields
path-granularly) with no atomic way to clear the list once it had been
populated. Once an admin reached `list: [entry]` and deleted the only
entry, every subsequent save failed schema validation and the section
became stuck.
Relax the schema to `.default([])`. The `.min(1)` was added in #5218 as
part of bundled cleanup, not as a deliberate rule. Every consumer of
`modelSpecs.list` already handles the empty/undefined case (`?.list`,
`?? []`, length-checked), and `processModelSpecs` short-circuits to
`undefined` when the list is empty so the runtime treats it as "no
specs configured." No call site is load-bearing on length >= 1.
Tighten the `buildEndpointOption.js` enforce guard from
`?.list && ?.enforce` to `?.list?.length && ?.enforce`. Empty arrays
are truthy in JS, so the existing guard would have entered the enforce
branch on `list: []` and returned "No model spec selected" or "Invalid
model spec" had `processModelSpecs` ever been bypassed.
Add a runtime warn in `processModelSpecs` when `enforce: true` is
configured alongside an empty list, so operators see the resulting
"enforcement disabled" state in logs rather than silently getting a
permissive runtime.
Add coverage for the empty-list parse path in `config-schemas.spec.ts`
and for the empty-list-with-enforce branch in `buildEndpointOption.spec.js`.
* chore: update import order in config-schemas.spec.ts
`resolveSummarizationProvider` calls `getProviderConfig` to translate the
agent's resolved provider into an initializer + client overrides. Three
real-world inputs were unsupported and fell through to "raw provider"
fallback (silently dropping client overrides):
1. **`vertexai`** — not in `providerConfigMap` at all. Vertex shares
initialization with Google (auth-only runtime distinction). Map
`Providers.VERTEXAI` to `initializeGoogle`.
2. **`openrouter` (and other known custom providers) with CamelCase
custom endpoint names** — agent main flow looks up endpoints
case-sensitively (case-preserving keys are how
`loadCustomEndpointsConfig` lets users have distinct entries
differing only in case). Once it succeeds, `agent.provider` is
normalized to lowercase. Downstream resolvers re-enter
`getProviderConfig` with the lowercased value and miss configs
whose `name` is camel-cased. Add a case-insensitive fallback,
narrowly scoped to known custom providers and only after the
case-sensitive direct lookup fails.
3. **Ambiguous case-insensitive matches (codex review feedback)** —
if the user has e.g. `OpenRouter` and `OPENROUTER` (neither
lowercase) and the agent runtime passes `openrouter`, the
case-insensitive fallback could silently route to whichever entry
appears first in the array (potentially different baseURL/apiKey).
Detect multiple case-insensitive matches and throw a clear error
with both names rather than picking arbitrarily.
## Tests
`providers.spec.ts` — new file, 7 tests:
- vertexai → Google initializer
- google (API key) → Google initializer (regression guard)
- case-insensitive fallback when only CamelCase entry exists
- exact-case match preserved when both casings exist (case identity)
- exact-case lowercase entry still resolves
- throws on ambiguous case-insensitive matches when no exact-case exists
- still throws when no match at all
When a tool round-trip is interrupted between the tool result and the
model's text reply (user aborted, network drop, pod restart, ...) and
LibreChat persists the partial assistant message, the next conversation
turn reconstructs an `AIMessage` from `formatAgentMessages` that has
`tool_calls` populated but no `additional_kwargs.signatures`. Vertex
Gemini 3 rejects the resumed request with 400 because the most recent
historical functionCall has no `thought_signature`.
## Storage shape
Capture as `Record<tool_call_id, signature>` rather than a flat array.
This addresses the codex P1 review:
> When an assistant turn contains multiple sequential tool-call batches,
> this restoration path writes all persisted thoughtSignatures onto only
> the last tool-bearing AIMessage. Vertex/Gemini validates signatures
> for each step in the current tool-calling turn, so earlier
> functionCall steps reconstructed without their signature can still
> fail with 400.
A single agent run can fire multiple `chat_model_end` events when the
loop cycles the LLM with intervening tool results — each cycle owns a
distinct `tool_call_id`. Per-id storage maps each signature back onto
the right reconstructed `AIMessage`, not just the last one.
## Mapping
`additional_kwargs.signatures` is a flat array indexed by *response part*
(text + functionCall interleaved). `tool_calls` is just the function
calls in their original order. Non-empty signatures correspond 1:1 with
tool_calls in order — see `partsToSignatures` in
`@langchain/google-common`. Single-pass walk maps `signatures[i]` (when
non-empty) onto the i-th `tool_call.id`.
## Pipeline
| Stage | File | Change |
|---|---|---|
| Capture | callbacks.js | `ModelEndHandler` accepts `Record<string,string>` map; walks signatures + tool_calls in tandem to record per-id. Gated on the map being provided — non-Vertex flows are no-op (and also no-op even when provided, since they don't emit signatures). |
| Plumbing | initialize.js | Allocate `collectedThoughtSignatures = {}`, share with handler + client. Always allocated; the JSDoc explicitly documents that it stays empty for non-Vertex providers. |
| Surface | client.js | `sendCompletion` returns `metadata.thoughtSignatures` when the map has entries; falls through unchanged when empty. |
| Persist | (existing BaseClient.handleRespCompletion) | Writes `metadata` from `sendCompletion` onto `responseMessage.metadata`. Mongoose `Mixed` — no migration. |
| Restore | formatMessages.js | Track every tool-bearing AIMessage produced from a TMessage. For each, build a position-aligned `additional_kwargs.signatures` array (empty placeholders for tool_calls without a stored sig). Agents' `fixThoughtSignatures` dispatches non-empty entries to functionCall parts in order. |
## Live verification
- **Single-step:** real Vertex `gemini-3.1-flash-lite-preview` resume-after-tool case. With fix ✅ / without ❌ 400.
- **Multi-step (codex case):** real two-step agent loop (list /tmp → echo done). Each step's signature attaches to its own reconstructed AIMessage. With fix ✅ / without ❌ 400.
- **Cross-provider:** Anthropic Claude haiku-4.5 + OpenAI gpt-5-mini accept the persisted/restored shape unchanged.
## Tests
`modelEndHandler.spec.js` (new) — 6 tests:
- maps non-empty signatures onto tool_call_ids in order
- accumulates per-id across multiple `model_end` events (multi-step)
- no-op when `collectedThoughtSignatures` is null
- no-op when `signatures` field missing (non-Vertex)
- no-op when `tool_calls` missing
- preserves existing `collectedUsage` array contract
`formatAgentMessages.spec.js` — 6 new tests:
- restores onto the AIMessage that owns the tool_call
- per-step attachment for multi-step turns (codex review case)
- preserves tool_call ordering when signatures are partial
- no-op when metadata.thoughtSignatures absent
- no-op when assistant has no tool_calls
- no-op when stored ids don't match any current tool_call
37 passing across 3 suites; 15 existing formatAgentMessages tests unchanged.
## Compatibility
- Backward-compatible — restore gated on `metadata.thoughtSignatures` being a populated object; capture gated on the map being provided.
- No schema migration — uses `Message.metadata: Mixed` already in place.
- Cross-provider safe — non-Vertex providers tolerate the field (verified live against Anthropic + OpenAI converters).
- Pairs with [agents#159](https://github.com/danny-avila/agents/pull/159) for full coverage on histories that mix plain-text and toolcall AIMessages.
Bitnami moved versioned image tags from docker.io/bitnami to
docker.io/bitnamilegacy on 2025-08-28, which causes ImagePullBackOff
on a fresh Helm install of the LibreChat chart. Override the MongoDB
subchart image repository to bitnamilegacy/mongodb so installs
succeed out of the box.
Fixes#13031
* feat(admin-panel): add /api/admin/oauth/refresh endpoint for cross-origin BFF refresh
The cookie-based /api/auth/refresh controller can't be reached cross-origin
from a separately-hosted admin panel because the refresh-token cookie isn't
sent on cross-origin fetches. Add a dedicated POST /api/admin/oauth/refresh
endpoint that accepts the refresh token in the request body, exchanges it
at the IdP via openid-client refreshTokenGrant, and returns the same
response shape as /api/admin/oauth/exchange.
Implementation lives in packages/api/src/auth/refresh.ts as the
applyAdminRefresh helper. It validates the refreshed tokenset, looks up the
admin user by openidId (with optional user_id disambiguation when multiple
user docs share an openidId), mints the bearer via an injected mintToken
hook, and runs an optional onRefreshSuccess hook for downstream forks that
need to update server-side session state.
The default mintToken passed by the OSS route signs an HS256 LibreChat JWT
via generateToken so admin panel callers continue to use the existing local
JWT strategy. Forks that prefer to hand back an IdP-signed token (e.g. for
deployments where the JWT auth gate is JWKS-only) override mintToken
without changing the helper or the route.
Also threads expiresAt through AdminExchangeData and AdminExchangeResponse
so admin panel clients can drive proactive refresh before the bearer
expires. Defaults the OSS exchange flow to Date.now() + sessionExpiry.
* fix(admin-panel): address review feedback on /api/admin/oauth/refresh
mintToken now returns {token, expiresAt} so the minter is authoritative
for the bearer's lifetime instead of deriving it from the IdP `exp` claim.
The refresh response would otherwise lie to the admin panel and trigger
premature or late refresh cycles.
The helper now falls back to the inbound refresh_token when the IdP omits
one on rotation (Auth0 with rotation off, Microsoft personal accounts).
Without this the admin panel loses its refresh capability after one cycle.
Other hardening:
resolveAdminUser validates user_id with Types.ObjectId.isValid before
hitting Mongoose, avoiding a CastError that would surface as a generic
500 with no useful information for the client.
If user_id resolves to a user whose openidId does not match the refreshed
sub, throw USER_ID_MISMATCH (401) instead of silently swapping in a
different user matching the sub.
Wrap tokenset.claims() in readClaims so an IdP that returns a tokenset
without a usable id_token gets mapped to CLAIMS_INCOMPLETE (502) rather
than bubbling a raw exception.
findUsers now uses the same SAFE_USER_PROJECTION as getUserById so the
fallback path no longer pulls password/totpSecret/backupCodes into memory.
Removed dead fields (email on AdminRefreshClaims, id_token on
RefreshTokenset) and fixed import ordering per AGENTS.md.
Adds packages/api/src/auth/refresh.spec.ts: 18 tests covering the happy
path, userId disambiguation (match, invalid ObjectId, null, mismatch),
all error branches (IDP_INCOMPLETE, CLAIMS_INCOMPLETE for both throw and
missing sub, USER_NOT_FOUND, mintToken/onRefreshSuccess propagation), and
refresh-token preservation under rotation/no-rotation.
* chore(admin-panel): polish per re-review on /api/admin/oauth/refresh
readClaims now logs the original error name/message at warn before mapping
to CLAIMS_INCOMPLETE so a programming bug doesn't get silently rebadged
as an IdP problem in production logs.
The route handler's JSDoc now enumerates every error response (status +
error_code) so admin-panel implementors can plan for each branch without
reading the source.
Tightens the helper's surface: removed the now-dead `exp` field from
`AdminRefreshClaims` (only `sub` is read since the v2 mintToken refactor),
and tightened `AdminRefreshDeps.findUsers`'s projection parameter from
`string | null` to `string` so the contract matches actual usage.
Test polish: the userId-resolves-to-null fallthrough test now asserts the
exact `findUsers` and `getUserById` call arguments so a regression in the
fallthrough query shape is caught. The "skips onRefreshSuccess" test now
asserts a populated response shape rather than just `toBeDefined`.
Declined per prior triage and re-confirmed: a role guard inside
`applyAdminRefresh` (downstream `/api/admin/*` already enforces
ACCESS_ADMIN via requireCapability) and moving the IdP grant call out of
the JS route into TypeScript (matches existing oauth.js / openidStrategy
pattern; package-boundary refactor belongs in a separate PR).
* fix(admin-panel): reject /api/admin/oauth/refresh tokensets from foreign issuers
When the route handler can resolve the configured OpenID issuer, it now
threads it into applyAdminRefresh as expectedIssuer. The helper compares
that against the tokenset claims iss (after normalizeOpenIdIssuer on
both sides to absorb trailing-slash differences) and throws
ISSUER_MISMATCH (401) on mismatch.
The check is skipped when either side is unset so behavior is unchanged
for IdPs that don't return iss on a refresh-grant id_token, and for
older deployments where the OpenID config doesn't expose serverMetadata.
This is a defense-in-depth measure for the refresh path only. The
deeper OIDC posture fix (binding IUser lookup to (sub, iss) as a pair)
is pre-existing debt across openidStrategy.js and the regular exchange
flow as well, and belongs in a separate PR with the schema change and
backfill migration.
* fix(admin-panel): bind refresh user lookup to (sub, iss) and handle getOpenIdConfig throw
Two fixes raised on the PR thread that I previously misdescribed:
The user lookup in resolveAdminUser was keyed on openidId alone, so a
tokenset from a different issuer that happened to share the same sub
could resolve to a local user from a different IdP. Now exports
getIssuerBoundConditions and isUserIssuerAllowed from openid.ts (the
helpers findOpenIDUser already uses) and reuses them. The findUsers
filter becomes ($or of getIssuerBoundConditions for openidId) when an
expectedIssuer is provided, with the same legacy backward-compat
clause for users whose openidIssuer field was never populated. The
direct user_id path now also checks isUserIssuerAllowed and throws
USER_ID_MISMATCH if the stored openidIssuer disagrees with the
configured issuer.
The route's getOpenIdConfig() call was previously documented as
returning null when uninitialized; the actual implementation throws.
That made the if (!openIdConfig) guard unreachable, and an unconfigured
server would surface as 500 INTERNAL_ERROR rather than 503
OPENID_NOT_CONFIGURED. Wraps the call in try/catch so the documented
503 response is what callers actually receive.
Adds 4 tests covering the new lookup binding behavior.
* fix(admin-panel): re-check ACCESS_ADMIN on /api/admin/oauth/refresh
The IdP refresh token can outlive a capability/role change, so the
initial requireAdminAccess on the OAuth callback isn't sufficient.
Inject canAccessAdmin via the existing capability model
(hasCapability with SystemCapabilities.ACCESS_ADMIN, matching
requireAdminAccess so custom roles and user grants are honored)
and gate token minting on it. Capability backend errors are
warn-and-denied to keep the bearer-mint path fail-closed.
* fix(admin-panel): scope /api/admin/oauth/refresh to the request tenant
The same (openidId, openidIssuer) pair is allowed across tenants by
the user schema's unique index. The refresh helper was wrapping both
the direct getUserById and the fallback findUsers in runAsSystem,
bypassing tenant isolation, so an IdP identity that exists in two
tenants could resolve to the wrong tenant's user and mint a JWT
bound to that tenant.
Drop the runAsSystem wrappers, add a trusted tenantId option to
applyAdminRefresh, AND it into the fallback findUsers filter, and
assert it against the direct getUserById result. Mount
preAuthTenantMiddleware on the refresh route so the deployment's
X-Tenant-Id header drives the trusted tenant via ALS. Single-tenant
deploys (no header) keep the existing openidId-only behaviour.
Adds TENANT_MISMATCH (401) and a regression covering duplicate
(sub, iss) across tenants plus the direct-userId tenant assertion.
* fix(admin-panel): gate /api/admin/oauth/refresh on OPENID_REUSE_TOKENS
The OSS refreshController only refreshes OpenID tokensets when
OPENID_REUSE_TOKENS is enabled. The body-based admin variant was
unconditionally calling refreshTokenGrant, which made the flag
ineffective for the admin OAuth flow and let admin sessions keep
renewing in deployments that explicitly turned token reuse off.
Add the same isEnabled(process.env.OPENID_REUSE_TOKENS) check up
front and return 403 TOKEN_REUSE_DISABLED so the admin panel BFF
can surface the configuration mismatch instead of silently churning
through retries.
Code-execution outputs land on `messages.attachments` (set by
`processCodeOutput`), while user uploads land on `messages.files`.
The threadFileIds switch (#13004) walked only `files`, so on a
single linear thread:
Turn 1: assistant produces sample.xlsx → attachment with codeEnvRef
Turn 2: user says "add 2 rows"
→ primeCodeFiles: file_ids=0 resourceFiles=0
→ /exec sent files=[]
→ sandbox: FileNotFoundError: 'sample.xlsx'
The `getThreadData` walk found zero file_ids because the assistant's
codeEnvRef was on `attachments`, not `files`. Compounded by the
DB select string `'messageId parentMessageId files'` which didn't
pull `attachments` into memory in the first place — so even fixing
the walk in isolation wouldn't have surfaced them.
Both layers fixed:
- `ThreadMessage` type adds `attachments?: Array<{ file_id?: string }>`
- `getThreadData` walks both arrays, dedups via the same Set
- `initialize.ts` selects `'messageId parentMessageId files attachments'`
## Test plan
`packages/api/src/utils/message.spec.ts` (+6 cases):
- collects file_ids from `attachments`
- walks both `files` and `attachments` on the same message
- regression: linear thread with code-output attachments across
user→assistant→user→assistant produces the right file_ids
- dedupes shared ids that appear in both arrays
- skips attachments without file_id (mirrors `files` behavior)
- empty `attachments` array
`packages/api/src/agents/__tests__/initialize.test.ts` (+1 case):
- locks the DB select string includes `attachments` alongside
`files` / `messageId` / `parentMessageId`
- [x] `npx jest src/utils/message.spec.ts` — 39/39 pass
- [x] `npx jest src/agents/__tests__/initialize.test.ts` — 33/33 pass
- [x] lint clean on all four touched files
* refactor(attachments): add variant prop to AttachmentGroup
* feat(tool-call): add hideImageAttachments prop to ToolCall
* fix(tool-call): keep MCP image outputs visible when tool group auto-collapses
* test(tool-call): verify MCP images hoist out of collapsed tool group
* fix(tool-call): hoist all grouped attachments and prevent ExecuteCode double-render
- rename hideImageAttachments -> hideAttachments and hide every attachment
in the inner tool when a group auto-collapses, then hoist them via
ToolCallGroup with default variant 'all' so non-image attachments survive
the collapse alongside images
- thread hideAttachments to ExecuteCode so it skips its inline AttachmentGroup
when grouped, preventing double-render when the group is expanded
- memoize sequentialParts and groupedParts in ContentParts (with
groupAttachments rolled into each tool-group entry) so we don't re-flatMap
on every render
* test(tool-call): cover hideAttachments contract and grouping integration
- ToolCall: assert AttachmentGroup is skipped when hideAttachments=true and
rendered when explicitly false, locking the prop's contract
- ToolCallGroup: update variant assertion to 'all' (now hoists images and
files together) and add a non-image-only hoist case
- ContentParts.integration: new test exercising the full
ContentParts -> Part -> ToolCall -> AttachmentGroup chain with realistic
MCP-shaped data (groups 2+ contiguous tool calls and hoists, single calls
render inline, mixed image+file hoists, empty attachments are a no-op)
* fix(tool-call): extend hideAttachments to bash/read_file/skill/subagent
When the post-rebase dev branch added BashCall, ReadFileCall, SkillCall,
and SubagentCall as dedicated tool renderers, each rendered its own
inline AttachmentGroup. Once the parent tool group hoists every
attachment, those inline groups would double-render, so they now honor
the same hideAttachments contract as ToolCall and ExecuteCode.
Also seed the new ToolCallGroup mocks (Users icon, getToolDisplayLabel)
so the existing hoist test suite keeps passing on dev.
* fix(image-gen): suppress inline image when attachments are hoisted
OpenAIImageGen renders the generated image directly via <Image>. When
its tool_call lands inside a grouped tool call, the parent now hoists
those attachments into ToolCallGroup's AttachmentGroup, and the inline
<Image> would render the same file a second time. Thread hideAttachments
through Part -> ImageGen (agent-style branch) so the agent-style image
slot stays out of the way once the parent has hoisted.
* refactor(tool-call): drop dead variant prop and flatten render-part hooks
- AttachmentGroup's variant prop ('images' / 'non-images') had no callers
after the final hoisting design landed, so remove the prop and the
filtering branches; everything passes the default 'all' behavior.
- Replace the makeRenderPart factory + dual useMemo with two plain
useCallbacks (renderPart, renderGroupedPart) sharing the same dep set.
- Tighten test mocks: drop 'any' in the new integration test, hoist the
MCP delimiter constant above its consumer, and remove the now-stale
data-variant attribute assertion.
* refactor(tool-call): extract getToolCallId helper and tidy imports
- Pull the (part?.[TOOL_CALL] as Agents.ToolCall)?.id chain into a single
getToolCallId helper in ContentParts so the three call sites stop
repeating the cast verbatim.
- Re-sort ToolCallGroup local imports longest-to-shortest per the project
convention.
- Add a Users mock to the integration test's lucide-react stub so future
subagent-group tests don't trip over an undefined glyph.
* refactor(tool-call): unnest ternaries in subagent and group labels
* 🐛 fix: anchor getCodeGeneratedFiles on threadFileIds, not threadMessageIds
In a branched conversation (regenerations producing the same code-output
filename), `getCodeGeneratedFiles` would silently exclude files whose
File-record `messageId` lived on a sibling branch. The user-visible
symptom: "the previous file isn't persisted" — the LLM tries
`load_workbook("output.xlsx")` on turn 2 and gets `FileNotFoundError`
because LC sent `_injected_files: []` to codeapi instead of priming
the prior turn's output.
`claimCodeFile` is keyed by `(filename, conversationId, context)` —
not by messageId. When sibling A first creates `output.csv`, the File
record persists with `messageId = A`. When sibling N (a regeneration
of A's parent) recreates `output.csv`, the claim finds A's record and
`processCodeOutput` deliberately preserves `messageId = A` to keep
file→original-creator provenance intact (correct behavior for the
linear case where the original creator is in-thread).
Turn N+1's `parentMessageId = N`. `getThreadData` walks back from N:
the thread is `[N, root]` — sibling A is NOT in it. The pre-fix query
filtered by `messageId IN [N, root]`, so the file was excluded.
`getCodeGeneratedFiles` already lives next to `getUserCodeFiles`,
which has always filtered by `file_id IN threadFileIds` (the file_ids
referenced by `messages.files[]` arrays during the thread walk). The
asymmetry — user-uploaded files anchored on the message's reference,
code-generated files anchored on the File's own creator — was the
bug. Anchoring both functions on `threadFileIds` reaches the right
files regardless of which sibling first generated them.
`File.messageId` stays informational ("who first generated this") for
provenance and `processCodeOutput`'s "preserve original messageId on
update" logic stays as-is — only the lookup key for thread-scoped
fetches changes.
- `packages/data-schemas/src/methods/file.ts`: signature + filter
change. JSDoc spells out the branched-conversation rationale.
- `packages/api/src/agents/initialize.ts`: pass `threadFileIds` instead
of `threadMessageIds`. The local `threadMessageIds` declaration is
removed since the only consumer is gone.
- `packages/data-schemas/src/methods/file.spec.ts`: 5 new cases:
- basic happy-path (file referenced by current thread)
- **the regression**: file's creator messageId is on a sibling
branch but file_id is in threadFileIds → finds it
- empty/missing threadFileIds returns []
- cross-conversation isolation
- non-execute_code context filter still applies (a chat attachment
won't be returned even if its file_id is in threadFileIds —
that's `getUserCodeFiles`'s job)
Applies cleanly on top of dev. When LC #12960 (the typed CodeEnvRef
cutover) lands, the only conflict is the legacy `metadata.fileIdentifier`
metadata key flipping to `metadata.codeEnvRef` — same line, trivial
resolve.
- [x] `cd packages/data-schemas && npx jest src/methods/file.spec` —
42/42 pass (including the 5 new regression cases)
- [x] `cd packages/api && npx jest src/agents` — 722/722 pass
(modulo 2 pre-existing summarization e2e failures unrelated)
- [x] `cd api && npx jest server/services/Files server/controllers/agents` —
432/432 pass
- [x] `npx tsc --noEmit -p packages/api/tsconfig.json` — clean
- [ ] Manual: branched conversation reproducer — generate a file in
turn 1, regenerate the parent (sibling), then in turn N+1 ask the
agent to read the file. Pre-fix: `FileNotFoundError`. Post-fix:
the file is primed and load_workbook succeeds.
* 🧪 test: lock initialize.ts → getCodeGeneratedFiles call shape
Integration-level regression test asserting initializeAgent passes
`threadFileIds` (not `threadMessageIds`) to getCodeGeneratedFiles
in branched-conversation scenarios. Locks in the API shape from the
previous commit, sitting one layer above the data-schemas unit test —
so a future refactor to the priming chain can't silently revert to
the messageId-based filter without surfacing a test failure here.
Two cases:
- The full call shape: agent.tools=['execute_code'], resendFiles=true,
threadData mock returns distinct messageIds and fileIds. Asserts the
call uses fileIds, and that getUserCodeFiles uses the same array
(the symmetric design that closes the sibling-branch hole).
- Empty threadFileIds: getCodeGeneratedFiles is still called with []
(its own internal early-return handles the empty case); getUserCodeFiles
is gated at the call site and stays unscheduled.
* 🧠 fix: charge Gemini reasoning tokens in agent usage accounting
Resolves#13006.
`usage.ts` previously billed `usage.output_tokens` directly. For Vertex
AI Gemini thinking models, `@langchain/google-common`'s streaming path
emits `output_tokens = candidatesTokenCount` only, dropping
`thoughtsTokenCount`. Reasoning was billed at zero and the
`total_tokens === input_tokens + output_tokens` invariant was broken.
The fix lives in agents (danny-avila/agents#157) — but this is also a
defense-in-depth backstop in case agents misses a path or another
provider exhibits the same shape. `resolveCompletionTokens(usage)` adds
`output_token_details.reasoning` back when (and only when) the gap is
present (`total - input > output`), so providers that already include
reasoning in `output_tokens` (OpenAI o-series, Anthropic, the
Google-API wrapper) are no-ops — no double-counting.
- `SplitUsage` gains a `completion` field; all four billing call sites
in `processUsageGroup` use it instead of `usage.output_tokens`.
- `total_output_tokens` in the result also reflects the corrected
count.
- `UsageMetadata` interface in `IJobStore.ts` adds the
`output_token_details` field for type safety.
- 4 new tests in `usage.spec.ts` cover: Vertex undercount fix, OpenAI
no-double-count, structured spend path with cache + reasoning, no-op
when no details present.
* 🩹 fix: simplify reasoning correction to invariant-based gap check
Initial fix gated the correction on `output_token_details.reasoning > 0`,
which doesn't help in the live failure case: when google-common's stream
emits the buggy fallback usage_metadata, output_token_details is empty
({}) and the gate exits early.
Live debugging showed the reliable signal is the documented invariant
itself: `total_tokens === input_tokens + output_tokens`. When buggy
streams undercount output, total exceeds input + output by exactly the
unbilled reasoning. Use `total - input` as the corrected output.
This is provider-agnostic and stays a no-op for compliant providers
(OpenAI/Anthropic/Google-via-CustomChatGoogleGenerativeAI), where the
gap is zero.
Live verified end-to-end against gemini-3-flash-preview:
- With agents fix in place: output_tokens=437 → billed 437 (no-op)
- Backstop only (no agents fix, buggy input): raw 135, billed 297
(= total 309 - input 12, matches actual API charge)
Updated tests to cover both scenarios.