diff --git a/.github/workflows/backend-review.yml b/.github/workflows/backend-review.yml index f49f33cd3b..cd9f490201 100644 --- a/.github/workflows/backend-review.yml +++ b/.github/workflows/backend-review.yml @@ -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)' diff --git a/config/circular-deps.mjs b/config/circular-deps.mjs new file mode 100644 index 0000000000..bd8bf80569 --- /dev/null +++ b/config/circular-deps.mjs @@ -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); +} diff --git a/packages/api/src/agents/initialize.ts b/packages/api/src/agents/initialize.ts index 2ed2740d15..8ea74b0301 100644 --- a/packages/api/src/agents/initialize.ts +++ b/packages/api/src/agents/initialize.ts @@ -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 diff --git a/packages/api/src/agents/skills.ts b/packages/api/src/agents/skills.ts index bed7747687..f12fcf75e0 100644 --- a/packages/api/src/agents/skills.ts +++ b/packages/api/src/agents/skills.ts @@ -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. */ diff --git a/packages/api/src/agents/steering/__tests__/media.spec.ts b/packages/api/src/agents/steering/__tests__/media.spec.ts index d57a00ea81..e78e1c0579 100644 --- a/packages/api/src/agents/steering/__tests__/media.spec.ts +++ b/packages/api/src/agents/steering/__tests__/media.spec.ts @@ -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(); diff --git a/packages/api/src/agents/steering/index.ts b/packages/api/src/agents/steering/index.ts index 35eb3fbd7f..6487de5e57 100644 --- a/packages/api/src/agents/steering/index.ts +++ b/packages/api/src/agents/steering/index.ts @@ -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'; diff --git a/packages/api/src/agents/steering/media.ts b/packages/api/src/agents/steering/media.ts index 0c9a98e975..32a045b6c1 100644 --- a/packages/api/src/agents/steering/media.ts +++ b/packages/api/src/agents/steering/media.ts @@ -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; } -/** `db.getFiles`-shaped dependency (injected — this package has no DB access). */ -export type SteerFileFetcher = ( - filter: Record, - sortOptions: Record, - selectFields: Record, -) => Promise; - interface PseudoMessage { messageId: string; fileContext?: string; diff --git a/packages/api/src/agents/steering/refs.ts b/packages/api/src/agents/steering/refs.ts index 3f8a31d347..eb5f3d7691 100644 --- a/packages/api/src/agents/steering/refs.ts +++ b/packages/api/src/agents/steering/refs.ts @@ -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, diff --git a/packages/api/src/agents/steering/request.ts b/packages/api/src/agents/steering/request.ts index 4a6f5625cc..ab3b471a52 100644 --- a/packages/api/src/agents/steering/request.ts +++ b/packages/api/src/agents/steering/request.ts @@ -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, + sortOptions: Record, + selectFields: Record, +) => Promise; export interface SteerRequestBody { conversationId?: unknown; diff --git a/packages/api/src/storage/types.ts b/packages/api/src/storage/types.ts index 63afab569a..358f131270 100644 --- a/packages/api/src/storage/types.ts +++ b/packages/api/src/storage/types.ts @@ -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; diff --git a/packages/api/src/tools/registry/definitions.ts b/packages/api/src/tools/registry/definitions.ts index 88973c079f..3063940df4 100644 --- a/packages/api/src/tools/registry/definitions.ts +++ b/packages/api/src/tools/registry/definitions.ts @@ -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; - 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; - definitions?: Record; -}; +export type { ExtendedJsonSchema } from './schema'; export interface ToolRegistryDefinition { name: string; diff --git a/packages/api/src/tools/registry/schema.ts b/packages/api/src/tools/registry/schema.ts new file mode 100644 index 0000000000..817b2f840f --- /dev/null +++ b/packages/api/src/tools/registry/schema.ts @@ -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; + 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; + definitions?: Record; +}; diff --git a/packages/api/src/tools/toolkits/gemini.ts b/packages/api/src/tools/toolkits/gemini.ts index d147ed6f56..e3ade37867 100644 --- a/packages/api/src/tools/toolkits/gemini.ts +++ b/packages/api/src/tools/toolkits/gemini.ts @@ -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 = diff --git a/packages/api/src/tools/toolkits/oai.ts b/packages/api/src/tools/toolkits/oai.ts index 65c0089124..33d5375572 100644 --- a/packages/api/src/tools/toolkits/oai.ts +++ b/packages/api/src/tools/toolkits/oai.ts @@ -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 = diff --git a/packages/api/src/types/endpoints.ts b/packages/api/src/types/endpoints.ts index 1b9872c924..1071f43616 100644 --- a/packages/api/src/types/endpoints.ts +++ b/packages/api/src/types/endpoints.ts @@ -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 }>; diff --git a/packages/api/src/types/stream.ts b/packages/api/src/types/stream.ts index d9c1d22082..06ba894cc8 100644 --- a/packages/api/src/types/stream.ts +++ b/packages/api/src/types/stream.ts @@ -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; diff --git a/packages/api/tsdown.config.mjs b/packages/api/tsdown.config.mjs index 3a4202e9ec..9ce2bf347a 100644 --- a/packages/api/tsdown.config.mjs +++ b/packages/api/tsdown.config.mjs @@ -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. diff --git a/packages/client/tsdown.config.mjs b/packages/client/tsdown.config.mjs index a027189ce6..fcbb0802e6 100644 --- a/packages/client/tsdown.config.mjs +++ b/packages/client/tsdown.config.mjs @@ -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, diff --git a/packages/data-provider/tsdown.config.mjs b/packages/data-provider/tsdown.config.mjs index a870e0aa43..e2e9a7bea7 100644 --- a/packages/data-provider/tsdown.config.mjs +++ b/packages/data-provider/tsdown.config.mjs @@ -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 diff --git a/packages/data-schemas/src/admin/capabilities.ts b/packages/data-schemas/src/admin/capabilities.ts index 33a6c2ecf9..8982cce3cc 100644 --- a/packages/data-schemas/src/admin/capabilities.ts +++ b/packages/data-schemas/src/admin/capabilities.ts @@ -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:
, read:configs:
) + * - Config assignment capabilities (assign:configs:) + */ +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`. diff --git a/packages/data-schemas/src/types/admin.ts b/packages/data-schemas/src/types/admin.ts index 0fcbf450e7..4fa7254b10 100644 --- a/packages/data-schemas/src/types/admin.ts +++ b/packages/data-schemas/src/types/admin.ts @@ -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:
, read:configs:
) - * - Config assignment capabilities (assign:configs:) - */ -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 ───────────────────────────────────────── */ diff --git a/packages/data-schemas/tsdown.config.mjs b/packages/data-schemas/tsdown.config.mjs index 1b6923ce66..33cfa59952 100644 --- a/packages/data-schemas/tsdown.config.mjs +++ b/packages/data-schemas/tsdown.config.mjs @@ -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`.