🌀 ci: Deterministic Circular Dependency Checks (#14579)

* 🌀 ci: Deterministic Circular Dependency Checks

* 🌀 ci: Enforce Type-Level Edges in Circular Dependency Scan

* 🌀 ci: Materialize Import-Type Expression Edges in Cycle Scan

* 🌀 ci: Collect Inline Type-Only Specifier Edges in Cycle Scan
This commit is contained in:
Danny Avila 2026-08-01 14:43:26 -04:00 committed by GitHub
parent b253b623fe
commit ad0f72dede
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 413 additions and 194 deletions

View file

@ -4,6 +4,8 @@ on:
paths:
- 'api/**'
- 'packages/**'
- 'config/circular-deps.mjs'
- '.github/workflows/backend-review.yml'
- '!**.md'
permissions:
@ -157,7 +159,6 @@ jobs:
circular-deps:
name: Circular dependency checks
needs: build
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
@ -184,36 +185,8 @@ 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)'

220
config/circular-deps.mjs Normal file
View file

@ -0,0 +1,220 @@
import path from 'node:path';
import { createRequire } from 'node:module';
import { fileURLToPath, pathToFileURL } from 'node:url';
const root = path.resolve(fileURLToPath(import.meta.url), '../..');
/**
* Module graphs checked for cycles. `alias` mirrors each target's tsconfig
* paths (or module-alias for the legacy server); `internal` lists the aliased
* specifier prefixes that are first-party alongside relative/absolute imports.
* `minModules` is a resolution-rot guard: if a graph shrinks below it, the
* scan is no longer seeing the real codebase and must fail rather than
* silently pass.
*/
const targets = [
{
name: '@librechat/api',
dir: 'packages/api',
entries: ['src/index.ts', 'src/telemetry.ts'],
alias: { '~': 'src' },
internal: ['~'],
minModules: 200,
},
{
name: 'librechat-data-provider',
dir: 'packages/data-provider',
entries: ['src/index.ts', 'src/react-query/index.ts'],
alias: { 'librechat-data-provider/react-query': 'src/react-query/index.ts', src: 'src' },
internal: ['src/', 'librechat-data-provider/react-query'],
minModules: 20,
/**
* Grandfathered: the core type modules (schemas, config, api-endpoints,
* types/{assistants,agents,runs,web}) hold six pre-existing type-only
* knots that need their own untangling PR. Runtime edges are still
* enforced; the exclusion is logged on every run so it cannot read as
* full coverage.
*/
typeEdges: false,
},
{
name: '@librechat/data-schemas',
dir: 'packages/data-schemas',
entries: ['src/index.ts', 'src/admin/capabilities.ts'],
alias: { '~': 'src' },
internal: ['~'],
minModules: 75,
},
{
name: '@librechat/client',
dir: 'packages/client',
entries: ['src/index.ts'],
alias: { '~': 'src' },
internal: ['~'],
minModules: 100,
},
{
name: 'api server',
dir: 'api',
entries: ['server/index.js'],
alias: { '~': '.' },
internal: ['~'],
minModules: 150,
},
];
/**
* Recursively collects every type-only dependency specifier: `import type` /
* `export type ... from` declarations, inline type specifiers (`import { type
* Foo } from` — kind lives on the child specifier, source on the parent), and
* `import('...')` type expressions (TSImportType), which nest arbitrarily deep
* inside other declarations. All forms carry the specifier as `source.value`.
*/
const collectTypeSpecifiers = (node, specifiers) => {
if (node === null || typeof node !== 'object') {
return;
}
if (Array.isArray(node)) {
for (const item of node) {
collectTypeSpecifiers(item, specifiers);
}
return;
}
const typeOnly =
node.type === 'TSImportType' ||
(node.importKind ?? node.exportKind) === 'type' ||
(Array.isArray(node.specifiers) &&
node.specifiers.some((s) => (s.importKind ?? s.exportKind) === 'type'));
if (typeOnly && typeof node.source?.value === 'string') {
specifiers.add(node.source.value);
}
for (const value of Object.values(node)) {
if (value !== null && typeof value === 'object') {
collectTypeSpecifiers(value, specifiers);
}
}
};
/**
* Bundlers erase type-only edges before building the module graph, so purely
* type-level cycles (the declaration-graph kind) would never be reported.
* Re-materialize each type-only specifier as a bare side-effect import so the
* scanned graph carries type edges too. Uses the real parser (not a regex) so
* imports inside string templates never count.
*/
const typeEdgesPlugin = (parseAst) => ({
name: 'type-edges',
transform(code, id) {
const extension = /\.([mc]?tsx?)(?:$|\?)/.exec(id)?.[1];
if (!extension) {
return null;
}
const { body } = parseAst(code, { lang: extension.endsWith('x') ? 'tsx' : 'ts' });
const specifiers = new Set();
collectTypeSpecifiers(body, specifiers);
if (specifiers.size === 0) {
return null;
}
const edges = [...specifiers].map((s) => `\nimport ${JSON.stringify(s)};`).join('');
return { code: code + edges, map: null };
},
});
/** Stub style/asset imports: rolldown no longer bundles CSS, and assets carry no module edges. */
const assetsPlugin = {
name: 'assets-as-empty',
load(id) {
if (/\.(css|scss|sass|less|svg|png|jpe?g|gif|webp)(?:$|\?)/.test(id)) {
return { code: 'export {};', moduleType: 'js' };
}
return null;
},
};
/** Loads the rolldown instance the tsdown builds run on, keeping resolution semantics identical. */
async function loadRolldown() {
const apiRequire = createRequire(path.join(root, 'packages/api/package.json'));
const tsdownRequire = createRequire(apiRequire.resolve('tsdown'));
const { rolldown } = await import(pathToFileURL(tsdownRequire.resolve('rolldown')).href);
const { parseAst } = await import(pathToFileURL(tsdownRequire.resolve('rolldown/parseAst')).href);
return { rolldown, parseAst };
}
// eslint-disable-next-line no-control-regex
const stripAnsi = (message) => message.replace(/\u001B\[[0-9;]*m/g, '');
const relativize = (message) => stripAnsi(message).replaceAll(root + path.sep, '');
async function scan({ rolldown, parseAst }, target) {
const cycles = [];
const unresolved = [];
const isInternal = (id) =>
id.startsWith('.') || path.isAbsolute(id) || target.internal.some((p) => id.startsWith(p));
const alias = Object.fromEntries(
Object.entries(target.alias).map(([key, dir]) => [key, path.join(root, target.dir, dir)]),
);
try {
const build = await rolldown({
input: target.entries.map((entry) => path.join(root, target.dir, entry)),
platform: 'node',
resolve: { alias },
external: (id) => !isInternal(id),
plugins: [...(target.typeEdges === false ? [] : [typeEdgesPlugin(parseAst)]), assetsPlugin],
checks: { circularDependency: true },
onLog(_level, log) {
if (log.code === 'CIRCULAR_DEPENDENCY') {
cycles.push(relativize(log.message));
} else if (log.code === 'UNRESOLVED_IMPORT') {
unresolved.push(relativize(log.message));
}
},
});
const { output } = await build.generate({ format: 'cjs' });
const modules = output.reduce((sum, chunk) => sum + (chunk.moduleIds?.length ?? 0), 0);
await build.close();
return { target, cycles, unresolved, modules, error: null };
} catch (error) {
return { target, cycles, unresolved, modules: 0, error };
}
}
function report({ target, cycles, unresolved, modules, error }) {
const problems = [];
if (error) {
problems.push(`build failed: ${relativize(error.message)}`);
}
if (cycles.length > 0) {
problems.push(...cycles);
}
if (unresolved.length > 0) {
problems.push(...unresolved.map((message) => `unresolved first-party import: ${message}`));
}
if (!error && modules < target.minModules) {
problems.push(
`graph has ${modules} modules, below the ${target.minModules} floor; the scan is no longer resolving the real codebase`,
);
}
if (problems.length === 0) {
const scope =
target.typeEdges === false
? 'runtime edges only, type edges grandfathered'
: 'runtime + type edges';
console.log(`${target.name}: no circular dependencies (${modules} modules, ${scope})`);
return true;
}
console.error(`${target.name}:`);
for (const problem of problems) {
console.error(` ${problem}`);
}
return false;
}
const engine = await loadRolldown();
const results = await Promise.all(targets.map((target) => scan(engine, target)));
const passed = results.map(report).every(Boolean);
if (!passed) {
console.error('\nCircular dependency check failed.');
process.exit(1);
}

View file

@ -24,6 +24,12 @@ import type {
import type { GenericTool, LCToolRegistry, ToolMap, LCTool } from '@librechat/agents';
import type { IMongoFile, FileOwnerScope } from '@librechat/data-schemas';
import type { Response as ServerResponse } from 'express';
import type {
ResolvedManualSkill,
ResolvedAlwaysApplySkill,
TListSkillsByAccess,
TGetSkillByName,
} from './skills';
import type {
ServerRequest,
EndpointDbMethods,
@ -31,7 +37,6 @@ import type {
InitializeResultBase,
} from '~/types';
import type { LCAvailableTools, RequestScopedMCPConnectionStore } from '../mcp/types';
import type { ResolvedManualSkill, ResolvedAlwaysApplySkill } from './skills';
import type { TFilterFilesByAgentAccess } from './resources';
import {
injectSkillCatalog,
@ -529,77 +534,9 @@ export interface InitializeAgentDbMethods extends EndpointDbMethods {
files?: Array<{ file_id: string }>;
}> | null>;
/** List skill summaries for catalog injection (paginated, omits body/frontmatter) */
listSkillsByAccess?: (params: {
accessibleIds: import('mongoose').Types.ObjectId[];
limit: number;
cursor?: string | null;
}) => Promise<{
skills: Array<{
_id: import('mongoose').Types.ObjectId;
name: string;
description: string;
author: import('mongoose').Types.ObjectId;
/**
* When `true`, the skill is excluded from the catalog injected into
* the agent's additional_instructions and the model cannot invoke it
* via the `skill` tool. Manual `$` invocation is unaffected.
*/
disableModelInvocation?: boolean;
/**
* When `false`, the skill is hidden from the `$` popover and rejected
* by the manual-invocation resolver. Defaults to `true`.
*/
userInvocable?: boolean;
/** True for deployment-directory skills that are loaded in memory. */
deployment?: boolean;
}>;
has_more?: boolean;
after?: string | null;
}>;
/**
* Load a single skill by name, constrained to an ACL-accessible ID set.
* Returns the full document (including `body`) so manual invocation can
* prime SKILL.md without a second DB round-trip.
*
* `preferUserInvocable` (manual paths): on a same-name collision,
* prefer the newest doc with `userInvocable !== false`.
* `preferModelInvocable` (model paths `skill` / `read_file`): on a
* same-name collision, prefer the newest doc with
* `disableModelInvocation !== true`. Both fall back to the newest match
* so the explicit-rejection error paths still fire when only the
* non-preferred variant exists.
*/
getSkillByName?: (
name: string,
accessibleIds: import('mongoose').Types.ObjectId[],
options?: { preferUserInvocable?: boolean; preferModelInvocable?: boolean },
) => Promise<{
_id: import('mongoose').Types.ObjectId;
name: string;
body: string;
author: import('mongoose').Types.ObjectId;
/**
* Skill-declared tool allowlist, forwarded verbatim from the skill doc.
* Surfaced so the resolver can carry it onto `ResolvedManualSkill` for
* future runtime enforcement without a second round-trip.
*/
allowedTools?: string[];
/**
* Set when the skill was authored with `disable-model-invocation: true`.
* The skill tool handler short-circuits on this so a model that names
* such a skill (e.g. via hallucination or stale catalog) gets a clear
* rejection instead of silently executing.
*/
disableModelInvocation?: boolean;
/**
* Set when the skill was authored with `user-invocable: false`. The
* manual-invocation resolver skips with a warn log so an API-direct
* caller can't bypass the popover-side filter.
*/
userInvocable?: boolean;
/** True for deployment-directory skills that are loaded in memory. */
deployment?: boolean;
} | null>;
listSkillsByAccess?: TListSkillsByAccess;
/** Load a single skill by name, constrained to an ACL-accessible ID set. */
getSkillByName?: TGetSkillByName;
/**
* Load accessible skills with `alwaysApply: true`, eagerly including
* `body` so the priming pipeline can splice at turn start without a

View file

@ -6,10 +6,83 @@ import type { LCToolRegistry, LCTool, InjectedMessage } from '@librechat/agents'
import type { BaseMessage } from '@librechat/agents/langchain/messages';
import type { Agent } from 'librechat-data-provider';
import type { Types } from 'mongoose';
import type { InitializeAgentDbMethods } from './initialize';
import { registerCodeExecutionTools } from './tools';
import { logAxiosError } from '~/utils';
/**
* Load a single skill by name, constrained to an ACL-accessible ID set.
* Returns the full document (including `body`) so manual invocation can
* prime SKILL.md without a second DB round-trip.
*
* `preferUserInvocable` (manual paths): on a same-name collision,
* prefer the newest doc with `userInvocable !== false`.
* `preferModelInvocable` (model paths `skill` / `read_file`): on a
* same-name collision, prefer the newest doc with
* `disableModelInvocation !== true`. Both fall back to the newest match
* so the explicit-rejection error paths still fire when only the
* non-preferred variant exists.
*/
export type TGetSkillByName = (
name: string,
accessibleIds: Types.ObjectId[],
options?: { preferUserInvocable?: boolean; preferModelInvocable?: boolean },
) => Promise<{
_id: Types.ObjectId;
name: string;
body: string;
author: Types.ObjectId;
/**
* Skill-declared tool allowlist, forwarded verbatim from the skill doc.
* Surfaced so the resolver can carry it onto `ResolvedManualSkill` for
* future runtime enforcement without a second round-trip.
*/
allowedTools?: string[];
/**
* Set when the skill was authored with `disable-model-invocation: true`.
* The skill tool handler short-circuits on this so a model that names
* such a skill (e.g. via hallucination or stale catalog) gets a clear
* rejection instead of silently executing.
*/
disableModelInvocation?: boolean;
/**
* Set when the skill was authored with `user-invocable: false`. The
* manual-invocation resolver skips with a warn log so an API-direct
* caller can't bypass the popover-side filter.
*/
userInvocable?: boolean;
/** True for deployment-directory skills that are loaded in memory. */
deployment?: boolean;
} | null>;
/** List skill summaries for catalog injection (paginated, omits body/frontmatter). */
export type TListSkillsByAccess = (params: {
accessibleIds: Types.ObjectId[];
limit: number;
cursor?: string | null;
}) => Promise<{
skills: Array<{
_id: Types.ObjectId;
name: string;
description: string;
author: Types.ObjectId;
/**
* When `true`, the skill is excluded from the catalog injected into
* the agent's additional_instructions and the model cannot invoke it
* via the `skill` tool. Manual `$` invocation is unaffected.
*/
disableModelInvocation?: boolean;
/**
* When `false`, the skill is hidden from the `$` popover and rejected
* by the manual-invocation resolver. Defaults to `true`.
*/
userInvocable?: boolean;
/** True for deployment-directory skills that are loaded in memory. */
deployment?: boolean;
}>;
has_more?: boolean;
after?: string | null;
}>;
const SKILL_CATALOG_LIMIT = 100;
const MIN_SKILL_CATALOG_LIMIT = 1;
/** Max pages scanned per run when filtering out inactive skills. */
@ -153,7 +226,7 @@ export interface ResolveModelSpecSkillIdsParams {
/** Full VIEW-accessible skill IDs for this user before model-spec scoping. */
accessibleSkillIds: Types.ObjectId[];
/** DB lookup: name → skill doc constrained to the user's accessible IDs. */
getSkillByName?: InitializeAgentDbMethods['getSkillByName'];
getSkillByName?: TGetSkillByName;
}
/**
@ -331,7 +404,7 @@ export interface InjectSkillCatalogParams {
toolRegistry: LCToolRegistry | undefined;
accessibleSkillIds: Types.ObjectId[];
contextWindowTokens: number;
listSkillsByAccess: InitializeAgentDbMethods['listSkillsByAccess'];
listSkillsByAccess: TListSkillsByAccess | undefined;
/** When true, registers bash_tool alongside skill + read_file. */
codeEnvAvailable?: boolean;
/** When true, bash_tool registers with the hedged stateful-session description. */

View file

@ -1,5 +1,6 @@
import type { IMongoFile } from '@librechat/data-schemas';
import type { SteerMediaClient, SteerFileFetcher } from '../media';
import type { SteerFileFetcher } from '../request';
import type { SteerMediaClient } from '../media';
import { buildSteerMedia, stampSteerPartMedia } from '../media';
jest.spyOn(console, 'log').mockImplementation();

View file

@ -14,15 +14,16 @@ export {
STEER_MAX_FILES,
} from './request';
export type {
SteerRequestUser,
SteerRequestBody,
SteerRequestDeps,
SteerRunContext,
SteerCancelBody,
SteerFileFetcher,
SteerRequestResult,
} from './request';
export { buildSteerMedia, stampSteerPartMedia } from './media';
export type { SteerMediaClient, SteerFileFetcher, StampedSteerMedia } from './media';
export type { SteerMediaClient, StampedSteerMedia } from './media';
export { createSteerIndexOffsetHandlers } from './offset';
export type { SteerOffsetState } from './offset';
export { toSteerFileRef } from './refs';
export type { SteerRequestUser } from './refs';

View file

@ -4,8 +4,9 @@ import { ContentTypes } from 'librechat-data-provider';
import type { IMongoFile } from '@librechat/data-schemas';
import type { TFile } from 'librechat-data-provider';
import type { SteerQueueItem } from '~/stream/interfaces/IJobStore';
import type { SteerFileFetcher } from './request';
import type { SteerMediaResult } from './runtime';
import type { SteerRequestUser } from './request';
import type { SteerRequestUser } from './refs';
import { toSteerFileRef, collectFileIds, buildOwnerFilter } from './refs';
import { prependFileContext } from '../client';
@ -18,13 +19,6 @@ export interface SteerMediaClient {
): Promise<IMongoFile[] | undefined>;
}
/** `db.getFiles`-shaped dependency (injected — this package has no DB access). */
export type SteerFileFetcher = (
filter: Record<string, unknown>,
sortOptions: Record<string, unknown>,
selectFields: Record<string, unknown>,
) => Promise<IMongoFile[] | null | undefined>;
interface PseudoMessage {
messageId: string;
fileContext?: string;

View file

@ -1,5 +1,9 @@
import type { TFile } from 'librechat-data-provider';
import type { SteerRequestUser } from './request';
export interface SteerRequestUser {
id?: string;
tenantId?: string;
}
/**
* Copies the display-metadata fields a steer attachment ref may carry,

View file

@ -1,13 +1,14 @@
import { createHash, randomUUID } from 'crypto';
import { logger } from '@librechat/data-schemas';
import { SteerEvents } from 'librechat-data-provider';
import type { IMongoFile } from '@librechat/data-schemas';
import type { TFile } from 'librechat-data-provider';
import type {
GenerationProtocolVersion,
SteerQueueItem,
SteerReceipt,
} from '~/stream/interfaces/IJobStore';
import type { SteerFileFetcher } from './media';
import type { SteerRequestUser } from './refs';
import {
STEER_ENQUEUE_NOT_RUNNING,
STEER_ENQUEUE_QUEUE_FULL,
@ -34,10 +35,12 @@ export function getSteerMaxLength(): number {
return parseInt(process.env.STEER_MAX_LENGTH ?? '', 10) || DEFAULT_STEER_MAX_LENGTH;
}
export interface SteerRequestUser {
id?: string;
tenantId?: string;
}
/** `db.getFiles`-shaped dependency (injected — this package has no DB access). */
export type SteerFileFetcher = (
filter: Record<string, unknown>,
sortOptions: Record<string, unknown>,
selectFields: Record<string, unknown>,
) => Promise<IMongoFile[] | null | undefined>;
export interface SteerRequestBody {
conversationId?: unknown;

View file

@ -1,5 +1,5 @@
import type { TFile } from 'librechat-data-provider';
import type { ServerRequest } from '~/types';
import type { ServerRequest } from '~/types/http';
export interface SaveBufferParams {
userId: string;

View file

@ -1,34 +1,10 @@
import { WebSearchToolDefinition, CalculatorToolDefinition } from '@librechat/agents';
import type { ExtendedJsonSchema } from './schema';
import { AskUserQuestionToolDefinition } from '~/agents/hitl/askUserQuestionTool';
import { geminiToolkit } from '~/tools/toolkits/gemini';
import { oaiToolkit } from '~/tools/toolkits/oai';
/** Extended JSON Schema type that includes standard validation keywords */
export type ExtendedJsonSchema = {
type?: 'string' | 'number' | 'integer' | 'float' | 'boolean' | 'array' | 'object' | 'null';
enum?: (string | number | boolean | null)[];
items?: ExtendedJsonSchema;
properties?: Record<string, ExtendedJsonSchema>;
required?: string[];
description?: string;
additionalProperties?: boolean | ExtendedJsonSchema;
minLength?: number;
maxLength?: number;
minimum?: number;
maximum?: number;
minItems?: number;
maxItems?: number;
pattern?: string;
format?: string;
default?: unknown;
const?: unknown;
oneOf?: ExtendedJsonSchema[];
anyOf?: ExtendedJsonSchema[];
allOf?: ExtendedJsonSchema[];
$ref?: string;
$defs?: Record<string, ExtendedJsonSchema>;
definitions?: Record<string, ExtendedJsonSchema>;
};
export type { ExtendedJsonSchema } from './schema';
export interface ToolRegistryDefinition {
name: string;

View file

@ -0,0 +1,26 @@
/** Extended JSON Schema type that includes standard validation keywords */
export type ExtendedJsonSchema = {
type?: 'string' | 'number' | 'integer' | 'float' | 'boolean' | 'array' | 'object' | 'null';
enum?: (string | number | boolean | null)[];
items?: ExtendedJsonSchema;
properties?: Record<string, ExtendedJsonSchema>;
required?: string[];
description?: string;
additionalProperties?: boolean | ExtendedJsonSchema;
minLength?: number;
maxLength?: number;
minimum?: number;
maximum?: number;
minItems?: number;
maxItems?: number;
pattern?: string;
format?: string;
default?: unknown;
const?: unknown;
oneOf?: ExtendedJsonSchema[];
anyOf?: ExtendedJsonSchema[];
allOf?: ExtendedJsonSchema[];
$ref?: string;
$defs?: Record<string, ExtendedJsonSchema>;
definitions?: Record<string, ExtendedJsonSchema>;
};

View file

@ -1,4 +1,4 @@
import type { ExtendedJsonSchema } from '../registry/definitions';
import type { ExtendedJsonSchema } from '../registry/schema';
/** Default description for Gemini image generation tool */
const DEFAULT_GEMINI_IMAGE_GEN_DESCRIPTION =

View file

@ -1,4 +1,4 @@
import type { ExtendedJsonSchema } from '../registry/definitions';
import type { ExtendedJsonSchema } from '../registry/schema';
/** Default descriptions for image generation tool */
const DEFAULT_IMAGE_GEN_DESCRIPTION =

View file

@ -1,6 +1,7 @@
import type { ClientOptions, OpenAIClientOptions } from '@librechat/agents';
import type { TConfig } from 'librechat-data-provider';
import type { EndpointTokenConfig, ServerRequest } from '~/types';
import type { EndpointTokenConfig } from './tokens';
import type { ServerRequest } from './http';
export type TCustomEndpointsConfig = Partial<{ [key: string]: Omit<TConfig, 'order'> }>;

View file

@ -1,6 +1,6 @@
import type { Agents } from 'librechat-data-provider';
import type { EventEmitter } from 'events';
import type { ServerSentEvent } from '~/types';
import type { ServerSentEvent } from './events';
export interface GenerationJobMetadata {
userId: string;

View file

@ -12,6 +12,8 @@ export default defineConfig({
dts: { oxc: true },
outDir: 'dist',
sourcemap: true,
// Warn on module cycles at build time; CI enforces via config/circular-deps.mjs.
checks: { circularDependency: true },
// Externalize every third-party dependency (consumers provide the peers) and bundle
// only first-party code: relative imports and the `~/*` tsconfig alias (-> src).
// `neverBundle` is the 0.22 replacement for the deprecated `external` option.

View file

@ -17,6 +17,8 @@ export default defineConfig({
dts: { oxc: true },
outDir: 'dist',
sourcemap: true,
// Warn on module cycles at build time; CI enforces via config/circular-deps.mjs.
checks: { circularDependency: true },
// Force .mjs/.cjs (and .d.mts/.d.cts) regardless of package `type`, so the package can stay
// CommonJS (jest.config.js / babel.config.js are CJS) while still shipping dual ESM/CJS.
fixedExtension: true,

View file

@ -17,6 +17,8 @@ export default defineConfig({
dts: false,
outDir: 'dist',
sourcemap: true,
// Warn on module cycles at build time; CI enforces via config/circular-deps.mjs.
checks: { circularDependency: true },
deps: {
// Match the prior Rollup build: bundle nothing third-party. Externalize every
// bare import (deps, peers, and node built-ins like `crypto`); bundle only the

View file

@ -1,10 +1,5 @@
import { ResourceType } from 'librechat-data-provider';
import type {
BaseSystemCapability,
SystemCapability,
ConfigSection,
CapabilityCategory,
} from '~/types/admin';
import type { TCustomConfig } from 'librechat-data-provider';
// ---------------------------------------------------------------------------
// System Capabilities
@ -50,6 +45,39 @@ export const SystemCapabilities = {
READ_AUDIT_LOG: 'read:audit_log',
} as const;
/** Base capabilities derived from the SystemCapabilities constant. */
export type BaseSystemCapability = (typeof SystemCapabilities)[keyof typeof SystemCapabilities];
/** Principal types that can receive config overrides. */
export type ConfigAssignTarget = 'user' | 'group' | 'role';
/** Top-level keys of the configSchema from librechat.yaml. */
export type ConfigSection = string & keyof TCustomConfig;
/** Section-level config capabilities derived from configSchema keys. */
type ConfigSectionCapability = `manage:configs:${ConfigSection}` | `read:configs:${ConfigSection}`;
/** Principal-scoped config assignment capabilities. */
type ConfigAssignCapability = `assign:configs:${ConfigAssignTarget}`;
/**
* Union of all valid capability strings:
* - Base capabilities from SystemCapabilities
* - Section-level config capabilities (manage:configs:<section>, read:configs:<section>)
* - Config assignment capabilities (assign:configs:<user|group|role>)
*/
export type SystemCapability =
| BaseSystemCapability
| ConfigSectionCapability
| ConfigAssignCapability;
/** UI grouping of capabilities for the admin panel's capability editor. */
export type CapabilityCategory = {
key: string;
labelKey: string;
capabilities: BaseSystemCapability[];
};
/**
* Capabilities that are implied by holding a broader capability.
* e.g. `MANAGE_USERS` implies `READ_USERS`.

View file

@ -1,40 +1,14 @@
import type { PrincipalType, PrincipalModel, TCustomConfig } from 'librechat-data-provider';
import type { SystemCapabilities } from '~/admin/capabilities';
/* ── Capability types ───────────────────────────────────────────────── */
/* ── Capability types (defined alongside the SystemCapabilities constant) ── */
/** Base capabilities derived from the SystemCapabilities constant. */
export type BaseSystemCapability = (typeof SystemCapabilities)[keyof typeof SystemCapabilities];
/** Principal types that can receive config overrides. */
export type ConfigAssignTarget = 'user' | 'group' | 'role';
/** Top-level keys of the configSchema from librechat.yaml. */
export type ConfigSection = string & keyof TCustomConfig;
/** Section-level config capabilities derived from configSchema keys. */
type ConfigSectionCapability = `manage:configs:${ConfigSection}` | `read:configs:${ConfigSection}`;
/** Principal-scoped config assignment capabilities. */
type ConfigAssignCapability = `assign:configs:${ConfigAssignTarget}`;
/**
* Union of all valid capability strings:
* - Base capabilities from SystemCapabilities
* - Section-level config capabilities (manage:configs:<section>, read:configs:<section>)
* - Config assignment capabilities (assign:configs:<user|group|role>)
*/
export type SystemCapability =
| BaseSystemCapability
| ConfigSectionCapability
| ConfigAssignCapability;
/** UI grouping of capabilities for the admin panel's capability editor. */
export type CapabilityCategory = {
key: string;
labelKey: string;
capabilities: BaseSystemCapability[];
};
export type {
BaseSystemCapability,
ConfigAssignTarget,
ConfigSection,
SystemCapability,
CapabilityCategory,
} from '~/admin/capabilities';
/* ── Admin API response types ───────────────────────────────────────── */

View file

@ -8,6 +8,8 @@ export default defineConfig({
dts: { oxc: true },
outDir: 'dist',
sourcemap: true,
// Warn on module cycles at build time; CI enforces via config/circular-deps.mjs.
checks: { circularDependency: true },
// Externalize all third-party deps (consumers provide the peers); bundle only `dotenv`
// so the package stays self-contained for its env-loading side effect, matching the
// prior Rollup build. `neverBundle` is the 0.22 replacement for the deprecated `external`.