diff --git a/.dockerignore b/.dockerignore index 396f0da3e57..60ae4104f35 100644 --- a/.dockerignore +++ b/.dockerignore @@ -9,6 +9,8 @@ # Specific patterns to ignore data-node meili_data* +ollama +ministack librechat* Dockerfile* docs diff --git a/.env.example b/.env.example index 454f8460300..1edffc185b4 100644 --- a/.env.example +++ b/.env.example @@ -27,7 +27,13 @@ PORT=3080 # HTTP_HEADERS_TIMEOUT_MS=80000 # HTTP_REQUEST_TIMEOUT_MS=300000 +# Local MongoDB default. For Amazon DocumentDB 8.0+, use an instance-based cluster URI with +# `tls=true&retryWrites=false` and set MONGO_TLS_CA_FILE to the AWS CA bundle. +# Example: mongodb://user:password@cluster.example.docdb.amazonaws.com:27017/LibreChat?tls=true&retryWrites=false&replicaSet=rs0&readPreference=secondaryPreferred +# When tunnelling from the Docker API container, use host.docker.internal instead of 127.0.0.1. MONGO_URI=mongodb://127.0.0.1:27017/LibreChat +# Path inside the API container. The override mounts ./certs/documentdb here. +MONGO_TLS_CA_FILE= #The maximum number of connections in the connection pool. */ MONGO_MAX_POOL_SIZE= #The minimum number of connections in the connection pool. */ @@ -495,6 +501,9 @@ ANTHROPIC_API_KEY=user_provided # AWS Profile # BEDROCK_AWS_PROFILE=your-profile-name +# Host directory containing the AWS config, credentials, and optional SSO cache. +# Docker Compose mounts this directory read-only at /home/node/.aws. +AWS_PROFILE_DIR=/home/your-user/.aws # Static credentials (use only if profiles or IAM roles are not suitable) # BEDROCK_AWS_ACCESS_KEY_ID=someAccessKey @@ -889,6 +898,12 @@ ALLOW_EMAIL_LOGIN=true # ALLOW_EMAIL_LOGIN_OVERRIDE=false # note: permits direct API email login while ALLOW_EMAIL_LOGIN=false; each use is logged ALLOW_REGISTRATION=true ALLOW_SOCIAL_LOGIN=false +# Local ministack Cognito provisioning names. The app resolves the generated pool id + +# client id by these names at startup when OPENID_ALLOW_INSECURE_REQUESTS is set, so +# OPENID_ISSUER / OPENID_CLIENT_ID below can stay as placeholders for local dev. +COGNITO_POOL_NAME= +COGNITO_CLIENT_NAME= + ALLOW_SOCIAL_REGISTRATION=false ALLOW_PASSWORD_RESET=false # ALLOW_ACCOUNT_DELETION=true # note: enabled by default if omitted/commented out @@ -977,7 +992,8 @@ OPENID_AUDIENCE= # the intended access-token audience during refresh. Usually matches OPENID_AUDIENCE. OPENID_REFRESH_AUDIENCE= -OPENID_BUTTON_LABEL= +# Login-page button label for the OpenID/Cognito provider (empty -> "Continue with OpenID"). +OPENID_BUTTON_LABEL=Continue with Cognito OPENID_IMAGE_URL= # Set to true to automatically redirect to the OpenID provider when a user visits the login page # This will bypass the login form completely for users, only use this if OpenID is your only authentication method @@ -985,6 +1001,13 @@ OPENID_AUTO_REDIRECT=false # Set to true to use PKCE (Proof Key for Code Exchange) for OpenID authentication. # For public clients (no client secret), leave OPENID_CLIENT_SECRET empty and set this to true. OPENID_USE_PKCE=false +# Allow OpenID discovery/token/JWKS over plain HTTP (local emulators such as ministack, +# issuer http://ministack:4566). Keep empty/false for real HTTPS providers like AWS Cognito. +OPENID_ALLOW_INSECURE_REQUESTS= +# Host-reachable base for browser-facing OpenID endpoints (authorize/logout) when using a +# local emulator whose discovery advertises a compose-network host. e.g. http://localhost:4566. +# Leave empty for real HTTPS providers. +OPENID_PUBLIC_ENDPOINT_URL= #Set to true to reuse openid tokens for authentication management instead of using the mongodb session and the custom refresh token. OPENID_REUSE_TOKENS= #Max age a reused OpenID session token is served before LibreChat forces an IdP refresh. Default 900000 ms (15 min). @@ -1454,3 +1477,47 @@ OPENWEATHER_API_KEY= # Circuit breaker: max backoff cap (ms) for exponential backoff # MCP_CB_MAX_BACKOFF_MS=300000 + +#=====================================================================# +# ministack / fork infra # +#=====================================================================# +# Docker Compose project namespace for the local ministack/fork stack. +# Prefixes container, network, and volume names so this stack stays +# isolated from other Compose projects on the same host. +COMPOSE_PROJECT_NAME=plastic-ai + +#=====================================================================# +# Observability stack (fork, compose profile) # +#=====================================================================# +# Local Langfuse (GenAI traces) and HyperDX (browser activity), started with +# docker compose --profile observability up -d +# Every value below is a local-development placeholder: replace them, and never +# reuse them in a deployed environment (terraform/ generates its own). +# +# To send data to the local stack, also set these in the sections above: +# LANGFUSE_PUBLIC_KEY / LANGFUSE_SECRET_KEY any pk-lf-... / sk-lf-... pair; Langfuse is +# bootstrapped with the same pair +# LANGFUSE_BASE_URL=http://langfuse-web:3000 +# RUM_ENABLED=true, RUM_AUTH_MODE=proxy, RUM_PROXY_TARGET_URL=http://otel-collector:4318 +# RUM_PROXY_AUTHORIZATION shared token the collector checks +# Comment them out again when the profile is not running; otherwise every export +# fails (harmlessly, but noisily) against services that are not there. + +# Host ports for the two UIs. +LANGFUSE_PORT=3100 +HYPERDX_PORT=8080 +# Langfuse admin login. Sign-up is disabled, so this is the only account. +LANGFUSE_INIT_USER_EMAIL=admin@example.com +LANGFUSE_INIT_USER_PASSWORD=change-me-local +# Langfuse internals. LANGFUSE_ENCRYPTION_KEY must be 64 hex characters (openssl rand -hex 32). +LANGFUSE_NEXTAUTH_SECRET=change-me-local +LANGFUSE_SALT=change-me-local +LANGFUSE_ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000 +LANGFUSE_POSTGRES_PASSWORD=change-me-local +LANGFUSE_REDIS_AUTH=change-me-local +# Shared ClickHouse: the admin account Langfuse uses, and HyperDX's account, which is +# limited to the `hyperdx` database and the content-free `analytics` views +# (otel/clickhouse/hyperdx.xml). +CLICKHOUSE_USER=clickhouse +CLICKHOUSE_PASSWORD=change-me-local +HYPERDX_CLICKHOUSE_PASSWORD=change-me-local diff --git a/.gitattributes b/.gitattributes index 725ac8b6bd3..cf61dd5e826 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,8 @@ # Force LF line endings for shell scripts and git hooks (required for cross-platform compatibility) .husky/* text eol=lf *.sh text eol=lf + +# Files OpenTofu reads and hashes (image tags, task triggers): CRLF on one +# machine would change the hashes and redeploy on every apply from the other. +librechat.*.yaml text eol=lf +otel/** text eol=lf diff --git a/.github/ISSUE_TEMPLATE/BUG-REPORT.yml b/.github/ISSUE_TEMPLATE/BUG-REPORT.yml index 5ab77141948..54a2227ede5 100644 --- a/.github/ISSUE_TEMPLATE/BUG-REPORT.yml +++ b/.github/ISSUE_TEMPLATE/BUG-REPORT.yml @@ -7,7 +7,7 @@ body: attributes: value: | Thanks for taking the time to fill out this bug report! - + Before submitting, please: - Search existing [Issues and Discussions](https://github.com/LibreChat-AI/LibreChat/discussions) to see if your bug has already been reported - Use [Discussions](https://github.com/LibreChat-AI/LibreChat/discussions) instead of Issues for: @@ -26,14 +26,14 @@ body: id: version-info attributes: label: Version Information - description: | - In LibreChat, open **Settings → About** and click **Copy diagnostics**, then paste the result here. - This captures the exact version, commit, branch, and build date so maintainers can pinpoint the build you're running. - - If the About panel is unavailable (older version / self-hosted with it disabled), please provide as much of the following as possible instead: - - Docker: `docker images | grep librechat` (image tag) and `docker inspect | grep -i "\"Commit\\|BUILD_"` if build args were set - - Source: `git rev-parse HEAD` and `git rev-parse --abbrev-ref HEAD` - placeholder: Paste the diagnostics block here + description: | + In LibreChat, open **Settings → About** and click **Copy diagnostics**, then paste the result here. + This captures the exact version, commit, branch, and build date so maintainers can pinpoint the build you're running. + + If the About panel is unavailable (older version / self-hosted with it disabled), please provide as much of the following as possible instead: + - Docker: `docker images | grep librechat` (image tag) and `docker inspect | grep -i "\"Commit\\|BUILD_"` if build args were set + - Source: `git rev-parse HEAD` and `git rev-parse --abbrev-ref HEAD` + placeholder: Paste the diagnostics block here validations: required: true - type: textarea @@ -90,4 +90,4 @@ body: description: By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/LibreChat-AI/LibreChat/blob/main/.github/CODE_OF_CONDUCT.md) options: - label: I agree to follow this project's Code of Conduct - required: true + required: true diff --git a/.gitignore b/.gitignore index 517f332fcf2..b28534c974e 100644 --- a/.gitignore +++ b/.gitignore @@ -112,8 +112,8 @@ junit.xml **/venv/ # docker override file -docker-compose.override.yaml -docker-compose.override.yml +# docker-compose.override.yaml +# docker-compose.override.yml # meilisearch meilisearch @@ -193,3 +193,9 @@ scripts/activity-labels/results/ !e2e/lighthouse/librechat.yaml /.lighthouse/ /e2e/lighthouse/.test-results/ + +# Infrastructure and development artifacts +.kiro/ +infra/ +/ministack/state/ +/ollama/ \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 594e5478401..19ca38e06ab 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -168,6 +168,48 @@ and keep it inside the feature; app-global preferences and shell state a feature through props or a small host-supplied context rather than reached for through `~/store`; when a consumer sits outside the feature you are changing, leave that atom on Recoil and pass it in. Passing them in is what lets a feature move to its own workspace later without a rewrite, and it keeps the +Jotai conversion scoped to the state a feature owns. See the detailed policy in `CLAUDE.md` under +“Client State Ownership”. + +## Upstream mergeability + +This repository is a fork of upstream LibreChat, re-platformed onto ministack/AWS. Keep the fork as +close as possible to upstream so future upstream changes merge with minimal conflict. Prefer these +patterns, which are already the de-facto style across the migration; see `CLAUDE.md` under "Workspace +Boundaries" for the companion module-placement policy. + +- **Comment out, do not delete.** When code is replaced or disabled, leave the upstream line in place + as a commented-out merge anchor so the three-way merge still lines up. This is the established + pattern: `api/db/connect.js` keeps the entire Mongoose connection block commented out, and + `api/server/routes/index.js` keeps `// const search = require('./search');` above each + `unsupported(...)` stub. `docker-compose.override.yml` keeps the legacy `MONGO_URI` and `RAG_API_URL` + values in comments beside their replacements. +- **Interleave new code with the old it replaces.** Put the new line directly adjacent to the + commented-out original so a reviewer can see exactly where a merged upstream change maps, rather than + moving the replacement to a distant block. +- **Make minimal changes.** Prefer the smallest edit that achieves the goal — env/config toggles and + additive wiring over rewrites. `ENDPOINTS=agents,bedrock` and `fileStrategy: "s3"` switch behavior + through configuration instead of editing the code paths they select. +- **Create new files instead of replacing existing ones** where practical, so upstream files stay + byte-close to their origin. The DynamoDB port lives in new `packages/data-schemas/src/dynamo/*` and + `packages/api/src/dynamo/*`; the Bedrock Knowledge Base in `packages/api/src/bedrock-kb/*`; the 501 + profile in `packages/api/src/http/unsupported.ts` — none rewrite the upstream Mongoose models in + place. + +## Configuration and Docker changes + +When a change introduces new configuration, add every new variable to **both** `.env` and +`.env.example`, keyed the same, with the `.env.example` entry carrying a documenting comment and a +safe placeholder (never a real secret). The two files must stay in lockstep so a fresh checkout of +`.env.example` lists the same knobs the running `.env` uses. + +Make all Docker changes in `docker-compose.override.yml`, never by editing the upstream +`docker-compose.yml`. The override file is where the fork adds and re-points services (new init +containers such as `dynamodb-init`/`s3-init`/`bedrock-kb-init`, ministack endpoints, and the +commented-out legacy `MONGO_URI`/`RAG_API_URL` anchors), which keeps the upstream compose file +byte-close to its origin and every fork-specific service additive. `docker-compose.override.yml.example` +is upstream LibreChat's commented override template, not a mirror of the fork's override — the fork's +active services live only in `docker-compose.override.yml`, so do not mirror them into the example. Jotai conversion scoped to the state a feature owns. For persisted atoms, use the helpers in `client/src/store/jotai-utils.ts`. diff --git a/Dockerfile.multi b/Dockerfile.multi index 976fab71dcc..40c8672f31b 100644 --- a/Dockerfile.multi +++ b/Dockerfile.multi @@ -71,6 +71,76 @@ COPY --from=data-provider-build /app/packages/data-provider/dist /app/packages/d COPY --from=data-schemas-build /app/packages/data-schemas/dist /app/packages/data-schemas/dist RUN npm run build +# Typecheck API source copied into Docker's Linux filesystem +FROM base AS api-typecheck-run +WORKDIR /app +COPY packages/api ./packages/api +COPY --from=data-provider-build /app/packages/data-provider/dist /app/packages/data-provider/dist +COPY --from=data-schemas-build /app/packages/data-schemas/dist /app/packages/data-schemas/dist +RUN npx tsc --noEmit -p packages/api/tsconfig.json && touch /tmp/api-typecheck + +FROM scratch AS api-typecheck +COPY --from=api-typecheck-run /tmp/api-typecheck /api-typecheck + +# Typecheck data-schemas source copied into Docker's Linux filesystem +FROM base AS data-schemas-typecheck-run +WORKDIR /app +COPY packages/data-schemas ./packages/data-schemas +COPY --from=data-provider-build /app/packages/data-provider/dist /app/packages/data-provider/dist +RUN npx tsc --noEmit -p packages/data-schemas/tsconfig.json && touch /tmp/data-schemas-typecheck + +FROM scratch AS data-schemas-typecheck +COPY --from=data-schemas-typecheck-run /tmp/data-schemas-typecheck /data-schemas-typecheck + +# Run API package unit tests against source copied into Docker's Linux filesystem +FROM base AS api-test-run +WORKDIR /app +COPY config ./config +COPY packages/api ./packages/api +COPY --from=data-provider-build /app/packages/data-provider/dist /app/packages/data-provider/dist +COPY --from=data-schemas-build /app/packages/data-schemas/dist /app/packages/data-schemas/dist +RUN npx jest --coverage=false --config packages/api/jest.config.mjs --rootDir packages/api \ + --testPathPatterns="(src/files/rag\.spec\.ts$|src/storage/s3/)" \ + && touch /tmp/api-test + +FROM scratch AS api-test +COPY --from=api-test-run /tmp/api-test /api-test + +# Run legacy `/api` workspace unit tests against source copied into Docker's Linux filesystem +FROM base AS legacy-api-test-run +WORKDIR /app +COPY config ./config +COPY skill ./skill +COPY api ./api +COPY --from=data-provider-build /app/packages/data-provider/dist ./packages/data-provider/dist +COPY --from=data-schemas-build /app/packages/data-schemas/dist ./packages/data-schemas/dist +COPY --from=api-package-build /app/packages/api/dist ./packages/api/dist +WORKDIR /app/api +RUN npx cross-env NODE_ENV=test jest \ + --testPathPatterns="(server/services/Files/(process|strategies)|test/app/clients/tools/util/fileSearch)" \ + --testPathIgnorePatterns="integration" \ + && touch /tmp/legacy-api-test + +FROM scratch AS legacy-api-test +COPY --from=legacy-api-test-run /tmp/legacy-api-test /legacy-api-test + +# Run cognito-login-migration spec tests against source copied into Docker's Linux filesystem +FROM base AS cognito-login-test-run +WORKDIR /app +COPY config ./config +COPY skill ./skill +COPY api ./api +COPY --from=data-provider-build /app/packages/data-provider/dist ./packages/data-provider/dist +COPY --from=data-schemas-build /app/packages/data-schemas/dist ./packages/data-schemas/dist +COPY --from=api-package-build /app/packages/api/dist ./packages/api/dist +WORKDIR /app/api +RUN npx cross-env NODE_ENV=test jest \ + --testPathPatterns="(server/routes/oauth|server/routes/cognito|strategies/openidStrategy|strategies/.*[Cc]ognito|server/cognitoLogin|test/cognito)" \ + --testPathIgnorePatterns="integration" \ + && touch /tmp/cognito-login-test + +FROM scratch AS cognito-login-test +COPY --from=cognito-login-test-run /tmp/cognito-login-test /cognito-login-test # Build `client` package FROM base AS client-package-build WORKDIR /app/packages/client diff --git a/api/db/connect.js b/api/db/connect.js index eec263d9f6a..2956dda8b74 100644 --- a/api/db/connect.js +++ b/api/db/connect.js @@ -22,6 +22,8 @@ const maxIdleTimeMS = parseInt(process.env.MONGO_MAX_IDLE_TIME_MS) || undefined; const waitQueueTimeoutMS = parseInt(process.env.MONGO_WAIT_QUEUE_TIMEOUT_MS) || undefined; /** Set to false to disable automatic index creation for all models associated with this connection. */ const autoIndex = optionalEnabled(process.env.MONGO_AUTO_INDEX); +/** CA bundle path for TLS connections such as Amazon DocumentDB. */ +const tlsCAFile = process.env.MONGO_TLS_CA_FILE || undefined; /** Set to `false` to disable Mongoose automatically calling `createCollection()` on every model created on this connection. */ const autoCreate = optionalEnabled(process.env.MONGO_AUTO_CREATE); @@ -55,6 +57,7 @@ async function connectDb() { ...(maxIdleTimeMS ? { maxIdleTimeMS } : {}), ...(waitQueueTimeoutMS ? { waitQueueTimeoutMS } : {}), ...(autoIndex != undefined ? { autoIndex } : {}), + ...(tlsCAFile ? { tlsCAFile } : {}), ...(autoCreate != undefined ? { autoCreate } : {}), // useNewUrlParser: true, // useUnifiedTopology: true, diff --git a/api/package.json b/api/package.json index 8197ac16b6c..24505359399 100644 --- a/api/package.json +++ b/api/package.json @@ -37,6 +37,7 @@ "@anthropic-ai/vertex-sdk": "^0.16.0", "@aws-sdk/client-bedrock-runtime": "^3.1013.0", "@aws-sdk/client-cloudfront": "^3.1042.0", + "@aws-sdk/client-cognito-identity-provider": "^3.1013.0", "@aws-sdk/client-s3": "^3.980.0", "@aws-sdk/cloudfront-signer": "^3.1036.0", "@aws-sdk/credential-providers": "^3.1045.0", diff --git a/api/server/routes/oauth.wiring.test.js b/api/server/routes/oauth.wiring.test.js new file mode 100644 index 00000000000..16bb9df40aa --- /dev/null +++ b/api/server/routes/oauth.wiring.test.js @@ -0,0 +1,133 @@ +const express = require('express'); +const request = require('supertest'); + +const originalDomainClient = process.env.DOMAIN_CLIENT; +process.env.DOMAIN_CLIENT = 'http://client.test'; + +const mockLogger = { + warn: jest.fn(), + error: jest.fn(), + info: jest.fn(), + debug: jest.fn(), +}; + +const mockOAuthHandler = jest.fn((_req, res) => res.status(204).end()); +const mockOpenIDCallbackMiddleware = jest.fn((_req, _res, next) => next()); +const mockCreateOpenIDCallbackAuthenticator = jest.fn(() => mockOpenIDCallbackMiddleware); + +const mockPassportAuthenticate = jest.fn(() => (_req, res, _next) => res.redirect('/authorize')); + +jest.mock('passport', () => ({ + authenticate: (...args) => mockPassportAuthenticate(...args), +})); + +jest.mock('openid-client', () => ({ + randomState: jest.fn(() => 'random-state'), +})); + +jest.mock('@librechat/data-schemas', () => ({ + logger: mockLogger, +})); + +jest.mock('librechat-data-provider', () => ({ + ...jest.requireActual('librechat-data-provider'), + ErrorTypes: { + AUTH_FAILED: 'auth_failed', + }, +})); + +jest.mock('@librechat/api', () => ({ + ...jest.requireActual('@librechat/api'), + buildOAuthFailureLog: jest.fn(() => ({})), + createOpenIDCallbackAuthenticator: (...args) => mockCreateOpenIDCallbackAuthenticator(...args), + createSetBalanceConfig: jest.fn(() => (_req, _res, next) => next()), + getOAuthFailureMessage: jest.fn(() => 'OAuth authentication failed'), + redirectToAuthFailure: jest.fn((res) => res.redirect('/login?error=auth_failed')), +})); + +jest.mock('~/server/middleware', () => ({ + checkDomainAllowed: jest.fn((_req, _res, next) => next()), + loginLimiter: jest.fn((_req, _res, next) => next()), + logHeaders: jest.fn((_req, _res, next) => next()), + markOAuthNavigation: jest.fn((_req, _res, next) => next()), +})); + +jest.mock('~/server/controllers/auth/oauth', () => ({ + createOAuthHandler: jest.fn(() => mockOAuthHandler), +})); + +jest.mock('~/models', () => ({ + findBalanceByUser: jest.fn(), + upsertBalanceFields: jest.fn(), +})); + +jest.mock('~/server/services/Config', () => ({ + getAppConfig: jest.fn(), +})); + +afterAll(() => { + if (originalDomainClient === undefined) { + delete process.env.DOMAIN_CLIENT; + return; + } + process.env.DOMAIN_CLIENT = originalDomainClient; +}); + +function getOAuthRouter() { + jest.resetModules(); + return require('./oauth'); +} + +function createApp() { + const app = express(); + app.use('/oauth', getOAuthRouter()); + app.use((err, _req, res, _next) => { + res.status(500).json({ message: err.message }); + }); + return app; +} + +describe('OAuth route wiring', () => { + beforeEach(() => { + mockLogger.warn.mockClear(); + mockLogger.error.mockClear(); + mockLogger.info.mockClear(); + mockLogger.debug.mockClear(); + mockOAuthHandler.mockClear(); + mockOpenIDCallbackMiddleware.mockClear(); + mockCreateOpenIDCallbackAuthenticator.mockClear(); + mockPassportAuthenticate.mockClear(); + mockPassportAuthenticate.mockImplementation(() => (_req, res, _next) => res.redirect('/authorize')); + mockOpenIDCallbackMiddleware.mockImplementation((_req, _res, next) => next()); + }); + + it('initiates the OIDC authorization-code flow at /oauth/openid', async () => { + const app = createApp(); + + const response = await request(app).get('/oauth/openid'); + + expect(response.status).toBe(302); + expect(response.headers.location).toBe('/authorize'); + expect(mockPassportAuthenticate).toHaveBeenCalledWith( + 'openid', + expect.objectContaining({ session: false, state: 'random-state' }), + ); + }); + + it('invokes the OIDC callback authenticator at /oauth/openid/callback', async () => { + const app = createApp(); + + const response = await request(app).get( + '/oauth/openid/callback?code=secret-code&state=random-state', + ); + + expect(response.status).toBe(204); + expect(mockCreateOpenIDCallbackAuthenticator).toHaveBeenCalled(); + expect(mockOpenIDCallbackMiddleware).toHaveBeenCalledWith( + expect.any(Object), + expect.any(Object), + expect.any(Function), + ); + expect(mockOAuthHandler).toHaveBeenCalled(); + }); +}); diff --git a/api/strategies/openidStrategy.js b/api/strategies/openidStrategy.js index 3f2f9c22d10..0d34184a75f 100644 --- a/api/strategies/openidStrategy.js +++ b/api/strategies/openidStrategy.js @@ -2,6 +2,11 @@ const undici = require('undici'); const { get } = require('lodash'); const passport = require('passport'); const client = require('openid-client'); +const { + CognitoIdentityProviderClient, + ListUserPoolsCommand, + ListUserPoolClientsCommand, +} = require('@aws-sdk/client-cognito-identity-provider'); const jwtDecode = require('jsonwebtoken/decode'); const { hashToken, logger, tenantStorage } = require('@librechat/data-schemas'); const { Strategy: OpenIDStrategy } = require('openid-client/passport'); @@ -899,8 +904,84 @@ const setupOpenIdAdmin = (openidConfig) => { * @returns {Promise} A promise that resolves when the OpenID strategy is set up and returns the openid client config object. * @throws {Error} If an error occurs during the setup process. */ +/** + * Local-emulator config discovery. ministack's Cognito CreateUserPool / + * CreateUserPoolClient return server-generated ids that cannot be pinned from + * compose or .env (same constraint the Bedrock KB resolver handles by name). + * When the insecure/local flag is on and OPENID_ISSUER / OPENID_CLIENT_ID are + * unset (or still the by-name placeholder), resolve them from COGNITO_POOL_NAME / + * COGNITO_CLIENT_NAME via the Cognito API, so config survives pool/client + * recreation without an external shim. Real AWS sets OPENID_ISSUER / + * OPENID_CLIENT_ID directly, so this is a no-op there. + * + * Mutates process.env.OPENID_ISSUER / OPENID_CLIENT_ID in place; safe to call + * repeatedly (setupOpenId may retry). + */ +async function resolveLocalCognitoConfig() { + if (!isEnabled(process.env.OPENID_ALLOW_INSECURE_REQUESTS)) { + return; + } + const poolName = process.env.COGNITO_POOL_NAME; + const clientName = process.env.COGNITO_CLIENT_NAME; + if (!poolName) { + return; + } + // A concrete issuer that already carries a pool id (…/_) is treated + // as authoritative; the by-name placeholder is not. + const issuer = process.env.OPENID_ISSUER ?? ''; + const issuerLooksResolved = /_[A-Za-z0-9]+$/.test(issuer.replace(/\/$/, '')); + const clientLooksResolved = + !!process.env.OPENID_CLIENT_ID && process.env.OPENID_CLIENT_ID !== clientName; + if (issuerLooksResolved && clientLooksResolved) { + return; + } + const endpoint = process.env.AWS_ENDPOINT_URL; + const region = process.env.AWS_REGION || process.env.AWS_DEFAULT_REGION; + if (!endpoint || !region) { + logger.warn( + '[openidStrategy] OPENID_ALLOW_INSECURE_REQUESTS is set but AWS_ENDPOINT_URL/AWS_REGION are missing; cannot resolve Cognito config by name.', + ); + return; + } + const accessKeyId = process.env.AWS_ACCESS_KEY_ID; + const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY; + const cognito = new CognitoIdentityProviderClient({ + region, + endpoint, + ...(accessKeyId && secretAccessKey + ? { credentials: { accessKeyId, secretAccessKey } } + : {}), + }); + const pools = await cognito.send(new ListUserPoolsCommand({ MaxResults: 60 })); + const pool = pools.UserPools?.find((p) => p.Name === poolName); + if (!pool?.Id) { + logger.warn(`[openidStrategy] Cognito pool named '${poolName}' not found for local resolution.`); + return; + } + // Issuer host is the compose-network address the API container reaches; the + // browser-facing hosted-UI/callback URLs are baked into the app client itself. + process.env.OPENID_ISSUER = `${endpoint.replace(/\/$/, '')}/${pool.Id}`; + if (clientName) { + const clients = await cognito.send( + new ListUserPoolClientsCommand({ UserPoolId: pool.Id, MaxResults: 60 }), + ); + const appClient = clients.UserPoolClients?.find((c) => c.ClientName === clientName); + if (appClient?.ClientId) { + process.env.OPENID_CLIENT_ID = appClient.ClientId; + } else { + logger.warn( + `[openidStrategy] Cognito app client named '${clientName}' not found in pool ${pool.Id}.`, + ); + } + } + logger.info( + `[openidStrategy] Resolved local Cognito config by name: issuer=${process.env.OPENID_ISSUER} clientId=${process.env.OPENID_CLIENT_ID}`, + ); +} + async function setupOpenId() { try { + await resolveLocalCognitoConfig(); const usePKCE = isEnabled(process.env.OPENID_USE_PKCE); const shouldGenerateNonce = isEnabled(process.env.OPENID_GENERATE_NONCE); @@ -922,16 +1003,61 @@ async function setupOpenId() { clientMetadata.token_endpoint_auth_method = 'none'; } + const allowInsecure = isEnabled(process.env.OPENID_ALLOW_INSECURE_REQUESTS); + /** @type {Configuration} */ - openidConfig = await client.discovery( - new URL(process.env.OPENID_ISSUER), - process.env.OPENID_CLIENT_ID, - clientMetadata, - undefined, - { - [client.customFetch]: customFetch, - }, - ); + if (allowInsecure) { + // Local emulator path (e.g. ministack): its discovery document advertises + // the real-AWS issuer (https://cognito-idp..amazonaws.com/) + // while being served over http://ministack:4566, so client.discovery's + // issuer check rejects it. Fetch the metadata ourselves and build the + // Configuration directly, which skips that check. Guarded by + // OPENID_ALLOW_INSECURE_REQUESTS so real HTTPS providers keep full validation. + const metadataUrl = new URL( + `${process.env.OPENID_ISSUER.replace(/\/$/, '')}/.well-known/openid-configuration`, + ); + const metadataResponse = await customFetch(metadataUrl, { method: 'GET' }); + if (!metadataResponse.ok) { + throw new Error( + `discovery request failed: ${metadataResponse.status} ${metadataResponse.statusText}`, + ); + } + const serverMetadata = await metadataResponse.json(); + // Browser-vs-container reachability: the discovery doc advertises endpoints on the + // compose-network host (e.g. http://ministack:4566) which the browser cannot resolve. + // Rewrite only the browser-facing endpoints (authorization, end-session) to the + // host-reachable base from OPENID_PUBLIC_ENDPOINT_URL; server-to-server endpoints + // (token, jwks, userinfo) keep the internal host the API container reaches. + const publicBase = process.env.OPENID_PUBLIC_ENDPOINT_URL; + const internalBase = process.env.AWS_ENDPOINT_URL; + if (publicBase && internalBase) { + const internalOrigin = new URL(internalBase).origin; + const publicOrigin = new URL(publicBase).origin; + for (const key of ['authorization_endpoint', 'end_session_endpoint']) { + const value = serverMetadata[key]; + if (typeof value === 'string' && value.startsWith(internalOrigin)) { + serverMetadata[key] = publicOrigin + value.slice(internalOrigin.length); + } + } + } + openidConfig = new client.Configuration( + serverMetadata, + process.env.OPENID_CLIENT_ID, + clientMetadata, + ); + openidConfig[client.customFetch] = customFetch; + client.allowInsecureRequests(openidConfig); + } else { + openidConfig = await client.discovery( + new URL(process.env.OPENID_ISSUER), + process.env.OPENID_CLIENT_ID, + clientMetadata, + undefined, + { + [client.customFetch]: customFetch, + }, + ); + } logger.info(`[openidStrategy] OpenID authentication configuration`, { usePKCE, diff --git a/api/test/cognito/discoveryUrl.property.test.js b/api/test/cognito/discoveryUrl.property.test.js new file mode 100644 index 00000000000..1c3191df47a --- /dev/null +++ b/api/test/cognito/discoveryUrl.property.test.js @@ -0,0 +1,113 @@ +/** + * Feature: cognito-login-migration, Property 2: Discovery URL construction. + * + * The discovery URL is the issuer joined with /.well-known/openid-configuration, + * i.e. new URL('.well-known/openid-configuration', issuerEndingWithSlash). The + * join is owned by openid-client; this asserts the contract the fork relies on. + */ + +const WELL_KNOWN = '.well-known/openid-configuration'; + +function resolveDiscoveryUrl(issuer) { + const base = new URL(issuer); + if (!base.pathname.endsWith('/')) { + base.pathname = `${base.pathname}/`; + } + return new URL(WELL_KNOWN, base).href; +} + +function expectedDiscoveryUrl(issuer) { + const u = new URL(issuer); + let path = u.pathname; + if (!path.endsWith('/')) { + path = `${path}/`; + } + return `${u.origin}${path}${WELL_KNOWN}`; +} + +function mulberry32(seed) { + let a = seed >>> 0; + return function () { + a |= 0; + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +function generateIssuer(rng) { + const pick = (arr) => arr[Math.floor(rng() * arr.length)]; + const scheme = pick(['https', 'http']); + const regions = ['us-east-1', 'us-west-2', 'eu-west-1', 'ap-southeast-2', 'eu-central-1']; + const hosts = [ + 'example.com', + 'auth.example.org', + 'login.internal', + 'ministack', + `cognito-idp.${pick(regions)}.amazonaws.com`, + `cognito-idp.${pick(regions)}.localhost.localstack.cloud`, + ]; + const host = pick(hosts); + const includePort = rng() < 0.4; + const port = includePort ? `:${pick(['4566', '9229', '8080', '443', '3000'])}` : ''; + const region = pick(regions); + const poolId = `${region}_${Math.floor(rng() * 1e8).toString(36).padStart(6, '0')}`; + const pathShapes = [ + () => '', + () => '/', + () => `/${poolId}`, + () => `/${poolId}/`, + () => `/oidc/${poolId}`, + () => `/oidc/${poolId}/`, + () => `/realms/${poolId}/protocol`, + () => `/realms/${poolId}/protocol/`, + () => `/a/b/c`, + () => `/a/b/c/`, + ]; + const path = pick(pathShapes)(); + return `${scheme}://${host}${port}${path}`; +} + +describe('Feature: cognito-login-migration, Property 2: Discovery URL construction', () => { + const ITERATIONS = 200; + const SEED = 0x2c0f1a7b; + + it('resolves /.well-known/openid-configuration for any generated issuer (>=100 iterations)', () => { + const rng = mulberry32(SEED); + let checked = 0; + for (let i = 0; i < ITERATIONS; i++) { + const issuer = generateIssuer(rng); + const resolved = resolveDiscoveryUrl(issuer); + const expected = expectedDiscoveryUrl(issuer); + expect(resolved).toBe(expected); + expect(resolved.endsWith(`/${WELL_KNOWN}`)).toBe(true); + const occurrences = resolved.split(WELL_KNOWN).length - 1; + expect(occurrences).toBe(1); + expect(resolved.startsWith(new URL(issuer).origin)).toBe(true); + const afterScheme = resolved.slice(resolved.indexOf('://') + 3); + expect(afterScheme).not.toContain('//'); + checked++; + } + expect(checked).toBe(ITERATIONS); + expect(checked).toBeGreaterThanOrEqual(100); + }); + + it('matches openid-client oidc-algorithm documented examples', () => { + expect(resolveDiscoveryUrl('https://example.com')).toBe( + 'https://example.com/.well-known/openid-configuration', + ); + expect(resolveDiscoveryUrl('https://example.com/pathname')).toBe( + 'https://example.com/pathname/.well-known/openid-configuration', + ); + }); + + it('is stable whether or not the issuer path carries a trailing slash', () => { + const withSlash = resolveDiscoveryUrl('https://cognito-idp.us-east-1.amazonaws.com/us-east-1_ABC123/'); + const withoutSlash = resolveDiscoveryUrl('https://cognito-idp.us-east-1.amazonaws.com/us-east-1_ABC123'); + expect(withSlash).toBe(withoutSlash); + expect(withSlash).toBe( + 'https://cognito-idp.us-east-1.amazonaws.com/us-east-1_ABC123/.well-known/openid-configuration', + ); + }); +}); diff --git a/api/test/cognito/loginOptions.test.js b/api/test/cognito/loginOptions.test.js new file mode 100644 index 00000000000..03decdb4622 --- /dev/null +++ b/api/test/cognito/loginOptions.test.js @@ -0,0 +1,114 @@ +/** + * Feature: cognito-login-migration: the Cognito OIDC login option is exposed + * when ALLOW_SOCIAL_LOGIN=true and the OPENID_* gate is satisfied. + * + * Scoped to ~/server/socialLogins so the routes-index module graph is not loaded. + */ + +const mockSessionMiddleware = jest.fn((req, res, next) => next()); +const mockPassportSessionMiddleware = jest.fn((req, res, next) => next()); +const mockSession = jest.fn(() => mockSessionMiddleware); +const mockPassportUse = jest.fn(); +const mockPassportSession = jest.fn(() => mockPassportSessionMiddleware); +const mockGetLogStores = jest.fn(() => 'openid-session-store'); +const mockOpenIdJwtLogin = jest.fn(() => 'openid-jwt-strategy'); +const mockSetupOpenId = jest.fn(); +const mockSetupSaml = jest.fn(); +const mockRegisterOpenIdWithRetry = jest.fn(async () => {}); +const mockShouldUseSecureCookie = jest.fn(() => true); +const mockMath = jest.fn((value, fallback) => (value == null || value === '' ? fallback : value)); + +const isEnabled = (value) => { + if (typeof value === 'boolean') { + return value; + } + if (typeof value === 'string') { + return value.toLowerCase().trim() === 'true'; + } + return false; +}; + +jest.mock('express-session', () => (...args) => mockSession(...args)); +jest.mock('passport', () => ({ + use: (...args) => mockPassportUse(...args), + session: (...args) => mockPassportSession(...args), +})); +jest.mock('librechat-data-provider', () => ({ + CacheKeys: { OPENID_SESSION: 'openid-session', SAML_SESSION: 'saml-session' }, +})); +jest.mock('@librechat/api', () => ({ + math: (...args) => mockMath(...args), + isEnabled: (value) => { + if (typeof value === 'boolean') { + return value; + } + if (typeof value === 'string') { + return value.toLowerCase().trim() === 'true'; + } + return false; + }, + shouldUseSecureCookie: (...args) => mockShouldUseSecureCookie(...args), + registerOpenIdWithRetry: (...args) => mockRegisterOpenIdWithRetry(...args), +})); +jest.mock('@librechat/data-schemas', () => ({ + DEFAULT_SESSION_EXPIRY: 900000, + logger: { error: jest.fn(), info: jest.fn(), warn: jest.fn() }, +})); +jest.mock('~/cache', () => ({ getLogStores: (...args) => mockGetLogStores(...args) })); +jest.mock('~/strategies', () => ({ + openIdJwtLogin: (...args) => mockOpenIdJwtLogin(...args), + facebookLogin: jest.fn(), + facebookAdminLogin: jest.fn(), + discordLogin: jest.fn(), + discordAdminLogin: jest.fn(), + setupOpenId: (...args) => mockSetupOpenId(...args), + googleLogin: jest.fn(), + googleAdminLogin: jest.fn(), + githubLogin: jest.fn(), + githubAdminLogin: jest.fn(), + appleLogin: jest.fn(), + appleAdminLogin: jest.fn(), + setupSaml: (...args) => mockSetupSaml(...args), +})); + +const configureSocialLogins = require('~/server/socialLogins'); + +const setPkceOpenIdEnv = () => { + process.env.OPENID_CLIENT_ID = 'ministack-client-id'; + process.env.OPENID_CLIENT_SECRET = ''; + process.env.OPENID_ISSUER = 'https://issuer.example.com'; + process.env.OPENID_SCOPE = 'openid profile email'; + process.env.OPENID_SESSION_SECRET = 'openid-session-secret'; + process.env.OPENID_USE_PKCE = 'true'; +}; + +describe('Feature: cognito-login-migration — Cognito login-option presence (Req 2.2)', () => { + const ORIGINAL_ENV = process.env; + + beforeEach(() => { + jest.clearAllMocks(); + process.env = {}; + mockSetupOpenId.mockResolvedValue({ issuer: 'https://issuer.example.com' }); + }); + + afterAll(() => { + process.env = ORIGINAL_ENV; + }); + + it('exposes the Cognito OIDC login option when ALLOW_SOCIAL_LOGIN=true and OPENID_* is configured', async () => { + process.env.ALLOW_SOCIAL_LOGIN = 'true'; + setPkceOpenIdEnv(); + const app = { use: jest.fn() }; + expect(isEnabled(process.env.ALLOW_SOCIAL_LOGIN)).toBe(true); + await configureSocialLogins(app); + expect(mockRegisterOpenIdWithRetry).toHaveBeenCalledTimes(1); + expect(app.use).toHaveBeenCalledWith(mockSessionMiddleware); + }); + + it('does not expose the OIDC login option when the OPENID_* gate is unmet', async () => { + process.env.ALLOW_SOCIAL_LOGIN = 'true'; + const app = { use: jest.fn() }; + await configureSocialLogins(app); + expect(mockRegisterOpenIdWithRetry).not.toHaveBeenCalled(); + }); +}); diff --git a/api/test/cognito/oidcActivation.property.test.js b/api/test/cognito/oidcActivation.property.test.js new file mode 100644 index 00000000000..8f55fee7d08 --- /dev/null +++ b/api/test/cognito/oidcActivation.property.test.js @@ -0,0 +1,144 @@ +/** + * Feature: cognito-login-migration, Property 1: OIDC activation matches the gating condition. + * + * OIDC is configured iff OPENID_ISSUER, OPENID_CLIENT_ID, OPENID_SCOPE and + * OPENID_SESSION_SECRET are all set AND (OPENID_USE_PKCE enabled OR + * OPENID_CLIENT_SECRET present). The predicate mirrors the guard in + * api/server/socialLogins.js and is checked against the real isEnabled. + */ + +const { isEnabled } = require('@librechat/api'); + +function oidcActivates(env) { + return Boolean( + env.OPENID_CLIENT_ID && + (isEnabled(env.OPENID_USE_PKCE) || env.OPENID_CLIENT_SECRET?.trim()) && + env.OPENID_ISSUER && + env.OPENID_SCOPE && + env.OPENID_SESSION_SECRET, + ); +} + +function specSaysActivate(env) { + const isSet = (v) => typeof v === 'string' && v !== ''; + const coreConfigured = + isSet(env.OPENID_ISSUER) && + isSet(env.OPENID_CLIENT_ID) && + isSet(env.OPENID_SCOPE) && + isSet(env.OPENID_SESSION_SECRET); + const pkceEnabled = isEnabled(env.OPENID_USE_PKCE); + const secretPresent = + typeof env.OPENID_CLIENT_SECRET === 'string' && env.OPENID_CLIENT_SECRET.trim() !== ''; + return coreConfigured && (pkceEnabled || secretPresent); +} + +const STRING_STATES = [undefined, '', ' ', 'set-value']; +const PKCE_STATES = [undefined, '', 'true', 'TRUE', 'True', 'false', 'no', 'yes', '1']; +const RELEVANT_KEYS = [ + 'OPENID_ISSUER', + 'OPENID_CLIENT_ID', + 'OPENID_SCOPE', + 'OPENID_SESSION_SECRET', + 'OPENID_CLIENT_SECRET', +]; + +function makeRng(seed) { + let a = seed >>> 0; + return function next() { + a |= 0; + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +function enumerateConfigs() { + const configs = []; + const total = STRING_STATES.length ** RELEVANT_KEYS.length; + for (let combo = 0; combo < total; combo++) { + let n = combo; + const base = {}; + for (const key of RELEVANT_KEYS) { + base[key] = STRING_STATES[n % STRING_STATES.length]; + n = Math.floor(n / STRING_STATES.length); + } + for (const pkce of PKCE_STATES) { + configs.push({ ...base, OPENID_USE_PKCE: pkce }); + } + } + return configs; +} + +function randomConfigs(count, seed) { + const rng = makeRng(seed); + const pick = (arr) => arr[Math.floor(rng() * arr.length)]; + const stringPool = [undefined, '', ' ', '\t', 'x', 'https://issuer.example', 'abc-123', ' padded ']; + const pkcePool = [undefined, '', 'true', 'TRUE', ' true ', 'false', '0', '1', 'enabled']; + const configs = []; + for (let i = 0; i < count; i++) { + const cfg = { OPENID_USE_PKCE: pick(pkcePool) }; + for (const key of RELEVANT_KEYS) { + cfg[key] = pick(stringPool); + } + configs.push(cfg); + } + return configs; +} + +describe('Feature: cognito-login-migration, Property 1: OIDC activation matches the gating condition', () => { + const configs = [...enumerateConfigs(), ...randomConfigs(256, 0x5eed)]; + + it('exceeds the 100-iteration property-test minimum', () => { + expect(configs.length).toBeGreaterThanOrEqual(100); + }); + + it('activates OIDC iff core vars are set AND (PKCE enabled OR client secret present)', () => { + let activatedCount = 0; + let notActivatedCount = 0; + for (const cfg of configs) { + const actual = oidcActivates(cfg); + const expected = specSaysActivate(cfg); + expect(actual).toBe(expected); + if (actual) { + activatedCount++; + } else { + notActivatedCount++; + } + } + expect(activatedCount).toBeGreaterThan(0); + expect(notActivatedCount).toBeGreaterThan(0); + }); + + describe('boundary examples pinning the gating condition', () => { + const core = { + OPENID_ISSUER: 'https://issuer.example', + OPENID_CLIENT_ID: 'client-id', + OPENID_SCOPE: 'openid profile email', + OPENID_SESSION_SECRET: 'session-secret', + }; + + it('activates for a public/PKCE client with no client secret', () => { + expect(oidcActivates({ ...core, OPENID_USE_PKCE: 'true', OPENID_CLIENT_SECRET: '' })).toBe(true); + }); + + it('activates for a confidential client via client secret when PKCE is off', () => { + expect(oidcActivates({ ...core, OPENID_USE_PKCE: 'false', OPENID_CLIENT_SECRET: 'shhh' })).toBe(true); + }); + + it('does NOT activate when neither PKCE is enabled nor a client secret is present', () => { + expect(oidcActivates({ ...core, OPENID_USE_PKCE: 'false', OPENID_CLIENT_SECRET: ' ' })).toBe(false); + }); + + it.each([ + ['OPENID_ISSUER'], + ['OPENID_CLIENT_ID'], + ['OPENID_SCOPE'], + ['OPENID_SESSION_SECRET'], + ])('does NOT activate when required var %s is missing', (missingKey) => { + const cfg = { ...core, OPENID_USE_PKCE: 'true', OPENID_CLIENT_SECRET: '' }; + delete cfg[missingKey]; + expect(oidcActivates(cfg)).toBe(false); + }); + }); +}); diff --git a/api/test/cognito/provisioningIdempotence.property.test.js b/api/test/cognito/provisioningIdempotence.property.test.js new file mode 100644 index 00000000000..b70319dad22 --- /dev/null +++ b/api/test/cognito/provisioningIdempotence.property.test.js @@ -0,0 +1,156 @@ +/** + * Feature: cognito-login-migration, Property 8: Provisioning is idempotent. + * + * Models the cognito-init service's list-then-create-by-name shell command + * (docker-compose.override.yml) as a JS function over a fake Cognito registry, + * and asserts that running it N>=1 times yields at most one pool and one app + * client per configured name. + */ + +class FakeCognito { + constructor() { + this.pools = []; + this.clientsByPool = {}; + this._seq = 0; + } + + _nextId(prefix) { + this._seq += 1; + return `${prefix}-${this._seq}`; + } + + resolvePoolIdByName(name) { + const match = this.pools.find((p) => p.Name === name); + return match ? match.Id : 'None'; + } + + createUserPool(name) { + const id = this._nextId('pool'); + this.pools.push({ Id: id, Name: name }); + this.clientsByPool[id] = []; + return id; + } + + resolveClientIdByName(poolId, clientName) { + const clients = this.clientsByPool[poolId] || []; + const match = clients.find((c) => c.ClientName === clientName); + return match ? match.ClientId : 'None'; + } + + createUserPoolClient(poolId, clientName) { + const id = this._nextId('client'); + if (!this.clientsByPool[poolId]) { + this.clientsByPool[poolId] = []; + } + this.clientsByPool[poolId].push({ ClientId: id, ClientName: clientName }); + return id; + } + + countPoolsNamed(name) { + return this.pools.filter((p) => p.Name === name).length; + } + + countClientsNamedInPool(poolId, name) { + return (this.clientsByPool[poolId] || []).filter((c) => c.ClientName === name).length; + } +} + +function provision(registry, poolName, clientName) { + let poolId = registry.resolvePoolIdByName(poolName); + if (poolId === 'None' || poolId === '' || poolId == null) { + poolId = registry.createUserPool(poolName); + } + let clientId = registry.resolveClientIdByName(poolId, clientName); + if (clientId === 'None' || clientId === '' || clientId == null) { + clientId = registry.createUserPoolClient(poolId, clientName); + } + return { poolId, clientId }; +} + +function makeRng(seed) { + let state = seed >>> 0; + return () => { + state ^= state << 13; + state ^= state >>> 17; + state ^= state << 5; + state >>>= 0; + return state / 0xffffffff; + }; +} + +function randInt(rng, min, max) { + return min + Math.floor(rng() * (max - min + 1)); +} + +function pick(rng, arr) { + return arr[randInt(rng, 0, arr.length - 1)]; +} + +function makeStartingState(rng, poolName, clientName) { + const registry = new FakeCognito(); + const unrelatedNames = ['other-pool', 'legacy-pool', 'staging-pool', 'librechat-dev']; + const scenario = randInt(rng, 0, 3); + if (scenario >= 1) { + const poolId = registry.createUserPool(poolName); + if (scenario >= 2) { + registry.createUserPoolClient(poolId, clientName); + } + } + const unrelatedCount = randInt(rng, 0, 3); + for (let i = 0; i < unrelatedCount; i += 1) { + const name = pick(rng, unrelatedNames); + const id = registry.createUserPool(name); + if (rng() < 0.5) { + registry.createUserPoolClient(id, pick(rng, ['app', 'admin-client', 'other-client'])); + } + } + return registry; +} + +describe('Feature: cognito-login-migration, Property 8: Provisioning is idempotent', () => { + const CONFIGS = [ + { poolName: 'plastic-ai-users', clientName: 'plastic-ai-web' }, + { poolName: 'plastic-ai-pool', clientName: 'plastic-ai-client' }, + { poolName: 'p', clientName: 'c' }, + ]; + + test('running provision one or more times yields at most one pool and one client per configured name', () => { + const ITERATIONS = 150; + let checked = 0; + for (let i = 0; i < ITERATIONS; i += 1) { + const rng = makeRng(0x9e3779b9 ^ (i * 2654435761)); + const { poolName, clientName } = pick(rng, CONFIGS); + const registry = makeStartingState(rng, poolName, clientName); + const unrelatedSnapshot = registry.pools.filter((p) => p.Name !== poolName).map((p) => p.Name); + const runCount = randInt(rng, 1, 6); + let last = null; + for (let r = 0; r < runCount; r += 1) { + last = provision(registry, poolName, clientName); + } + expect(registry.countPoolsNamed(poolName)).toBe(1); + const resolvedPoolId = registry.resolvePoolIdByName(poolName); + expect(last.poolId).toBe(resolvedPoolId); + expect(resolvedPoolId).not.toBe('None'); + expect(registry.countClientsNamedInPool(resolvedPoolId, clientName)).toBe(1); + expect(registry.resolveClientIdByName(resolvedPoolId, clientName)).toBe(last.clientId); + expect(last.clientId).not.toBe('None'); + const unrelatedAfter = registry.pools.filter((p) => p.Name !== poolName).map((p) => p.Name).sort(); + expect(unrelatedAfter).toEqual([...unrelatedSnapshot].sort()); + checked += 1; + } + expect(checked).toBe(ITERATIONS); + }); + + test('a second run creates nothing new after the first run provisions from empty', () => { + const { poolName, clientName } = CONFIGS[0]; + const registry = new FakeCognito(); + const first = provision(registry, poolName, clientName); + const poolsAfterFirst = registry.pools.length; + const clientsAfterFirst = (registry.clientsByPool[first.poolId] || []).length; + const second = provision(registry, poolName, clientName); + expect(second.poolId).toBe(first.poolId); + expect(second.clientId).toBe(first.clientId); + expect(registry.pools.length).toBe(poolsAfterFirst); + expect((registry.clientsByPool[first.poolId] || []).length).toBe(clientsAfterFirst); + }); +}); diff --git a/certs/documentdb/README.md b/certs/documentdb/README.md new file mode 100644 index 00000000000..8664ac581cb --- /dev/null +++ b/certs/documentdb/README.md @@ -0,0 +1,8 @@ +# Amazon DocumentDB CA bundle + +Place the AWS `global-bundle.pem` CA bundle in this directory before starting +LibreChat against Amazon DocumentDB. The Docker Compose override mounts this +directory read-only at `/app/certs/documentdb`. + +Do not commit certificates or database credentials. Download the current bundle +from the Amazon DocumentDB documentation and verify its source before use. diff --git a/client/src/hooks/Chat/useChatFunctions.ts b/client/src/hooks/Chat/useChatFunctions.ts index 18bb4fa29f8..5b190d3bf7c 100644 --- a/client/src/hooks/Chat/useChatFunctions.ts +++ b/client/src/hooks/Chat/useChatFunctions.ts @@ -27,6 +27,7 @@ import type { } from 'librechat-data-provider'; import type { SetterOrUpdater } from 'recoil'; import type { TAskFunction, ExtendedFile } from '~/common'; +import type { UiAction } from '~/lib/rum/actions'; import { logger, requestChatFocus, @@ -48,6 +49,7 @@ import { revealedQueuedTurnFamily } from '~/store/steer'; import store, { useGetEphemeralAgent } from '~/store'; import { startupConfigKey } from '~/data-provider'; import useUserKey from '~/hooks/Input/useUserKey'; +import { trackAction } from '~/lib/rum/actions'; import { useAuthContext } from '~/hooks'; /** A revalidating cache younger than this is locally authoritative (the run @@ -61,6 +63,30 @@ const logChatRequest = (request: Record) => { logger.log('====================================='); }; +/** `isResubmit`: an edited user turn is replayed as a plain send under an explicit parent. */ +const getSubmitAction = ({ + isContinued, + isRegenerate, + isEdited, + isResubmit, +}: { + isContinued: boolean; + isRegenerate: boolean; + isEdited: boolean; + isResubmit: boolean; +}): UiAction => { + if (isContinued) { + return 'message.continue'; + } + if (isEdited) { + return 'message.edit'; + } + if (isRegenerate) { + return 'message.regenerate'; + } + return isResubmit ? 'message.edit' : 'message.send'; +}; + const getAppendParentMessageId = ({ latestMessage, currentMessages, @@ -335,6 +361,12 @@ export default function useChatFunctions({ return false; } + const submitAction = getSubmitAction({ + isContinued, + isRegenerate, + isEdited, + isResubmit: parentMessageId != null, + }); const conversation = cloneDeep(immutableConversation); const latestCodeApprovalMode = getConversation()?.codeApprovalMode; const codeApprovalMode = @@ -797,6 +829,14 @@ export default function useChatFunctions({ ? current : withSubmittedCodeDecision(current, workspaceSubmission), ); + if (!compact) { + trackAction(submitAction, { + conversationId, + endpoint, + model: conversation?.model, + agentId: conversation?.agent_id, + }); + } setSubmissionStart(Date.now()); setSubmission(submission); logger.dir('message_stream', submission, { depth: null }); diff --git a/client/src/hooks/Chat/useChatHelpers.ts b/client/src/hooks/Chat/useChatHelpers.ts index 46eb08bc299..5e07e97d472 100644 --- a/client/src/hooks/Chat/useChatHelpers.ts +++ b/client/src/hooks/Chat/useChatHelpers.ts @@ -14,6 +14,7 @@ import { siblingIdxFamily, siblingKey } from '~/components/Chat/Messages/Thread/ import useChatFunctions from '~/hooks/Chat/useChatFunctions'; import useSteerConvert from '~/hooks/Chat/useSteerConvert'; import { resolveAbortSteerTarget } from '~/utils'; +import { trackAction } from '~/lib/rum/actions'; import useNewConvo from '~/hooks/useNewConvo'; import { getMessageCacheIds } from './cache'; import { useAbortCleanup } from './abort'; @@ -352,9 +353,10 @@ export default function useChatHelpers(index = 0, paramId?: string) { const handleStopGenerating = useCallback( (e: React.MouseEvent) => { e.preventDefault(); + trackAction('message.stop', { conversationId, endpoint: endpointType ?? endpoint }); stopGenerating(); }, - [stopGenerating], + [stopGenerating, conversationId, endpoint, endpointType], ); const handleRegenerate = useCallback( diff --git a/client/src/hooks/Chat/useNewChat.ts b/client/src/hooks/Chat/useNewChat.ts index 4f109400de8..586bb1c10cd 100644 --- a/client/src/hooks/Chat/useNewChat.ts +++ b/client/src/hooks/Chat/useNewChat.ts @@ -32,6 +32,7 @@ import { takeRetainedFileDeletions, } from '~/utils'; import { useGetFiles, useDeleteFilesMutation } from '~/data-provider'; +import { trackAction } from '~/lib/rum/actions'; import useNewConvo from '~/hooks/useNewConvo'; import store from '~/store'; @@ -462,6 +463,7 @@ export default function useNewChat({ ]), ); removeTabAttachmentPresence(discardedFileIds, index); + trackAction('conversation.new'); newConversation(); onNewChat?.(); }, [ diff --git a/client/src/hooks/Files/useFileHandling.ts b/client/src/hooks/Files/useFileHandling.ts index c498c7d2673..911ed81deb4 100644 --- a/client/src/hooks/Files/useFileHandling.ts +++ b/client/src/hooks/Files/useFileHandling.ts @@ -40,6 +40,7 @@ import useLocalize, { TranslationKeys } from '~/hooks/useLocalize'; import { useDelayedUploadToast } from './useDelayedUploadToast'; import { useChatContext } from '~/Providers/ChatContext'; import store, { ephemeralAgentByConvoId } from '~/store'; +import { trackAction } from '~/lib/rum/actions'; import useClientResize from './useClientResize'; import useUpdateFiles from './useUpdateFiles'; @@ -836,6 +837,9 @@ const useFileHandlingCore = (params: UseFileHandling | undefined, fileState: Fil if (!accepted && assignedFileId) { takeUploadRecovery(assignedFileId); } + if (accepted) { + trackAction('file.upload', { count: fileList.length, toolResource: _toolResource }); + } return accepted; } catch (error) { if (assignedFileId) { diff --git a/client/src/hooks/Input/useSelectMention.ts b/client/src/hooks/Input/useSelectMention.ts index 62bd9fe13fe..b410c04daea 100644 --- a/client/src/hooks/Input/useSelectMention.ts +++ b/client/src/hooks/Input/useSelectMention.ts @@ -17,6 +17,7 @@ import { getConvoSwitchLogic, logger, } from '~/utils'; +import { trackAction } from '~/lib/rum/actions'; import { useDefaultConvo } from '~/hooks'; import store from '~/store'; @@ -68,6 +69,7 @@ export default function useSelectMention({ if (!newEndpoint) { return; } + trackAction('model.switch', { endpoint: newEndpoint, spec: spec.name, model: preset.model }); const { template, @@ -151,6 +153,13 @@ export default function useSelectMention({ return; } + trackAction(kwargs.agent_id ? 'agent.switch' : 'model.switch', { + endpoint: newEndpoint, + model: kwargs.model, + agentId: kwargs.agent_id, + assistantId: kwargs.assistant_id, + }); + const conversation = getConversation(); const { diff --git a/client/src/hooks/Messages/useCopyToClipboard.ts b/client/src/hooks/Messages/useCopyToClipboard.ts index ca68595e070..9ca20c55758 100644 --- a/client/src/hooks/Messages/useCopyToClipboard.ts +++ b/client/src/hooks/Messages/useCopyToClipboard.ts @@ -12,6 +12,7 @@ import { INVALID_CITATION_REGEX, } from '~/utils/citations'; import { markdownToHtml } from '~/utils/richtext'; +import { trackAction } from '~/lib/rum/actions'; import store from '~/store'; type Source = { @@ -265,7 +266,18 @@ export function useCopyMessageToClipboard({ variant, ]); - return useCopyToClipboard({ ...source, richText }); + const copyToClipboard = useCopyToClipboard({ ...source, richText }); + + return useCallback( + (setIsCopied: React.Dispatch>): boolean => { + const copied = copyToClipboard(setIsCopied); + if (copied) { + trackAction('message.copy', { fromUser: isCreatedByUser === true }); + } + return copied; + }, + [copyToClipboard, isCreatedByUser], + ); } /** diff --git a/client/src/hooks/Messages/useMessageActions.tsx b/client/src/hooks/Messages/useMessageActions.tsx index 6f5a56b5b7a..9398a704609 100644 --- a/client/src/hooks/Messages/useMessageActions.tsx +++ b/client/src/hooks/Messages/useMessageActions.tsx @@ -16,6 +16,7 @@ import type { TMessageProps } from '~/common'; import { useCopyMessageToClipboard, hasCopyableText } from './useCopyToClipboard'; import { useAssistantsMapContext, useAgentsMapContext } from '~/Providers'; import { useAuthContext } from '~/hooks/AuthContext'; +import { trackAction } from '~/lib/rum/actions'; import { useGetAddedConvo } from '~/hooks/Chat'; import { useLocalize } from '~/hooks'; import store from '~/store'; @@ -158,6 +159,10 @@ export default function useMessageActions(props: TMessageActions) { const payload: TUpdateFeedbackRequest = { feedback: newFeedback ? toMinimalFeedback(newFeedback) : undefined, }; + trackAction('message.feedback', { + conversationId: conversation?.conversationId, + rating: newFeedback?.rating ?? 'cleared', + }); feedbackMutation.mutate(payload, { onSuccess: (data) => { @@ -177,7 +182,7 @@ export default function useMessageActions(props: TMessageActions) { }, }); }, - [feedbackMutation], + [feedbackMutation, conversation?.conversationId], ); return { diff --git a/client/src/lib/rum/actions.spec.ts b/client/src/lib/rum/actions.spec.ts new file mode 100644 index 00000000000..844b15d5534 --- /dev/null +++ b/client/src/lib/rum/actions.spec.ts @@ -0,0 +1,64 @@ +import { setActionClient, trackAction } from './actions'; + +describe('rum actions', () => { + const addAction = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + setActionClient(undefined); + }); + + it('does nothing until a client is set', () => { + expect(() => trackAction('message.send', { model: 'model-a' })).not.toThrow(); + expect(addAction).not.toHaveBeenCalled(); + }); + + it('forwards the action name and drops empty attributes', () => { + setActionClient({ addAction }); + + trackAction('message.send', { + conversationId: 'convo-1', + endpoint: 'bedrock', + model: '', + agentId: undefined, + spec: null, + count: 0, + temporary: false, + }); + + expect(addAction).toHaveBeenCalledTimes(1); + expect(addAction).toHaveBeenCalledWith('message.send', { + conversationId: 'convo-1', + endpoint: 'bedrock', + count: 0, + temporary: false, + }); + }); + + it('forwards an action without attributes', () => { + setActionClient({ addAction }); + + trackAction('conversation.new'); + + expect(addAction).toHaveBeenCalledWith('conversation.new', undefined); + }); + + it('stops forwarding once the client is cleared', () => { + setActionClient({ addAction }); + setActionClient(undefined); + + trackAction('message.copy'); + + expect(addAction).not.toHaveBeenCalled(); + }); + + it('swallows client failures', () => { + setActionClient({ + addAction: () => { + throw new Error('exporter unavailable'); + }, + }); + + expect(() => trackAction('message.stop')).not.toThrow(); + }); +}); diff --git a/client/src/lib/rum/actions.ts b/client/src/lib/rum/actions.ts new file mode 100644 index 00000000000..3880a3b9ed5 --- /dev/null +++ b/client/src/lib/rum/actions.ts @@ -0,0 +1,51 @@ +import type { HyperDXActionClient, RumActionAttributes } from './diagnostics'; + +/** Curated user actions recorded as named RUM events. */ +export const UI_ACTIONS = [ + 'message.send', + 'message.stop', + 'message.regenerate', + 'message.continue', + 'message.edit', + 'message.copy', + 'message.feedback', + 'model.switch', + 'agent.switch', + 'file.upload', + 'conversation.new', +] as const; + +export type UiAction = (typeof UI_ACTIONS)[number]; + +/** Identifiers and enums only: never message text, file names or UI labels. */ +export type UiActionAttributes = Record; + +let actionClient: HyperDXActionClient | undefined; + +export function setActionClient(client: HyperDXActionClient | undefined): void { + actionClient = client; +} + +function definedAttributes(attributes: UiActionAttributes): RumActionAttributes { + const output: RumActionAttributes = {}; + for (const key in attributes) { + const value = attributes[key]; + if (value != null && value !== '') { + output[key] = value; + } + } + return output; +} + +/** No-op until RUM has initialized, so call sites need no feature check. */ +export function trackAction(name: UiAction, attributes?: UiActionAttributes): void { + if (!actionClient) { + return; + } + + try { + actionClient.addAction(name, attributes ? definedAttributes(attributes) : undefined); + } catch { + /* Diagnostics should never affect app behavior. */ + } +} diff --git a/client/src/lib/rum/useRum.ts b/client/src/lib/rum/useRum.ts index 62e96ddba48..78efa08a9e2 100644 --- a/client/src/lib/rum/useRum.ts +++ b/client/src/lib/rum/useRum.ts @@ -10,6 +10,7 @@ import { } from './diagnostics'; import { useGetStartupConfig } from '~/data-provider'; import { useAuthContext } from '~/hooks/AuthContext'; +import { setActionClient } from './actions'; import { normalizeRumPath } from './routes'; import { getClientBuildId } from './build'; @@ -222,6 +223,7 @@ export default function useRum(): void { }); hyperDxRef.current = HyperDX; + setActionClient(HyperDX); initializedKeyRef.current = initKey; HyperDX.setGlobalAttributes(buildGlobalAttributes(user, config, routeRef.current)); startRumDiagnostics(HyperDX, () => routeRef.current); diff --git a/docker-compose.override.yml b/docker-compose.override.yml new file mode 100644 index 00000000000..a052ca61feb --- /dev/null +++ b/docker-compose.override.yml @@ -0,0 +1,631 @@ +# Please consult our docs for more info: https://www.librechat.ai/docs/configuration/docker_override + +# TO USE THIS FILE, FIRST UNCOMMENT THE LINE ('services:') + +# THEN UNCOMMENT ONLY THE SECTION OR SECTIONS CONTAINING THE CHANGES YOU WANT TO APPLY +# SAVE THIS FILE AS 'docker-compose.override.yaml' +# AND USE THE 'docker compose build' & 'docker compose up -d' COMMANDS AS YOU WOULD NORMALLY DO + +# WARNING: YOU CAN ONLY SPECIFY EVERY SERVICE NAME ONCE (api, mongodb, meilisearch, ...) +# IF YOU WANT TO OVERRIDE MULTIPLE SETTINGS IN ONE SERVICE YOU WILL HAVE TO EDIT ACCORDINGLY + +# EXAMPLE: if you want to use the config file and the latest numbered release docker image the result will be: + +services: + api: + image: librechat + build: + context: . + target: node + volumes: + - type: bind + source: ${PWD}/librechat.yaml + target: /app/librechat.yaml + - type: bind + source: ./certs/documentdb + target: /app/certs/documentdb + read_only: true + - type: bind + source: ${AWS_PROFILE_DIR} + target: /home/node/.aws + read_only: true + # Amazon DocumentDB is an external managed service, so the API must not wait + # for the local MongoDB compatibility baseline. + # depends_on: + # mongodb: + # condition: service_started + depends_on: !override + rag_api: + condition: service_started + ministack: + condition: service_healthy + s3-init: + condition: service_completed_successfully + environment: + # Override the upstream local MongoDB URI with an Amazon DocumentDB URI + # from .env. The URI must include tls=true and retryWrites=false. + # MONGO_URI: mongodb://mongodb:27017/LibreChat + MONGO_URI: ${MONGO_URI} + # Leave empty for local MongoDB; set to /app/certs/documentdb/global-bundle.pem for DocumentDB. + MONGO_TLS_CA_FILE: ${MONGO_TLS_CA_FILE:-} + RAG_API_URL: http://rag_api:${RAG_PORT:-8000} + AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID} + AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY} + AWS_REGION: ${AWS_REGION} + AWS_DEFAULT_REGION: ${AWS_REGION} + AWS_SDK_LOAD_CONFIG: "1" + AWS_BUCKET_NAME: plastic-ai-files + AWS_ENDPOINT_URL: http://ministack:${MINISTACK_PORT} + # The global endpoint keeps local AWS services on ministack. Bedrock uses + # its explicit librechat.yaml baseURL, while profile/SSO resolution must + # reach real AWS instead of inheriting the global ministack endpoint. + AWS_ENDPOINT_URL_SSO: https://portal.sso.${AWS_REGION}.amazonaws.com + AWS_ENDPOINT_URL_SSO_OIDC: https://oidc.${AWS_REGION}.amazonaws.com + AWS_ENDPOINT_URL_STS: https://sts.${AWS_REGION}.amazonaws.com + AWS_FORCE_PATH_STYLE: "true" + BEDROCK_AWS_DEFAULT_REGION: ${BEDROCK_AWS_DEFAULT_REGION} + BEDROCK_AWS_PROFILE: ${BEDROCK_AWS_PROFILE} + HOME: /home/node + NO_PROXY: ${NO_PROXY:-localhost,127.0.0.1,::1},${no_proxy:-},meilisearch,chat-meilisearch,rag_api,vectordb,ministack,ollama,langfuse-web,otel-collector,host.docker.internal + no_proxy: ${no_proxy:-localhost,127.0.0.1,::1},${NO_PROXY:-},meilisearch,chat-meilisearch,rag_api,vectordb,ministack,ollama,langfuse-web,otel-collector,host.docker.internal + networks: + - default + - infra-network + + ollama: + image: ollama/ollama:latest + environment: + OLLAMA_HOST: 0.0.0.0:${OLLAMA_PORT} + ports: + - "${OLLAMA_PORT}:${OLLAMA_PORT}" + volumes: + - type: bind + source: ${OLLAMA_MODELS_PATH} + target: /root/.ollama/models + healthcheck: + test: ["CMD", "ollama", "list"] + interval: 5s + timeout: 5s + retries: 3 + networks: + - infra-network + + ollama-pull: + image: ollama/ollama:latest + environment: + OLLAMA_HOST: http://ollama:${OLLAMA_PORT} + command: ["pull", "${OLLAMA_MODEL}"] + depends_on: + ollama: + condition: service_healthy + networks: + - infra-network + + ministack: + image: ministackorg/ministack:1.5.16 + container_name: infra_ministack + ports: + - "${MINISTACK_PORT}:${MINISTACK_PORT}" + depends_on: + ollama-pull: + condition: service_completed_successfully + environment: + LAMBDA_EXECUTOR: docker + DOCKER_NETWORK: ${COMPOSE_PROJECT_NAME}_infra-network + GATEWAY_PORT: ${MINISTACK_PORT} + AWS_DEFAULT_REGION: ${AWS_REGION} + MINISTACK_BEDROCK_PROXY_URL: http://ollama:${OLLAMA_PORT} + MINISTACK_BEDROCK_PROXY_TIMEOUT_SECONDS: 120 + PERSIST_STATE: "1" + STATE_DIR: /var/lib/ministack/state + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - type: bind + source: ${MINISTACK_STATE_PATH} + target: /var/lib/ministack/state + healthcheck: + # test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:${MINISTACK_PORT}/_ministack/health"] + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:${MINISTACK_PORT}/_ministack/health')"] + interval: 5s + timeout: 5s + retries: 20 + networks: + - infra-network + + s3-init: + image: amazon/aws-cli:2.36.44 + entrypoint: ["/bin/sh", "-c"] + command: + - | + until aws --endpoint-url "http://ministack:${MINISTACK_PORT}" s3api list-buckets >/dev/null 2>&1; do sleep 2; done + aws --endpoint-url "http://ministack:${MINISTACK_PORT}" s3api head-bucket --bucket plastic-ai-files >/dev/null 2>&1 || + aws --endpoint-url "http://ministack:${MINISTACK_PORT}" s3api create-bucket --bucket plastic-ai-files + aws --endpoint-url "http://ministack:${MINISTACK_PORT}" s3api head-bucket --bucket plastic-ai-langfuse >/dev/null 2>&1 || + aws --endpoint-url "http://ministack:${MINISTACK_PORT}" s3api create-bucket --bucket plastic-ai-langfuse + environment: + AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID} + AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY} + AWS_DEFAULT_REGION: ${AWS_REGION} + depends_on: + ministack: + condition: service_healthy + restart: "no" + networks: + - infra-network + + # cognito-init: idempotently provisions the ministack Cognito User Pool + App Client for + # OIDC login. Resolves pool/client by name (list-then-create) like the siblings above. + # ministack local-dev caveats (hardcoded confirmation codes, in-memory MFA, browser-vs- + # container hosted-UI reachability) are documented in MIGRATION_ROADMAP.md Part B.2. + cognito-init: + image: amazon/aws-cli:2.36.44 + entrypoint: ["/bin/sh", "-c"] + command: + - | + until aws --endpoint-url "http://ministack:${MINISTACK_PORT}" cognito-idp list-user-pools --max-results 60 >/dev/null 2>&1; do sleep 2; done + pool_id=$$(aws --endpoint-url "http://ministack:${MINISTACK_PORT}" cognito-idp list-user-pools --max-results 60 \ + --query "UserPools[?Name=='${COGNITO_POOL_NAME}'].Id | [0]" --output text) + if [ "$$pool_id" = "None" ] || [ -z "$$pool_id" ]; then + pool_id=$$(aws --endpoint-url "http://ministack:${MINISTACK_PORT}" cognito-idp create-user-pool \ + --pool-name "${COGNITO_POOL_NAME}" --query 'UserPool.Id' --output text) + fi + client_id=$$(aws --endpoint-url "http://ministack:${MINISTACK_PORT}" cognito-idp list-user-pool-clients \ + --user-pool-id "$$pool_id" --max-results 60 \ + --query "UserPoolClients[?ClientName=='${COGNITO_CLIENT_NAME}'].ClientId | [0]" --output text) + if [ "$$client_id" = "None" ] || [ -z "$$client_id" ]; then + aws --endpoint-url "http://ministack:${MINISTACK_PORT}" cognito-idp create-user-pool-client \ + --user-pool-id "$$pool_id" \ + --client-name "${COGNITO_CLIENT_NAME}" \ + --no-generate-secret \ + --allowed-o-auth-flows code \ + --allowed-o-auth-scopes openid profile email \ + --allowed-o-auth-flows-user-pool-client \ + --supported-identity-providers COGNITO \ + --callback-urls "${OPENID_CALLBACK_URL_ABSOLUTE:-http://localhost:3080/oauth/openid/callback}" \ + >/dev/null + fi + environment: + AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID} + AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY} + AWS_DEFAULT_REGION: ${AWS_REGION} + depends_on: + ministack: + condition: service_healthy + restart: "no" + networks: + - infra-network + + mongodb: + # MongoDB is only a local compatibility baseline. It is not an Amazon + # DocumentDB emulator and cannot validate engine-specific behavior. + command: mongod --noauth --replSet rs0 --bind_ip_all + healthcheck: + test: + - CMD-SHELL + - >- + mongosh --quiet --eval "try { rs.status().ok } catch (error) { + rs.initiate({_id:'rs0',members:[{_id:0,host:'mongodb:27017'}]}).ok }" + | grep 1 + interval: 5s + timeout: 5s + retries: 20 + profiles: + - mongodb-compat + - legacy-mongodb + + # --------------------------------------------------------------------------- + # Observability (profile `observability`): Langfuse for GenAI traces and + # HyperDX for browser activity, on one shared ClickHouse. The services mirror + # terraform/{clickhouse,langfuse,hyperdx}.tf so the AWS wiring can be + # exercised locally. The api does not depend on them: exports fail open. + # + # docker compose --profile observability up -d + # Langfuse http://localhost:3100 (LANGFUSE_PORT) HyperDX http://localhost:8080 (HYPERDX_PORT) + # --------------------------------------------------------------------------- + clickhouse: + image: clickhouse/clickhouse-server:25.12 + user: "101:101" + environment: + CLICKHOUSE_DB: default + CLICKHOUSE_USER: ${CLICKHOUSE_USER} + CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD} + HYPERDX_CLICKHOUSE_PASSWORD: ${HYPERDX_CLICKHOUSE_PASSWORD} + volumes: + - clickhouse-data:/var/lib/clickhouse + - type: bind + source: ./otel/clickhouse/hyperdx.xml + target: /etc/clickhouse-server/users.d/hyperdx.xml + read_only: true + healthcheck: + test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:8123/ping || exit 1"] + interval: 5s + timeout: 5s + retries: 20 + profiles: + - observability + networks: + - infra-network + + langfuse-postgres: + image: postgres:17 + environment: + POSTGRES_USER: langfuse + POSTGRES_PASSWORD: ${LANGFUSE_POSTGRES_PASSWORD} + POSTGRES_DB: langfuse + TZ: UTC + PGTZ: UTC + volumes: + - langfuse-postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U langfuse"] + interval: 3s + timeout: 3s + retries: 20 + profiles: + - observability + networks: + - infra-network + + langfuse-redis: + image: redis:7 + # Langfuse queues must never be evicted. + command: ["--requirepass", "${LANGFUSE_REDIS_AUTH}", "--maxmemory-policy", "noeviction"] + healthcheck: + test: ["CMD-SHELL", "redis-cli -a \"$$REDIS_AUTH\" --no-auth-warning ping | grep PONG"] + interval: 3s + timeout: 5s + retries: 20 + environment: + REDIS_AUTH: ${LANGFUSE_REDIS_AUTH} + profiles: + - observability + networks: + - infra-network + + langfuse-worker: + image: docker.langfuse.com/langfuse/langfuse-worker:4 + environment: &langfuse-environment + NEXTAUTH_URL: http://localhost:${LANGFUSE_PORT:-3100} + DATABASE_URL: postgresql://langfuse:${LANGFUSE_POSTGRES_PASSWORD}@langfuse-postgres:5432/langfuse + SALT: ${LANGFUSE_SALT} + ENCRYPTION_KEY: ${LANGFUSE_ENCRYPTION_KEY} + TELEMETRY_ENABLED: "false" + CLICKHOUSE_MIGRATION_URL: clickhouse://clickhouse:9000 + CLICKHOUSE_URL: http://clickhouse:8123 + CLICKHOUSE_USER: ${CLICKHOUSE_USER} + CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD} + CLICKHOUSE_CLUSTER_ENABLED: "false" + REDIS_HOST: langfuse-redis + REDIS_PORT: "6379" + REDIS_AUTH: ${LANGFUSE_REDIS_AUTH} + # Blob storage is the fork's S3 emulator; on AWS it is a real bucket. + LANGFUSE_S3_EVENT_UPLOAD_BUCKET: plastic-ai-langfuse + LANGFUSE_S3_EVENT_UPLOAD_REGION: ${AWS_REGION} + LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID} + LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY} + LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT: http://ministack:${MINISTACK_PORT} + LANGFUSE_S3_EVENT_UPLOAD_FORCE_PATH_STYLE: "true" + LANGFUSE_S3_EVENT_UPLOAD_PREFIX: events/ + LANGFUSE_S3_MEDIA_UPLOAD_BUCKET: plastic-ai-langfuse + LANGFUSE_S3_MEDIA_UPLOAD_REGION: ${AWS_REGION} + LANGFUSE_S3_MEDIA_UPLOAD_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID} + LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY} + LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT: http://ministack:${MINISTACK_PORT} + LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE: "true" + LANGFUSE_S3_MEDIA_UPLOAD_PREFIX: media/ + depends_on: &langfuse-depends-on + langfuse-postgres: + condition: service_healthy + langfuse-redis: + condition: service_healthy + clickhouse: + condition: service_healthy + s3-init: + condition: service_completed_successfully + profiles: + - observability + networks: + - infra-network + + langfuse-web: + image: docker.langfuse.com/langfuse/langfuse:4 + ports: + - "${LANGFUSE_PORT:-3100}:3000" + environment: + <<: *langfuse-environment + NEXTAUTH_SECRET: ${LANGFUSE_NEXTAUTH_SECRET} + AUTH_DISABLE_SIGNUP: "true" + # Headless bootstrap: one organisation, one project, one admin, and the API + # key pair the app exports traces with (LANGFUSE_PUBLIC_KEY/SECRET_KEY). + LANGFUSE_INIT_ORG_ID: plastic-ai + LANGFUSE_INIT_ORG_NAME: plastic-ai + LANGFUSE_INIT_PROJECT_ID: plastic-ai + LANGFUSE_INIT_PROJECT_NAME: plastic-ai + LANGFUSE_INIT_PROJECT_PUBLIC_KEY: ${LANGFUSE_PUBLIC_KEY} + LANGFUSE_INIT_PROJECT_SECRET_KEY: ${LANGFUSE_SECRET_KEY} + LANGFUSE_INIT_USER_EMAIL: ${LANGFUSE_INIT_USER_EMAIL} + LANGFUSE_INIT_USER_NAME: admin + LANGFUSE_INIT_USER_PASSWORD: ${LANGFUSE_INIT_USER_PASSWORD} + depends_on: *langfuse-depends-on + profiles: + - observability + networks: + - infra-network + + # HyperDX keeps accounts, dashboards and saved searches here; events go to ClickHouse. + hyperdx-mongo: + image: mongo:5.0.32-focal + volumes: + - hyperdx-mongo-data:/data/db + profiles: + - observability + networks: + - infra-network + + hyperdx: + image: docker.hyperdx.io/hyperdx/hyperdx:2 + ports: + - "${HYPERDX_PORT:-8080}:8080" + # DEFAULT_SOURCES: the tables HyperDX reads, shared with terraform/hyperdx.tf. + env_file: + - ./otel/hyperdx/sources.env + environment: + FRONTEND_URL: http://localhost:${HYPERDX_PORT:-8080} + MONGO_URI: mongodb://hyperdx-mongo:27017/hyperdx + USAGE_STATS_ENABLED: "false" + # Applied when the first account registers; registration closes after it. + DEFAULT_CONNECTIONS: '[{"name":"ClickHouse","host":"http://clickhouse:8123","username":"hyperdx","password":"${HYPERDX_CLICKHOUSE_PASSWORD}"}]' + depends_on: + clickhouse: + condition: service_healthy + hyperdx-mongo: + condition: service_started + profiles: + - observability + networks: + - infra-network + + # One-shot: adds the usage timeline source (the analytics.timeline view), its + # saved search and the dashboards in otel/hyperdx/provision to every HyperDX + # team. With no account registered yet it changes nothing, so after + # registering run `docker compose run --rm hyperdx-provision`. Mirrors the + # hyperdx-provision task in terraform/hyperdx.tf. + hyperdx-provision: + image: docker.hyperdx.io/hyperdx/hyperdx:2 + entrypoint: ["node", "/provision/provision.js"] + working_dir: /app/packages/api + environment: + MONGO_URI: mongodb://hyperdx-mongo:27017/hyperdx + HYPERDX_PROVISION_DIR: /provision + volumes: + - type: bind + source: ./otel/hyperdx/provision + target: /provision + read_only: true + depends_on: + hyperdx: + condition: service_started + clickhouse-analytics-init: + condition: service_completed_successfully + restart: "no" + profiles: + - observability + networks: + - infra-network + + # Receives the browser telemetry the api forwards from /api/rum. It runs + # standalone (no OpAMP), so the shared token is what guards its OTLP port. + otel-collector: + image: docker.clickhouse.com/clickhouse/clickstack-otel-collector:2 + environment: + CLICKHOUSE_ENDPOINT: tcp://clickhouse:9000?dial_timeout=10s + CLICKHOUSE_USER: hyperdx + CLICKHOUSE_PASSWORD: ${HYPERDX_CLICKHOUSE_PASSWORD} + HYPERDX_OTEL_EXPORTER_CLICKHOUSE_DATABASE: hyperdx + OTLP_AUTH_TOKEN: ${RUM_PROXY_AUTHORIZATION} + depends_on: + clickhouse: + condition: service_healthy + profiles: + - observability + networks: + - infra-network + + # One-shot: creates the content-free `analytics` views + # (otel/clickhouse/analytics.sql) that HyperDX's usage timeline reads, once + # Langfuse and the collector have created their tables. Mirrors + # terraform/clickhouse.tf. + clickhouse-analytics-init: + image: clickhouse/clickhouse-server:25.12 + entrypoint: ["/bin/sh", "/analytics-init.sh"] + environment: + CLICKHOUSE_HOST: clickhouse + CLICKHOUSE_USER: ${CLICKHOUSE_USER} + CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD} + ANALYTICS_SQL_FILE: /analytics.sql + volumes: + - type: bind + source: ./otel/clickhouse/analytics-init.sh + target: /analytics-init.sh + read_only: true + - type: bind + source: ./otel/clickhouse/analytics.sql + target: /analytics.sql + read_only: true + depends_on: + clickhouse: + condition: service_healthy + restart: "no" + profiles: + - observability + networks: + - infra-network + +networks: + infra-network: + +volumes: + clickhouse-data: + langfuse-postgres-data: + hyperdx-mongo-data: + +# --------------------------------------------------- + +# services: + +# # USE LIBRECHAT CONFIG FILE +# api: +# volumes: +# - type: bind +# source: ./librechat.yaml +# target: /app/librechat.yaml + +# # LOCAL BUILD +# api: +# image: librechat +# build: +# context: . +# target: node + +# # BUILD FROM LATEST IMAGE +# api: +# image: registry.librechat.ai/danny-avila/librechat-dev:latest + +# # BUILD FROM LATEST IMAGE (NUMBERED RELEASE) +# api: +# image: registry.librechat.ai/danny-avila/librechat:latest + +# # BUILD FROM LATEST API IMAGE +# api: +# image: registry.librechat.ai/danny-avila/librechat-dev-api:latest + +# # BUILD FROM LATEST API IMAGE (NUMBERED RELEASE) +# api: +# image: registry.librechat.ai/danny-avila/librechat-api:latest + +# # ADD SAML CERT FILE +# api: +# volumes: +# - type: bind +# source: ./your_cert.pem +# target: /app/your_cert.pem + +# # ADD MONGO-EXPRESS +# mongo-express: +# image: mongo-express +# container_name: mongo-express +# environment: +# ME_CONFIG_MONGODB_SERVER: mongodb +# ME_CONFIG_BASICAUTH_USERNAME: admin +# ME_CONFIG_BASICAUTH_PASSWORD: password +# ports: +# - '8081:8081' +# depends_on: +# - mongodb +# restart: always + +# # USE MONGODB V4.4.18 - FOR OLDER CPU WITHOUT AVX SUPPORT +# mongodb: +# image: mongo:4.4.18 + +# # DISABLE THE MONGODB CONTAINER - YOU NEED TO SET AN ALTERNATIVE MONGODB URI IN THE .ENV FILE +# api: +# environment: +# - MONGO_URI=${MONGO_URI} +# mongodb: +# image: tianon/true +# command: "" +# entrypoint: "" + +# # EXPOSE MONGODB PORTS - USE CAREFULLY, THIS MAKES YOUR DATABASE VULNERABLE TO ATTACKS +# mongodb: +# ports: +# - 27018:27017 + +# # DISABLE MEILISEARCH +# meilisearch: +# profiles: +# - donotstart + +# # EXPOSE MEILISEARCH PORTS - DO NOT USE THE DEFAULT VALUE FOR THE MASTER KEY IF YOU DO THIS +# meilisearch: +# ports: +# - 7700:7700 + +# # USE RAG API IMAGE WITH LOCAL EMBEDDINGS SUPPORT +# rag_api: +# image: registry.librechat.ai/danny-avila/librechat-rag-api-dev:latest +# # For Linux user: +# extra_hosts: +# - "host.docker.internal:host-gateway" + +# # ADD OLLAMA +# ollama: +# image: ollama/ollama:latest +# deploy: +# resources: +# reservations: +# devices: +# - driver: nvidia +# capabilities: [compute, utility] +# ports: +# - "11434:11434" +# volumes: +# - ./ollama:/root/.ollama + +# # ADD LITELLM BASIC - NEED TO CONFIGURE litellm-config.yaml, ONLY NEED ENV TO ENABLE REDIS FOR CACHING OR LANGFUSE FOR MONITORING +# litellm: +# image: ghcr.io/berriai/litellm:main-latest +# volumes: +# - ./litellm/litellm-config.yaml:/app/config.yaml +# - ./litellm/application_default_credentials.json:/app/application_default_credentials.json # only if using Google Vertex +# ports: +# - "4000:8000" +# command: [ "--config", "/app/config.yaml", "--port", "8000", "--num_workers", "8" ] +# environment: +# OPENAI_API_KEY: none ## needs to be set if ollama's openai api compatibility is used +# GOOGLE_APPLICATION_CREDENTIALS: /app/application_default_credentials.json ## only if using Google Vertex +# REDIS_HOST: redis +# REDIS_PORT: 6379 +# REDIS_PASSWORD: RedisChangeMe +# LANGFUSE_PUBLIC_KEY: pk-lf-RandomStringFromLangfuseWebInterface +# LANGFUSE_SECRET_KEY: sk-lf-RandomStringFromLangfuseWebInterface +# LANGFUSE_HOST: http://langfuse-server:3000 + +# # ADD LITELLM CACHING +# redis: +# image: redis:7-alpine +# command: +# - sh +# - -c # this is to evaluate the $REDIS_PASSWORD from the env +# - redis-server --appendonly yes --requirepass $$REDIS_PASSWORD ## $$ because of docker-compose +# environment: +# REDIS_PASSWORD: RedisChangeMe +# volumes: +# - ./redis:/data + +# # ADD LITELLM MONITORING +# langfuse-server: +# image: ghcr.io/langfuse/langfuse:latest +# depends_on: +# - db +# ports: +# - "3000:3000" +# environment: +# - NODE_ENV=production +# - DATABASE_URL=postgresql://postgres:PostgresChangeMe@db:5432/postgres +# - NEXTAUTH_SECRET=ChangeMe +# - SALT=ChangeMe +# - NEXTAUTH_URL=http://localhost:3000 +# - TELEMETRY_ENABLED=${TELEMETRY_ENABLED:-true} +# - NEXT_PUBLIC_SIGN_UP_DISABLED=${NEXT_PUBLIC_SIGN_UP_DISABLED:-false} +# - LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES=${LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES:-false} +# db: +# image: postgres +# restart: always +# environment: +# - POSTGRES_USER=postgres +# - POSTGRES_PASSWORD=PostgresChangeMe +# - POSTGRES_DB=postgres +# volumes: +# - ./postgres:/var/lib/postgresql/data diff --git a/docker-compose.override.yml.example b/docker-compose.override.yml.example index 3b5159bf1c0..8cfa26553c4 100644 --- a/docker-compose.override.yml.example +++ b/docker-compose.override.yml.example @@ -15,7 +15,7 @@ # api: # volumes: # - type: bind -# source: ./librechat.yaml +# source: ${PWD}/librechat.yaml # target: /app/librechat.yaml # image: registry.librechat.ai/librechat-ai/librechat:latest @@ -27,7 +27,7 @@ # api: # volumes: # - type: bind -# source: ./librechat.yaml +# source: ${PWD}/librechat.yaml # target: /app/librechat.yaml # # LOCAL BUILD diff --git a/librechat.ministack.yaml b/librechat.ministack.yaml new file mode 100644 index 00000000000..984e665dc38 --- /dev/null +++ b/librechat.ministack.yaml @@ -0,0 +1,1430 @@ +# For more information, see the Configuration Guide: +# https://www.librechat.ai/docs/configuration/librechat_yaml + +# Configuration version (required) +version: 1.3.16 + +# Cache settings: Set to true to enable caching +cache: true + +# Langfuse connections are managed through Settings > Langfuse when available. +# That flow verifies the credentials and stores the secret key encrypted; do not +# place a plaintext langfuse.secretKey in this file. Environment-managed central +# credentials and optional fanout routing are documented in .env.example. +# +# Self-hosted Langfuse behind an authenticating proxy or gateway can be given +# custom request headers. They are sent on every outbound Langfuse request — +# trace and media export, feedback scores, and credential verification. +# Values support ${ENV_VAR} interpolation; a header whose variable is unset is +# dropped with a warning rather than sent as a literal placeholder. +# +# These are deployment-level: trace export batches spans from every user through +# a single exporter, so unlike endpoints.custom headers they cannot carry +# per-user placeholders such as {{LIBRECHAT_USER_ID}}. +# +# Values are masked in admin config reads and in the startup config log, but +# prefer ${ENV_VAR} references over literal credentials here regardless. +# +# Scope: these are sent only when the deployment configures exactly ONE Langfuse +# origin (a self-hosted base URL, or a single tenant destination URL), and only +# to that origin. The map has no way to say which endpoint it authenticates to, +# so a deployment configuring several origins — e.g. a fanout collector plus a +# separate central host — gets a warning and no headers, rather than having a +# gateway credential sent somewhere it was not meant for. +# +# Fanout deployments additionally need collector support: the gateway forwards +# only Authorization upstream, so a tenant Langfuse behind its own proxy is not +# covered even when the collector receives these. +# +# Traces carry the internal user id as the Langfuse userId and no user or +# request metadata unless langfuse.trace opts in. userIdField picks the user +# field that becomes the trace userId (id, email, username, name, openidId, +# samlId, ldapId, googleId, githubId, discordId, appleId, facebookId); a user +# without a value for that field keeps the internal id. The two allowlists copy +# fields into trace metadata as librechat.user. (the userId fields plus +# role and provider) and librechat.conversation.id, librechat.endpoint, +# librechat.endpoint.type, librechat.provider, librechat.model, +# librechat.model.label, librechat.spec. +# +# langfuse: +# headers: +# CF-Access-Client-Id: "${CF_ACCESS_CLIENT_ID}" +# CF-Access-Client-Secret: "${CF_ACCESS_CLIENT_SECRET}" +# trace: +# userIdField: email +# userMetadataFields: [email, username, role, provider] +# conversationMetadataFields: [conversationId, endpoint, model, modelLabel, spec] + +# File storage configuration +# Single strategy for all file types (legacy format, still supported) +# fileStrategy: "s3" + +# Granular file storage strategies (new format - recommended) +# Allows different storage strategies for different file types +# fileStrategy: +# avatar: "s3" # Storage for user/agent avatar images +# image: "firebase" # Storage for uploaded images in chats +# document: "local" # Storage for document uploads (PDFs, text files, etc.) + +# Available strategies: "local", "s3", "firebase", "azure_blob", "cloudfront" +# If not specified, defaults to "local" for all file types +# You can mix and match strategies based on your needs: +# - Use S3 for avatars for fast global access +# - Use Firebase for images with automatic optimization +# - Use local storage for documents for privacy/compliance +# - Use CloudFront for CDN-accelerated delivery (requires S3 + cloudfront config below) + +# CloudFront CDN Configuration (optional) +# Use when fileStrategy: "cloudfront" or fileStrategies includes cloudfront +# Requires: AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_BUCKET_NAME +# For signed cookies and direct download URLs: CLOUDFRONT_KEY_PAIR_ID, CLOUDFRONT_PRIVATE_KEY +# cloudfront: +# domain: "https://cdn.example.com" # CloudFront domain (CNAME recommended for cookies) +# distributionId: "E1234ABCD" # Required if invalidateOnDelete is true +# invalidateOnDelete: false # Create cache invalidation on file delete +# imageSigning: "none" # "none" (public) | "cookies" (signed cookies) +# # When imageSigning: "cookies", API + CloudFront must share a parent domain. +# # Cookies are path-scoped to inline assets only: +# # /i/... private uploaded/generated images, user-scoped +# # /a/... tenant-visible avatars, tenant-scoped when tenantId is present +# # Downloads, documents, uploads, and code outputs stay outside /i and /a and +# # use backend-authorized signed URLs instead of signed cookies. +# # API: api.example.com, CloudFront CNAME: cdn.example.com, cookieDomain: ".example.com" +# cookieDomain: ".example.com" # Required for "cookies" - shared parent domain +# cookieExpiry: 1800 # Cookie lifetime in seconds (max: 604800 / 7 days, default: 1800 / 30 min) +# urlExpiry: 3600 # Signed CloudFront download URL lifetime in seconds +# # Optional multi-region S3/CloudFront layout. Default: false. +# # When enabled, new object keys include: +# # /i/r/{storageRegion}/t/{tenantId}/images/{userId}/{file} (private inline images) +# # /a/r/{storageRegion}/t/{tenantId}/avatars/{userId}/{file} (tenant-visible avatars) +# # /r/{storageRegion}/t/{tenantId}/{basePath}/{userId}/{file} (non-inline files) +# # storageRegion defaults to AWS_REGION when omitted, but only affects new keys +# # when includeRegionInPath is true. Existing files are not moved automatically. +# # LibreChat does not configure CloudFront origins, Route53, or regional routing. +# storageRegion: "us-east-2" +# includeRegionInPath: false +# # Direct-download filename/content-type overrides require the CloudFront cache/origin +# # request policy to forward and cache on response-content-disposition and +# # response-content-type query strings to S3. +# # Recommended for download paths: attach a CloudFront response headers policy +# # with X-Content-Type-Options: nosniff and CSP default-src 'none'. + +# Skill sync configuration (optional) +# GitHub tokens are referenced from environment variables. Put the token in +# `.env`, then reference it here with `token: '${GITHUB_SKILLS_TOKEN}'`. Use a +# GitHub fine-grained personal access token scoped to the selected repository +# with read-only Contents and Metadata permissions. +# skillSync: +# github: +# enabled: false +# intervalMinutes: 60 +# runOnStartup: true +# sources: +# - id: librechat-skills +# owner: your-org +# repo: your-skills-repo +# ref: main +# paths: +# - skills +# # Number of directory levels below each configured path to scan for +# # `SKILL.md`. Use 2 for repos shaped like `skills//`. +# skillDiscoveryDepth: 2 +# token: '${GITHUB_SKILLS_TOKEN}' +# # Optional. Owns the mirrored skills under the given tenant so they are +# # created and shared within that tenant. Required for visibility when +# # tenant isolation is enabled. Omit for single-tenant deployments. +# # Treat as immutable per source id: changing (or adding/removing) the +# # tenantId later leaves previously mirrored skills in the old tenant, +# # where this source's sync can no longer see or clean them up. To move a +# # source between tenants, delete its mirrored skills in the old tenant +# # first, or use a new source id for the new tenant. +# # tenantId: your-tenant-id + +# Custom interface configuration +interface: + customWelcome: 'Welcome to LibreChat! Enjoy your experience.' + # Enable/disable file search as a chatarea selection (default: true) + # Note: This setting does not disable the Agents File Search Capability. + # To disable the Agents Capability, see the Agents Endpoint configuration instead. + # Legacy Mongo-backed setting: fileSearch: true + fileSearch: false + # Privacy policy settings + privacyPolicy: + externalUrl: 'https://librechat.ai/privacy-policy' + openNewTab: true + + # Terms of service + termsOfService: + externalUrl: 'https://librechat.ai/tos' + openNewTab: true + modalAcceptance: true + modalTitle: 'Terms of Service for LibreChat' + modalContent: | + # Terms and Conditions for LibreChat + + *Effective Date: February 18, 2024* + + Welcome to LibreChat, the informational website for the open-source AI chat platform, available at https://librechat.ai. These Terms of Service ("Terms") govern your use of our website and the services we offer. By accessing or using the Website, you agree to be bound by these Terms and our Privacy Policy, accessible at https://librechat.ai//privacy. + + ## 1. Ownership + + Upon purchasing a package from LibreChat, you are granted the right to download and use the code for accessing an admin panel for LibreChat. While you own the downloaded code, you are expressly prohibited from reselling, redistributing, or otherwise transferring the code to third parties without explicit permission from LibreChat. + + ## 2. User Data + + We collect personal data, such as your name, email address, and payment information, as described in our Privacy Policy. This information is collected to provide and improve our services, process transactions, and communicate with you. + + ## 3. Non-Personal Data Collection + + The Website uses cookies to enhance user experience, analyze site usage, and facilitate certain functionalities. By using the Website, you consent to the use of cookies in accordance with our Privacy Policy. + + ## 4. Use of the Website + + You agree to use the Website only for lawful purposes and in a manner that does not infringe the rights of, restrict, or inhibit anyone else's use and enjoyment of the Website. Prohibited behavior includes harassing or causing distress or inconvenience to any person, transmitting obscene or offensive content, or disrupting the normal flow of dialogue within the Website. + + ## 5. Governing Law + + These Terms shall be governed by and construed in accordance with the laws of the United States, without giving effect to any principles of conflicts of law. + + ## 6. Changes to the Terms + + We reserve the right to modify these Terms at any time. We will notify users of any changes by email. Your continued use of the Website after such changes have been notified will constitute your consent to such changes. + + ## 7. Contact Information + + If you have any questions about these Terms, please contact us at contact@librechat.ai. + + By using the Website, you acknowledge that you have read these Terms of Service and agree to be bound by them. + + modelSelect: true + parameters: true + # Legacy Mongo-backed setting: presets: true + presets: false + prompts: + # Legacy Mongo-backed settings: use/create: true + use: false + create: false + share: false + public: false + # Legacy Mongo-backed setting: bookmarks: true + bookmarks: false + multiConvo: true + agents: + # Legacy Mongo-backed settings: use/create: true + use: false + create: false + share: false + public: false + # Scheduled chats are experimental and disabled unless explicitly enabled. + # A deployment with Redis-backed resumable streams can run this on every replica. + # A single-process deployment without Redis must also set + # `SCHEDULES_SINGLE_PROCESS=true`; unsafe multi-replica writes fail closed. + # Scheduled agents that can pause require both `USE_REDIS_STREAMS=true` for + # shared action state and a durable shared checkpointer for graph continuation. + # MongoDB is currently the built-in durable checkpointer (and the default). + # schedules: + # use: true + # create: true + # maxPerUser: 10 + # minIntervalMinutes: 60 + # autoDisableAfterFailures: 5 + # # Maximum occurrences concurrently passing readiness admission per replica (1-100). + # admissionConcurrency: 20 + # fireConcurrency: 5 + # # Maximum MCP readiness connections opened at once by one schedule admission (1-10). + # mcpPreflightConcurrency: 3 + # # Maximum wall-clock time for one MCP readiness admission (1000-600000 ms). + # mcpPreflightTimeoutMs: 300000 + # # Require every schedule to be filed under a chat project. Enforced on + # # create/update AND at each fire, so turning it on stops (auto-disables) + # # schedules that predate it rather than letting their runs land loose. + # requireProject: false + # # Pin every scheduled run to ONE chat project, ignoring the user's choice. + # # Implies `requireProject`. Chat projects are user-owned, so a value set here + # # only makes sense as a per-user or per-role config override. + # # projectId: '000000000000000000000000' + # No multi-user sharing is being tested in this deployment. + peoplePicker: + users: false + groups: false + roles: false + marketplace: + use: false + fileCitations: true + # Show the thumbs up/thumbs down feedback buttons on responses (default: true) + feedback: true + # Conversation trace viewer: a waterfall of the model calls and tool spans behind + # each response, read from the deployment's Langfuse project. The control only + # appears on the requesting user's own conversations with a sampled trace, and + # needs a Langfuse release that serves the v2 Observations API. + # traceViewer: + # enabled: false + # # Traces are matched to users by the internal user id, so no trace is shown while + # # langfuse.trace.userIdField exports another user field. + # # Return observation input, output and metadata in the record inspector. Input + # # carries system prompts, agent instructions and tool definitions, which a + # # user chatting with a shared agent cannot otherwise see. + # showInputOutput: false + # # Observations read per request (1-10000); older records load on demand. + # maxRecords: 1000 + # # Characters kept from each input, output and metadata value (1-1000000). + # maxContentLength: 50000 + # # Trace reads one user may start per minute (1-1000). + # requestsPerMinute: 30 + # # Budget for each request to Langfuse, in milliseconds (1000-300000). + # requestTimeoutMs: 10000 + # Enabled for the local `observability` Compose profile. It stays inert until + # LANGFUSE_* is set in .env. + traceViewer: + enabled: true + showInputOutput: false + # Tools pinned to the prompt bar by default for all users. + # Only seeds the initial state — once a user pins/unpins a tool, their choice is kept. + # Valid tool keys: artifacts, execute_code, web_search, file_search, skills. + # Use 'mcp' (or a specific MCP server name) to pin the MCP servers dropdown. + # When omitted, tools start unpinned and the MCP dropdown keeps its default (pinned). + # defaultPinnedTools: + # - 'artifacts' + # - 'execute_code' + # - 'mcp' + # Remote Agents configuration + # Controls user permissions for remote agents with external API support + # remoteAgents: + # use: false + # create: false + # share: false + # public: false + # MCP Servers configuration example + # Shared Links configuration + # Controls user permissions for shared links (e.g. sharing conversations via link) + # sharedLinks: + # create: false + # share: true + # public: true # Allows users to toggle "share with everyone" for their links. Whether anonymous access is permitted is controlled by ALLOW_SHARED_LINKS_PUBLIC. + # snapshotFiles: true # Snapshot files referenced by a shared chat so viewers can preview/download them via the link. Enabled by default; the SHARED_LINKS_SNAPSHOT_FILES env var overrides this. + # mcpServers: + # # Poll only while MCP controls are visible, in milliseconds (0 disables polling). + # toolsRefreshInterval: 300000 + # statusRefreshInterval: 30000 + # Controls user permissions for MCP (Model Context Protocol) server management + # - use: Allow users to use configured MCP servers + # - create: Allow users to create and manage new MCP servers + # - share: Allow users to share MCP servers with other users + # - public: Allow users to share MCP servers publicly (with everyone) + + # Creation / edit MCP server config Dialog config example + # trustCheckbox: + # label: + # en: 'I understand and I want to continue' + # de: 'Ich verstehe und möchte fortfahren' + # de-DE: 'Ich verstehe und möchte fortfahren' # You can narrow translation to regions like (de-DE or de-CH) + # subLabel: + # en: | + # Librechat hasn't reviewed this MCP server. Attackers may attempt to steal your data or trick the model into taking unintended actions, including destroying data. Learn more. + # de: | + # LibreChat hat diesen MCP-Server nicht überprüft. Angreifer könnten versuchen, Ihre Daten zu stehlen oder das Modell zu unbeabsichtigten Aktionen zu verleiten, einschließlich der Zerstörung von Daten. Mehr erfahren. + + # Temporary chat retention period in hours (default: 720, min: 1, max: 8760) + # temporaryChatRetention: 1 + # Regular chat retention in hours under retentionMode: "all" (min: 1, max: 8760). + # Defaults to the temporary chat retention policy when omitted. + # generalChatRetention: 2160 # 90 days, independently of temporary chats + # Existing stored deadlines are not migrated when these settings change. + # Retention mode: "all" applies expiry to all data types, "temporary" (default) only to temporary chats + # Before switching from "all" back to "temporary", remove retention deadlines from non-temporary data + # that should stop expiring: + # db.conversations.updateMany({ isTemporary: false, expiredAt: { $ne: null } }, { $unset: { expiredAt: 1 } }) + # db.messages.updateMany({ isTemporary: false, expiredAt: { $ne: null } }, { $unset: { expiredAt: 1 } }) + # MongoDB does not drop superseded indexes automatically. After upgrading, old Meili indexes + # such as "_meiliIndex_1_expiredAt_1" can be dropped from conversations/messages once the new + # "_meiliIndex_1_isTemporary_1_expiredAt_1" indexes exist. + # retentionMode: "temporary" + # Set retainAgentFiles to true to keep persistent agent resource files from expiring under + # retentionMode: "all"; non-agent files still expire. + # retainAgentFiles: false + +# Example Cloudflare turnstile (optional) +#turnstile: +# siteKey: "your-site-key-here" +# options: +# language: "auto" # "auto" or an ISO 639-1 language code (e.g. en) +# size: "normal" # Options: "normal", "compact", "flexible", or "invisible" + +# Example Registration Object Structure (optional) +registration: + # OpenID (Cognito) is the configured social login; this list drives the login-page buttons. + socialLogins: ["openid"] + # allowedDomains: + # - "gmail.com" + # How long a started social login may take to return from the provider (ms, default 600000) + # oauthStateTtlMs: 600000 + # OpenID discovery retries when the provider is unreachable (overrides OPENID_DISCOVERY_RETRY_* env vars) + # openidDiscovery: + # startupAttempts: 1 # attempts before startup continues; 0 = background retries only + # retryDelayMs: 5000 # delay between startup and background attempts + +# Example Balance settings +# balance: +# enabled: false +# startBalance: 20000 +# autoRefillEnabled: false +# refillIntervalValue: 30 +# refillIntervalUnit: 'days' +# refillAmount: 10000 +# reservationTtlMs: 1800000 # How long an in-flight request's reserved credits count if never released + +# Example Transactions settings +# Controls whether to save transaction records to the database +# Default is true (enabled) +#transactions: +# enabled: false +# Note: If balance.enabled is true, transactions will always be enabled +# regardless of this setting to ensure balance tracking works correctly + +# Speech (STT/TTS) outbound requests to operator-provided target URLs are SSRF-guarded +# at connect time: private, loopback, link-local, and cloud-metadata targets are blocked +# by default. To point STT/TTS at a private or self-hosted service (LocalAI, a self-hosted +# Whisper server), add its host:port to `allowedAddresses` on the `stt` / `tts` section. +# SECURITY: `allowedAddresses` entries are trusted before the private-IP check. A listed +# host:port is permitted even when it resolves to a private IP, so list only hosts you fully +# control and that cannot be repointed by an attacker. Do not list attacker-controllable or +# DNS-rebindable hostnames, because doing so re-opens the private-address path this guard +# closes. Prefer a private IP literal over a hostname when exempting a private target. Entries +# must include a port (`host:port`, `private.ip:port`, or `[ipv6]:port`); do not use URLs, paths, +# CIDR ranges, bare hosts/IPs, or public IP literals. +# When a forward proxy is configured (PROXY / HTTP(S)_PROXY), it performs DNS and egress in its +# own network context, so these requests are delegated to it and it must be SSRF-enforcing; the +# connect-time guard only covers direct, non-proxied connections. +# speech: +# tts: +# openai: +# url: '' +# apiKey: '${TTS_API_KEY}' +# model: '' +# voices: [''] +# allowedAddresses: +# - 'localhost:8020' +# - '127.0.0.1:8020' +# +# stt: +# openai: +# url: '' +# apiKey: '${STT_API_KEY}' +# model: '' +# allowedAddresses: +# - 'localhost:8000' +# - '127.0.0.1:8000' + +# OCR (Mistral / Mistral-compatible) outbound requests to `ocr.baseURL` are SSRF-guarded at +# connect time with the same default-deny for private targets. To point OCR at a private or +# self-hosted Mistral-compatible service, add its host:port to `allowedAddresses`. The same +# trust caveat as speech applies: a listed host:port is trusted before the private-IP check, +# so list only hosts you fully control and that cannot be repointed by an attacker. Entries +# must include a port and must not be URLs, paths, CIDR ranges, bare hosts/IPs, or public IP +# literals. +# ocr: +# baseURL: '${OCR_BASEURL}' +# apiKey: '${OCR_API_KEY}' +# allowedAddresses: +# - 'localhost:8080' +# - '127.0.0.1:8080' + +# rateLimits: +# # Authenticated agent-event admission has its own API-key-principal bucket. +# agentEvents: +# userMax: 40 +# userWindowInMinutes: 1 +# fileUploads: +# ipMax: 100 +# ipWindowInMinutes: 60 # Rate limit window for file uploads per IP +# userMax: 50 +# userWindowInMinutes: 60 # Rate limit window for file uploads per user +# conversationsImport: +# ipMax: 100 +# ipWindowInMinutes: 60 # Rate limit window for conversation imports per IP +# userMax: 50 +# userWindowInMinutes: 60 # Rate limit window for conversation imports per user + +# Agent Actions domain restrictions (OpenAPI spec validation) +# SECURITY: If not configured, SSRF targets are blocked (localhost, private IPs, .internal/.local TLDs). +# Prefer `allowedAddresses` for permitting internal targets — adding a private IP to +# `allowedDomains` switches the field into strict-whitelist mode and blocks every +# public destination not also listed. +# Supports wildcards: '*.example.com' and protocol/port restrictions: 'https://api.example.com:8443' +actions: + allowedDomains: + - 'swapi.dev' + - 'librechat.ai' + - 'google.com' + # - 'http://10.225.26.25:7894' # Internal IP with protocol/port (uncomment if needed) + # `allowedAddresses` is an SSRF exemption list, NOT a strict whitelist. + # Hostname/IP + port pairs listed here bypass the default-deny block for that + # one private/loopback/link-local service. Public domains continue to work + # normally — listing private targets here does not restrict access to anything else. + # + # Entries must include a port: `host:port`, `private.ip:port`, or `[ipv6]:port`. + # Do not use URLs, paths, CIDR ranges, bare hosts/IPs, or public IP literals. + # Public IP literals are rejected at config load (the field is scoped to + # private IP space; public IPs aren't SSRF targets). + # + # NOTE on hostnames: a hostname entry trusts whatever IP that name resolves to + # on the listed port. If DNS for that name is hijacked or rotated to a different + # private IP, the exemption follows. Only list hostnames whose DNS you control. + # Prefer literal IPs when you can. + # allowedAddresses: + # - 'host.docker.internal:11434' + # - '127.0.0.1:11434' + # - '10.0.0.5:8080' + +# Custom endpoint baseURL exemption list +# SECURITY: User-provided baseURLs (`baseURL: 'user_provided'`) are validated against +# the same SSRF block as Actions and MCP. If your users legitimately point at private +# services (self-hosted Ollama, internal LLM gateway, etc.), list those hostname/IP +# + port pairs here so the validator allows them through. Public destinations are +# unaffected. +# +# Entries must include a port: `host:port`, `private.ip:port`, or `[ipv6]:port`. +# Do not use URLs, paths, CIDR ranges, bare hosts/IPs, or public IP literals. +# Hostname entries trust whatever IP they resolve to on the listed port — only +# list names whose DNS you control. +# endpoints: +# allowedAddresses: +# - 'localhost:11434' +# - '127.0.0.1:11434' +# - 'ollama:11434' +# - '10.0.0.5:8080' + +# MCP Server domain restrictions for remote transports (SSE, WebSocket, HTTP) +# SECURITY: If not configured, SSRF targets are blocked (localhost, private IPs, .internal/.local TLDs). +# Prefer `allowedAddresses` for permitting internal targets — adding a private host to +# `allowedDomains` switches the field into strict-whitelist mode and blocks every +# public destination not also listed. +# Supports wildcards: '*.example.com' matches 'api.example.com', 'staging.example.com', etc. +# Supports protocol/port restrictions: 'https://api.example.com:8443' restricts to specific protocol/port. +# mcpSettings: +# allowedDomains: +# - 'host.docker.internal' # Docker host access (required for Docker setups) +# - 'localhost' # Local development +# - '*.example.com' # Wildcard subdomain +# - 'https://secure.api.com' # Protocol-restricted +# - 'http://internal:8080' # Protocol and port restricted +# # allowedAddresses is an SSRF exemption list (private-IP-space only). +# # Hostname/IP + port pairs listed here bypass the default-deny block for that +# # one private service; public destinations remain reachable. Useful when you +# # want default SSRF protection AND specific internal MCP servers. Entries must +# # include a port (`host:port`, `private.ip:port`, or `[ipv6]:port`) and must +# # not be URLs, paths, CIDR ranges, bare hosts/IPs, or public IP literals. +# # Hostname entries trust whatever IP they resolve to on the listed port. +# allowedAddresses: +# - 'host.docker.internal:8080' +# - '127.0.0.1:8080' + +# Example MCP Servers Object Structure +# mcpServers: +# everything: +# # type: sse # type can optionally be omitted +# url: http://localhost:3001/sse +# # proxy: "${MCP_PROXY_URL}" # optional outbound proxy (http/https/socks/socks5) +# timeout: 60000 # 1 minute timeout for this server, this is the default timeout for MCP servers. +# private-openid: +# type: streamable-http +# url: https://mcp.example.com/mcp +# headers: +# # Trusted operator configs automatically use the live session token and replace the +# # connection once after a 401/403. A rejected tools/call is never replayed automatically. +# # Enabled oauth or obo configuration takes precedence over this passthrough mode. +# Authorization: "Bearer {{LIBRECHAT_OPENID_ACCESS_TOKEN}}" +# puppeteer: +# type: stdio +# command: npx +# args: +# - -y +# - "@modelcontextprotocol/server-puppeteer" +# timeout: 300000 # 5 minutes timeout for this server +# filesystem: +# # type: stdio +# command: npx +# args: +# - -y +# - "@modelcontextprotocol/server-filesystem" +# - /home/user/LibreChat/ +# iconPath: /home/user/LibreChat/client/public/assets/logo.svg +# mcp-obsidian: +# command: npx +# args: +# - -y +# - "mcp-obsidian" +# - /path/to/obsidian/vault + +# Definition of custom endpoints +endpoints: + # assistants: + # disableBuilder: false # Disable Assistants Builder Interface by setting to `true` + # pollIntervalMs: 3000 # Polling interval for checking assistant updates + # timeoutMs: 180000 # Timeout for assistant operations + # # Should only be one or the other, either `supportedIds` or `excludedIds` + # supportedIds: ["asst_supportedAssistantId1", "asst_supportedAssistantId2"] + # # excludedIds: ["asst_excludedAssistantId"] + # # Only show assistants that the user created or that were created externally (e.g. in Assistants playground). + # # privateAssistants: false # Does not work with `supportedIds` or `excludedIds` + # # (optional) Models that support retrieval, will default to latest known OpenAI models that support the feature + # retrievalModels: ["gpt-4-turbo-preview"] + # # (optional) Assistant Capabilities available to all users. Omit the ones you wish to exclude. Defaults to list below. + # capabilities: ["code_interpreter", "retrieval", "actions", "tools", "image_vision"] + agents: + # Trimmed to what RAG chat testing needs, plus ask_user_question/web_search kept + # deliberately (a search provider key can be added later). Dropped: execute_code/ocr + # (no backing API key configured), artifacts/subagents/actions/chain/deferred_tools + # (don't exercise the file_search/context path), skills/memory (no backing config). + capabilities: ["file_search", "web_search", "context", "ask_user_question", "tools"] + # # (optional) Default recursion depth for agents, defaults to 25 + # recursionLimit: 50 + # # (optional) Max recursion depth for agents, defaults to 25 + # maxRecursionLimit: 100 + # # (optional) Abort a run once a single streamed tool call's arguments exceed this many bytes. + # # Guards against runaway malformed tool-call generation. Defaults to 65536 (64 KiB); 0 disables. + # maxToolCallArgBytes: 65536 + # # (optional) Abort a run once a single model generation emits more than this many stream events. + # # Defense in depth against looping provider streams. Disabled by default. + # maxDeltaEventsPerTurn: 100000 + # # (optional) Per-tool overrides for maxToolCallArgBytes, keyed by tool name; 0 disables that + # # tool's guard. LibreChat ships { create_file: 131072 } so document-sized file writes are not + # # cut off; entries here merge over (and can replace) that default. + # maxToolCallArgBytesByTool: + # create_file: 131072 + # # (optional) Disable the builder interface for agents + # disableBuilder: false + # # (optional) When conversation titles are generated: + # # immediate (default): generate as soon as the request is made, in parallel + # # with the response, from the user's first message (title appears within ~1-2s). + # # final: defer generation until the full response completes (legacy behavior). + # # Set under `endpoints.all` instead to apply as the global default for all endpoints. + # titleTiming: immediate + # # (optional) Generate one-line headers for blocks of Agent reasoning and tool calls. + # # Header generation is a separate model call whose usage and cost are recorded. + # activityLabel: true + # activityEndpoint: openAI + # activityModel: gpt-4.1-nano + # # activityPrompt: 'Write a short activity label...' + # # activityMaxPerRun: 20 + # # activityCharLimit: 600 + # # (optional) Replace the generic Thinking/Thoughts heading with a live + # # generated orientation as sufficiently long top-level reasoning evolves. + # # Enabling this sends a bounded snapshot (up to 4,000 characters) of the + # # visible reasoning to the resolved label endpoint, which may be a different provider. + # # With Langfuse tracing enabled, that snapshot is also recorded as generation input + # # unless the active redaction policy suppresses the label call. + # # reasoningLabel: true + # # reasoningLabelModel: gpt-4.1-nano # falls back to activity/title/run model + # # reasoningLabelEndpoint: openAI # falls back to activity/run endpoint + # # reasoningLabelPrompt: 'Describe the current reasoning direction...' + # # reasoningLabelMinChars: 500 # text required before the first label + # # reasoningLabelUpdateChars: 400 # new text between streaming revisions + # # reasoningLabelUpdateIntervalMs: 3000 # minimum time between streaming revisions + # # A final rewrite may run immediately after a meaningful 120-character tail. + # # reasoningLabelMaxPerRun: 8 # provider-call cap per response + # # (optional) Maximum total citations to include in agent responses, defaults to 30 + # maxCitations: 30 + # # (optional) Maximum citations per file to include in agent responses, defaults to 7 + # maxCitationsPerFile: 7 + # # (optional) Minimum relevance score for sources to be included in responses, defaults to 0.45 (45% relevance threshold) + # # Set to 0.0 to show all sources (no filtering), or higher like 0.7 for stricter filtering + # minRelevanceScore: 0.45 + # # (optional) Maximum explicit subagents per agent, for both the flat list and + # # graph definitions. Defaults to 10; hard cap 50. + # maxSubagents: 20 + # # (optional) Shared files during an active agent run. Each delegating agent must + # # also opt in with subagents.shareFiles. Inputs are read-only; outputs stay + # # private until explicitly published. Published files remain downloadable. + # fileSharing: + # enabled: false + # allowSiblingSharing: false # permit explicitly named sibling/descendant recipients + # maxFiles: 100 # manifest capacity, 1–1000 + # maxPrivateBytes: 268435456 # aggregate private output snapshots, 256 MiB + # ttlMs: 3600000 # active manifest lifetime, at most 24 hours + # # (optional) Maximum concurrent Code API uploads per route and user. Defaults to 3. + # codeApiUploadConcurrency: 3 + # # (optional) Maximum total time spent waiting on Code API rate limits. Defaults to 20000 ms. + # codeApiMaxRetryWaitMs: 20000 + # # (optional) Cap the number of active accessible skills shown in the model-visible catalog. + # # Useful for large organizations where many department-specific skills may be available. + # skills: + # maxCatalogSkills: 20 + # # (optional) Agent Capabilities available to all users. Omit the ones you wish to exclude. Defaults to list below. + # capabilities: ["deferred_tools", "execute_code", "file_search", "web_search", "artifacts", "subagents", "actions", "context", "skills", "memory", "ask_user_question", "tools", "chain", "ocr"] + # # The following capabilities are opt-in and must be added explicitly: + # # "programmatic_tools", "stateful_code_sessions", "run_in_background", "tool_intents" + # # "stateful_code_sessions" is highly experimental and may change substantially. + # # (optional) Limit the workspace scopes users may select. Omit to allow all three. + # statefulCodeSessions: + # allowedEnvironments: ["user", "agent-user", "conversation"] + # # Personal BYOM enrollment. Requires allowPrincipalWorkers on a control plane. + # # Omitted values default to enabled and five registered environments per user. + # # Principal config overrides may tighten, but cannot raise, this ceiling. + # # Lowering the limit, or disabling enrollment, does not revoke existing machines. + # # This counts registered environments across control planes, not online workers. + # principalWorkers: + # enabled: true + # maxPerUser: 25 + # # Route an agent to an operator-managed Code API or to a Code API whose + # # `remote-bridge` backend leases work to an outbound @librechat/code worker. + # environments: + # - id: managed-default + # name: Managed Code API + # type: managed + # baseURL: https://code.example.com/v1 + # default: true + # - id: engineering-vm + # name: Engineering VM + # type: attached + # baseURL: https://code-bridge.example.com/v1 + # # Pins an administrator-managed environment to one worker. + # owner: deployment + # pairing: + # workerId: engineering-vm + # tokenEnv: CODE_BRIDGE_ADMIN_TOKEN + # - id: personal-workers + # name: Personal Code Workers + # type: attached + # baseURL: https://code-bridge.example.com/v1 + # # Lets authenticated users create principal-bound environments and + # # receive a one-time code for an outbound @librechat/code worker. + # # The administrator secret stays in the named environment variable. + # owner: deployment + # # Optionally expose only these bounded preferences to environment owners. + # configSchema: + # limits: + # # Maximum Bash execution budget the model may request per call. + # # Omit to preserve the historical 30-second limit. Hard cap: 300000. + # maxCommandTimeoutMs: 120000 + # permissions: + # fileWrite: + # allowed: [allow, ask, deny] + # default: ask + # commandExecution: + # # Add allow to offer Full access in the chat composer. Both + # # permission categories must permit allow; defaults can stay ask. + # # Full access skips routine code prompts within the worker sandbox. + # # Explicit endpoint ask/deny rules and hooks still apply. + # allowed: [ask, deny] + # default: ask + # pairing: + # allowPrincipalWorkers: true + # tokenEnv: CODE_BRIDGE_ADMIN_TOKEN + # # (optional) Trusted origin for internal event delivery. By default, LibreChat + # # uses its own bound listener; override only for an internal TLS/front-door route. + # eventDriven: + # # selfUrl: 'https://librechat.internal' + # # (optional) Automatic conversational delivery for supported completed background tasks. + # # Enabled by default; set false to keep background tools and subagents poll-only. + # backgroundTasks: + # completionWakeups: true + # # Allow owners to cooperatively cancel ordinary background tools, including BYOM Bash. + # ordinaryToolCancellation: false + # # "run_in_background" makes Code Interpreter tools eligible by default and enables per-tool MCP opt-in. + # # "tool_intents" enables live model-written labels for native tools and opted-in MCP tools. + # # (optional) Require user approval before matching tool calls. Disabled by default. + # toolApproval: + # enabled: true + # mode: default # default, dontAsk, or bypass + # allow: ["mcp:trusted-server:read_*"] + # deny: ["mcp:*:delete_*"] # Deny rules always take precedence + # ask: ["mcp:*:*"] + # reason: "Review {tool} before it runs." + # # (optional) Persist Agent runs paused for approval or Ask User. MongoDB is the durable default. + # checkpointer: + # type: mongo # mongo (default) or memory (single-process development only) + # ttl: 86400 # Approval window in seconds; defaults to 24 hours + # # (optional) Authenticate machine clients for the Agent Management API. + # # Each verified OAuth client is bound server-side to one existing LibreChat + # # user and tenant; that user's normal role and Agent ACLs still apply. + # managementApi: + # auth: + # oidc: + # enabled: true + # issuer: 'https://identity.example.com/' + # audience: 'https://librechat.example.com/agents' + # # jwksUri: 'https://identity.example.com/.well-known/jwks.json' + # clients: + # - clientId: 'machine-client-id' + # # subject: 'provider-specific-service-principal-subject' + # userId: '507f1f77bcf86cd799439011' + # tenantId: 'tenant-id' + + # (optional) Custom request headers for the built-in OpenAI / Google endpoints. + # Forwarded on every request to the provider (or an AI gateway / reverse proxy + # in front of it) while keeping provider-native request shaping intact. Values + # support env vars (${VAR}), user fields ({{LIBRECHAT_USER_*}}), and request-body + # fields ({{LIBRECHAT_BODY_CONVERSATIONID}}). Set the same `headers:` block under + # `endpoints.all` to apply globally across endpoints (endpoint values win on key + # collisions). NOTE: send metadata headers like these only behind a gateway that + # consumes them — native provider APIs ignore unknown headers. + # openAI: + # headers: + # cf-aig-metadata: '{"user_email":"{{LIBRECHAT_USER_EMAIL}}","app":"librechat"}' + # google: + # headers: + # cf-aig-metadata: '{"user_email":"{{LIBRECHAT_USER_EMAIL}}","app":"librechat"}' + + # Anthropic endpoint configuration with Vertex AI support + # Use this to run Anthropic Claude models through Google Cloud Vertex AI + # anthropic: + # # (optional) Override the adaptive stream-smoothing cadence in milliseconds. + # # Agents SDK-backed providers smooth at 25ms by default; set 0 to disable + # # smoothing. (Legacy Assistants/Ollama paths sleep this long per chunk instead.) + # streamRate: 20 + # # (optional) Title model for conversation titles + # titleModel: claude-3.5-haiku # Use the visible model name (key from models config) + # # (optional) Custom request headers, same placeholder resolution as above. + # # Useful for correlating reverse-proxied requests by conversation, since an + # # unknown header is simply ignored by the native Anthropic API. + # headers: + # cf-aig-metadata: '{"user_email":"{{LIBRECHAT_USER_EMAIL}}","app":"librechat"}' + # X-Conversation-Id: '{{LIBRECHAT_BODY_CONVERSATIONID}}' + # + # # Vertex AI Configuration - enables running Claude models via Google Cloud + # # This is similar to Azure OpenAI but for Anthropic models on Google Cloud + # # Vertex AI is automatically enabled when this config section is present + # vertex: + # # Vertex AI region (optional, defaults to 'us-east5') + # # Available regions: us-east5, us-central1, europe-west1, europe-west4, asia-southeast1 + # # Multi-region endpoints: us, eu, global + # # IMPORTANT: specific regional endpoints only serve Claude Sonnet 4.6 and earlier. Newer + # # models (Opus 4.7+, Opus 5, Sonnet 5, Fable 5/5.1) require "global" or a multi-region value + # # ("us"/"eu") and will 404 on a specific region. "global" also avoids the 10% regional premium. + # region: "us-east5" + # # Path to Google service account key file (optional) + # # If not specified, uses GOOGLE_SERVICE_KEY_FILE env var or default path (api/data/auth.json) + # # The project_id is automatically extracted from the service key file + # # serviceKeyFile: "/path/to/service-account.json" + # # Google Cloud Project ID (optional) - auto-detected from service key file + # # Only specify if you need to override the project_id in your service key + # # projectId: "${VERTEX_PROJECT_ID}" + # + # # ============================================================================ + # # Model Configuration - Set Visible Model Names and Deployment Mappings + # # Similar to Azure OpenAI model naming pattern + # # ============================================================================ + # + # # Option 1: Simple array (legacy format - model name = deployment name) + # # Use this if you want the technical model IDs to show in the UI + # # models: + # # - "claude-fable-5-1" + # # - "claude-fable-5" + # # - "claude-opus-5" + # # - "claude-opus-4-8" + # # - "claude-sonnet-4-6" + # # - "claude-3-7-sonnet-20250219" + # # - "claude-3-5-sonnet-v2@20241022" + # # - "claude-3-5-haiku@20241022" + # + # # Option 2: Object format with custom visible names (RECOMMENDED) + # # The key is the visible model name shown in the UI (can be any name you want) + # # The deploymentName is the actual Vertex AI model ID used for API calls + # # You can use friendly names (avoid spaces for cleaner YAML) or technical IDs as keys + # models: + # claude-fable-5.1: + # deploymentName: claude-fable-5-1 + # claude-fable-5: + # deploymentName: claude-fable-5 + # claude-opus-5: + # deploymentName: claude-opus-5 + # claude-opus-4.8: + # deploymentName: claude-opus-4-8 + # claude-opus-4.5: + # deploymentName: claude-opus-4-5@20251101 + # claude-sonnet-4: + # deploymentName: claude-sonnet-4-6 + # claude-3.7-sonnet: + # deploymentName: claude-3-7-sonnet-20250219 + # claude-3.5-sonnet: + # deploymentName: claude-3-5-sonnet-v2@20241022 + # claude-3.5-haiku: + # deploymentName: claude-3-5-haiku@20241022 + # + # # Option 3: Mixed format with default deploymentName + # # Set a default deploymentName and use boolean values for models + # # deploymentName: claude-sonnet-4-6 + # # models: + # # claude-sonnet-4: true # Will use the default deploymentName + # # claude-3.5-haiku: + # # deploymentName: claude-3-5-haiku@20241022 # Override for this model + + # No custom OpenAI-compatible providers are needed for local ministack testing — + # only `bedrock` below (routed through ministack to Ollama) is used. Kept as + # reference examples, matching the Mistral example already commented out below. + # custom: + # Anthropic-compatible Example (native `/v1/messages` API) + # Set `provider: anthropic` to use the native Anthropic client instead of the + # default OpenAI-compatible one — for Anthropic itself or Anthropic-compatible + # gateways (e.g. AI gateways, OpenCode Zen). The `baseURL` must be the API root + # the Anthropic SDK appends `/v1/messages` to. List models explicitly: model + # auto-fetch uses the OpenAI `/models` convention and is not used for this provider. + # - name: 'Claude-Compatible' + # provider: 'anthropic' + # apiKey: '${ANTHROPIC_API_KEY}' + # baseURL: 'https://api.anthropic.com' + # # (optional) headers forwarded on every request (e.g. for a reverse proxy); + # # values support the same placeholders as built-in endpoints. + # headers: + # anthropic-version: '2023-06-01' + # models: + # default: + # - 'claude-sonnet-4-5' + # - 'claude-opus-4-5' + # fetch: false + # titleConvo: true + # titleModel: 'claude-sonnet-4-5' + # # Agent activity groups: collapse each block of reasoning + tool calls + # # under a generated one-line header. Same shape as the title options. + # activityLabel: true + # activityModel: 'claude-3-5-haiku' + # # activityEndpoint: 'anthropic' # run labels on another endpoint's credentials + # # activityPrompt: 'Write a 5-9 word past-tense label...' + # # activityMaxPerRun: 20 # cost cap per response + # # activityCharLimit: 600 # per-entry prompt truncation + # # Parent phase summaries are an independent opt-in. They collapse 2+ + # # logical activities before the answer into one run-level summary. + # # activityPhaseLabel: true + # # activityPhaseModel: 'claude-3-5-haiku' # falls back to activity/title/run model + # # activityPhaseEndpoint: 'anthropic' # falls back to activity/run endpoint + # # activityPhasePrompt: 'Summarize the completed agent phase...' + # # activityPhaseMaxPerRun: 5 # cost cap per response + # # Live reasoning labels are also independent. They update one top-level + # # THINK heading in place and never create or shift message content parts. + # # Enabling this sends a bounded snapshot (up to 4,000 characters) of the + # # visible reasoning to the resolved label endpoint, which may be a different provider. + # # With Langfuse tracing enabled, that snapshot is also recorded as generation input + # # unless the active redaction policy suppresses the label call. + # # reasoningLabel: true + # # reasoningLabelModel: 'claude-3-5-haiku' # falls back to activity/title/run model + # # reasoningLabelEndpoint: 'anthropic' # falls back to activity/run endpoint + # # reasoningLabelPrompt: 'Describe the current reasoning direction...' + # # reasoningLabelMinChars: 500 + # # reasoningLabelUpdateChars: 400 + # # reasoningLabelUpdateIntervalMs: 3000 + # # A final rewrite may run immediately after a meaningful 120-character tail. + # # reasoningLabelMaxPerRun: 8 + # modelDisplayLabel: 'Claude (Compatible)' + + # Groq Example + # - name: 'groq' + # apiKey: '${GROQ_API_KEY}' + # baseURL: 'https://api.groq.com/openai/v1/' + # models: + # default: + # - 'llama3-70b-8192' + # - 'llama3-8b-8192' + # - 'llama2-70b-4096' + # - 'mixtral-8x7b-32768' + # - 'gemma-7b-it' + # fetch: false + # titleConvo: true + # titleModel: 'mixtral-8x7b-32768' + # modelDisplayLabel: 'groq' + + # Mistral AI Example + # - name: 'Mistral' # Unique name for the endpoint + # # For `apiKey` and `baseURL`, you can use environment variables that you define. + # # recommended environment variables: + # apiKey: '${MISTRAL_API_KEY}' + # baseURL: 'https://api.mistral.ai/v1' + # + # # Models configuration + # models: + # # List of default models to use. At least one value is required. + # default: ['mistral-tiny', 'mistral-small', 'mistral-medium'] + # # Fetch option: Set to true to fetch models from API. + # fetch: true # Defaults to false. + # + # # Optional configurations + # + # # Title Conversation setting + # titleConvo: true # Set to true to enable title conversation + # + # # Title Method: Choose between "completion" or "functions". + # # titleMethod: "completion" # Defaults to "completion" if omitted. + # + # # Title Model: Specify the model to use for titles. + # titleModel: 'mistral-tiny' # Defaults to "gpt-3.5-turbo" if omitted. + # + # # Summarize setting: Set to true to enable summarization. + # # summarize: false + # + # # Summary Model: Specify the model to use if summarization is enabled. + # # summaryModel: "mistral-tiny" # Defaults to "gpt-3.5-turbo" if omitted. + # + # # The label displayed for the AI model in messages. + # modelDisplayLabel: 'Mistral' # Default is "AI" when not set. + # + # # Add additional parameters to the request. Default params will be overwritten. + # # addParams: + # # safe_prompt: true # This field is specific to Mistral AI: https://docs.mistral.ai/api/ + # + # # Drop Default params parameters from the request. See default params in guide linked below. + # # NOTE: For Mistral, it is necessary to drop the following parameters or you will encounter a 422 Error: + # dropParams: ['stop', 'user', 'frequency_penalty', 'presence_penalty'] + + # OpenRouter Example + # - name: 'OpenRouter' + # # For `apiKey` and `baseURL`, you can use environment variables that you define. + # # recommended environment variables: + # apiKey: '${OPENROUTER_KEY}' + # baseURL: 'https://openrouter.ai/api/v1' + # headers: + # x-librechat-body-parentmessageid: '{{LIBRECHAT_BODY_PARENTMESSAGEID}}' + # models: + # default: ['meta-llama/llama-3-70b-instruct'] + # fetch: true + # titleConvo: true + # titleModel: 'meta-llama/llama-3-70b-instruct' + # # Recommended: Drop the stop parameter from the request as Openrouter models use a variety of stop tokens. + # dropParams: ['stop'] + # modelDisplayLabel: 'OpenRouter' + + bedrock: + baseURL: 'http://ministack:${MINISTACK_PORT}' + models: + # Keep in sync with OLLAMA_MODEL in .env (LibreChat does not expand ${ENV} here). + - 'qwen3.5:0.8b' + + # AWS Bedrock Example + # Note: Bedrock endpoint is configured via environment variables + # bedrock: + # # Models Configuration + # # Specify which models are available (equivalent to BEDROCK_AWS_MODELS env variable) + # models: + # - "anthropic.claude-3-7-sonnet-20250219-v1:0" + # - "anthropic.claude-3-5-sonnet-20241022-v2:0" + # + # # Inference Profiles Configuration + # # Maps model IDs to their inference profile ARNs + # # IMPORTANT: The model ID (key) MUST be a valid AWS Bedrock model ID that you've added to the models list above + # # The ARN (value) is the inference profile you wish to map to for that model + # # Both the model ID and ARN are sent to AWS - the model ID for validation/metadata, the ARN for routing + # inferenceProfiles: + # "us.anthropic.claude-sonnet-4-6": "${BEDROCK_INFERENCE_PROFILE_CLAUDE_SONNET}" + # "anthropic.claude-3-7-sonnet-20250219-v1:0": "arn:aws:bedrock:us-west-2:123456789012:application-inference-profile/abc123" + # + # # Guardrail Configuration + # guardrailConfig: + # guardrailIdentifier: "your-guardrail-id" + # guardrailVersion: "1" + # + # # Trace behavior for debugging (optional) + # # - "enabled": Include basic trace information about guardrail assessments + # # - "enabled_full": Include comprehensive trace details (recommended for debugging) + # # - "disabled": No trace information (default) + # # Trace output is logged to application log files for compliance auditing + # trace: "enabled" + +modelSpecs: + list: + # - name: 'ministack-granite-4.2-3b' + # label: 'MiniStack (Granite 4.2 3B)' + # description: 'Local Granite inference through the MiniStack Bedrock service' + # preset: + # endpoint: 'bedrock' + # model: 'granite4.2:3b' + - name: 'ministack-qwen3.5-0.8b' + label: 'MiniStack (Qwen 3.5 0.8B)' + description: 'Local Qwen inference through the MiniStack Bedrock service' + preset: + endpoint: 'bedrock' + model: 'qwen3.5:0.8b' + +# Example modelSpecs configuration showing grouping options +# The 'group' field organizes model specs in the UI selector: +# - If 'group' matches an endpoint name (e.g., "openAI", "groq"), the spec appears nested under that endpoint +# - If 'group' is a custom name (doesn't match any endpoint), it creates a separate collapsible section +# - If 'group' is omitted, the spec appears as a standalone item at the top level +# +# The 'groupIcon' field sets an icon for custom groups: +# - Only needs to be set on one spec per group (first one is used) +# - Can be a URL or a built-in endpoint key (e.g., "openAI", "anthropic", "groq") +# modelSpecs: +# list: +# # Example 1: Nested under an endpoint (grouped with openAI endpoint) +# - name: "gpt-4o" +# label: "GPT-4 Optimized" +# description: "Most capable GPT-4 model with multimodal support" +# # default: true # Hard admin default; takes precedence over prior user choices +# # softDefault: true # First-time default only; skipped after a user selects a model/spec/agent +# # showInMenu: false # Hide from the model selector while keeping explicit `spec` access +# group: "openAI" # String value matching the endpoint name +# preset: +# endpoint: "openAI" +# model: "gpt-4o" +# # Skills can be enabled per model spec. Use true for the user's active +# # accessible skill catalog, false to force skills off, or a name list as +# # a strict allowlist for catalog, manual, and always-apply resolution. +# # skills: ["brand-guidelines", "code-review"] +# # Subagents can be enabled per model spec. `allowSelf: true` lets the +# # ephemeral agent spawn a fresh isolated copy of itself for focused work. +# # subagents: +# # enabled: true +# # allowSelf: true +# # agent_ids: [] +# +# # Example 2: Nested under a custom endpoint (grouped with groq endpoint) +# - name: "llama3-70b-8192" +# label: "Llama 3 70B" +# description: "Fastest inference available - great for quick responses" +# group: "groq" # String value matching your custom endpoint name from endpoints.custom +# preset: +# endpoint: "groq" +# model: "llama3-70b-8192" +# +# # Example 3: Custom group with icon (creates a separate collapsible section) +# - name: "coding-assistant" +# label: "Coding Assistant" +# description: "Specialized for coding tasks" +# group: "my-assistants" # Custom string - doesn't match any endpoint, so creates its own group +# groupIcon: "https://example.com/icons/assistants.png" # Icon URL for the group +# preset: +# endpoint: "openAI" +# model: "gpt-4o" +# instructions: "You are an expert coding assistant..." +# temperature: 0.3 +# +# - name: "writing-assistant" +# label: "Writing Assistant" +# description: "Specialized for creative writing" +# group: "my-assistants" # Same custom group name - both specs appear in same section +# # No need to set groupIcon again - the first spec's icon is used +# preset: +# endpoint: "anthropic" +# model: "claude-sonnet-4" +# instructions: "You are a creative writing expert..." +# +# # Example 4: Custom group using built-in icon key +# - name: "fast-models" +# label: "Fast Response Model" +# group: "Fast Models" +# groupIcon: "groq" # Uses the built-in Groq icon +# preset: +# endpoint: "groq" +# model: "llama3-8b-8192" +# +# # Example 5: Standalone (no group - appears at top level) +# - name: "general-assistant" +# label: "General Assistant" +# description: "General purpose assistant" +# # No 'group' field - appears as standalone item at top level (not nested) +# # hideBadgeRow: true # Optional: hides the tool badge row for this spec +# preset: +# endpoint: "openAI" +# model: "gpt-4o-mini" + +# Automatic conversation summarization (optional) +# summarization: +# enabled: true +# provider: "openAI" +# model: "gpt-4o-mini" +# retainRecent: +# turns: 2 # Keep the newest complete user/assistant turns outside the summary +# tokens: 2000 # Also preserve up to this many recent tokens + +# fileConfig: +# fileContextSizeLimit: 128 # Maximum aggregate model-bound attachment size in one agent turn (MB) +# fileContextCharLimit: 1000000 # Maximum aggregate extracted-text characters in one agent turn +# endpoints: +# agents: +# fileLimit: 10 +# assistants: +# fileLimit: 5 +# fileSizeLimit: 10 # Maximum size for an individual file in MB +# totalSizeLimit: 50 # Maximum total size for all files in a single request in MB +# supportedMimeTypes: +# - "image/.*" +# - "application/pdf" +# openAI: +# disabled: true # Disables file uploading to the OpenAI endpoint +# default: +# totalSizeLimit: 20 +# YourCustomEndpointName: +# fileLimit: 2 +# fileSizeLimit: 5 +# serverFileSizeLimit: 100 # Global server file size limit in MB +# avatarSizeLimit: 2 # Limit for user avatar image size in MB +# imageGeneration: # Image Gen settings, either percentage or px +# percentage: 100 +# px: 1024 +# # Client-side image resizing to prevent upload errors +# # Users can toggle this in Settings > Chat. Setting `enabled` here overrides +# # that choice for everyone and locks the toggle; omit it to leave users in control. +# clientImageResize: +# enabled: false # Enable/disable client-side image resizing (default: false) +# maxWidth: 1900 # Maximum width for resized images (default: 1900) +# maxHeight: 1900 # Maximum height for resized images (default: 1900) +# quality: 0.92 # JPEG quality for compression (0.0-1.0, default: 0.92) +# # See the Custom Configuration Guide for more information on Assistants Config: +# # https://www.librechat.ai/docs/configuration/librechat_yaml/object_structure/assistants_endpoint + +# Web Search Configuration (optional) +# webSearch: +# # Jina Reranking Configuration +# jinaApiKey: '${JINA_API_KEY}' # Your Jina API key +# jinaApiUrl: '${JINA_API_URL}' # Custom Jina API URL (optional, defaults to https://api.jina.ai/v1/rerank) +# # Other rerankers +# cohereApiKey: '${COHERE_API_KEY}' +# # Search providers +# serperApiKey: '${SERPER_API_KEY}' +# searxngInstanceUrl: '${SEARXNG_INSTANCE_URL}' +# searxngApiKey: '${SEARXNG_API_KEY}' +# # Tavily (search provider and/or scraper) +# tavilyApiKey: '${TAVILY_API_KEY}' +# # Keenable (search provider; keyless by default, a key only lifts rate limits) +# keenableApiKey: '${KEENABLE_API_KEY}' +# # Content scrapers +# firecrawlApiKey: '${FIRECRAWL_API_KEY}' +# firecrawlApiUrl: '${FIRECRAWL_API_URL}' +# # Outbound search and scrape requests are validated at connect time against +# # their resolved IP and blocked from reaching private, loopback, link-local, +# # or cloud-metadata space. `allowedAddresses` is an SSRF exemption list, NOT a +# # strict whitelist: hostname/IP + port pairs listed here bypass that block for +# # one deliberately-private endpoint (for example a self-hosted SearXNG +# # instance); public destinations continue to work normally. +# # +# # Entries must include a port: `host:port`, `private.ip:port`, or `[ipv6]:port`. +# # Do not use URLs, paths, CIDR ranges, bare hosts/IPs, or public IP literals. +# # A hostname entry trusts whatever IP that name resolves to on the listed port, +# # so only list hosts you fully control and whose DNS cannot be repointed by an +# # attacker. Listing an attacker-controllable or DNS-rebindable host re-opens the +# # private-address path this guard closes. Prefer literal IPs where you can. +# # +# # Self-hosted endpoints need an entry. A private destination such as +# # `http://searxng:8080`, `http://firecrawl:3002`, or `http://127.0.0.1:8080` is +# # blocked once this guard is active, so list it here or those requests will fail. +# # +# # A proxy from `HTTP_PROXY`, `HTTPS_PROXY`, or `ALL_PROXY` (either case) is +# # exempted automatically and needs no entry. That exemption is applied to the +# # whole tool rather than per destination, so a host `NO_PROXY` sends direct also +# # carries it. Note that when a proxy carries the request the proxy resolves the +# # destination, so destination egress policy is the proxy's to enforce, and for +# # https targets the proxy's own tunnel replaces this guard entirely. +# # allowedAddresses: +# # - 'searxng:8080' +# # - '127.0.0.1:8080' +# +# Tavily as both search and scraper provider example: +# webSearch: +# searchProvider: tavily +# scraperProvider: tavily +# tavilyApiKey: '${TAVILY_API_KEY}' +# # Optional: custom API URLs (defaults to https://api.tavily.com/search and https://api.tavily.com/extract) +# # tavilySearchUrl: '${TAVILY_SEARCH_URL}' +# # tavilyExtractUrl: '${TAVILY_EXTRACT_URL}' +# tavilySearchOptions: +# searchDepth: basic # 'basic', 'advanced', 'fast', or 'ultra-fast' (default: basic) +# maxResults: 5 # 1-20 results per search (default: 5) +# topic: general # 'general', 'news', or 'finance' +# # includeAnswer: basic # Include answer summary: true, 'basic', or 'advanced' +# # includeRawContent: markdown # Include raw content: true, 'markdown', or 'text' +# # includeImages: true # Include images in results +# # includeFavicon: true # Include favicon URL for each result +# # chunksPerSource: 3 # Chunks per source, only with 'advanced' depth (1-3) +# # safeSearch: false # Override Tavily safe_search filtering (true is enterprise-only) +# # includeDomains: # Restrict search to specific domains (max 300) +# # - 'example.com' +# # - 'docs.example.com' +# # excludeDomains: # Exclude specific domains from results (max 150) +# # - 'spam.com' +# # timeRange: week # 'day', 'week', 'month', or 'year' +# # timeout: 15000 # HTTP request timeout in milliseconds (max 120000) +# tavilyScraperOptions: +# extractDepth: basic # 'basic' (1 credit/5 URLs) or 'advanced' (2 credits/5 URLs, more thorough) +# # includeImages: false # Include images extracted from URLs +# # includeFavicon: false # Include favicon URL for each result +# # format: markdown # 'markdown' (default) or 'text' (plain text, may increase latency) +# # timeout: 15000 # HTTP request timeout in milliseconds (max 120000); Tavily Extract receives seconds clamped to 1-60 +# +# Keenable as both search provider and scraper example — a fully keyless web +# search stack: no API key for any of the three categories. The public search and +# fetch endpoints are rate-limited; a key only lifts the cap. +# webSearch: +# searchProvider: keenable +# scraperProvider: keenable +# rerankerType: none # Rerankers all require a key; 'none' skips reranking +# # keenableApiKey: '${KEENABLE_API_KEY}' # Optional: a key only lifts rate limits (public endpoints used when unset) +# # keenableApiUrl: '${KEENABLE_API_URL}' # Optional: search API URL override (fetch uses KEENABLE_FETCH_URL) +# keenableSearchOptions: +# maxResults: 5 # 1-20 results per search (default: 8) +# # site: example.com # Restrict search to a single domain +# # attributionTitle: LibreChat # X-Keenable-Title header sent with each request +# # timeout: 15000 # HTTP request timeout in milliseconds (max 120000) +# keenableScraperOptions: +# # attributionTitle: LibreChat # X-Keenable-Title header sent with each fetch +# timeout: 15000 # HTTP request timeout in milliseconds (max 120000) +# +# Keenable search paired with another scraper (Firecrawl here) also works; each +# category is configured independently: +# webSearch: +# searchProvider: keenable +# scraperProvider: firecrawl +# firecrawlApiKey: '${FIRECRAWL_API_KEY}' +# +# The reverse (someone else's search, Keenable as the scraper) also works. Pin +# the stack for every user as shown below, or leave the providers unpinned so +# each user can save this same keyless combination in the API-key dialog: +# webSearch: +# searchProvider: searxng +# searxngInstanceUrl: '${SEARXNG_INSTANCE_URL}' +# scraperProvider: keenable +# rerankerType: none +# +# SearXNG as the search provider example: +# webSearch: +# searchProvider: searxng +# searxngInstanceUrl: '${SEARXNG_INSTANCE_URL}' +# # searxngApiKey: '${SEARXNG_API_KEY}' +# searxngSearchOptions: +# # Engines your instance should query. Accepts a comma-separated string or a list. +# # Defaults to 'google,bing,duckduckgo'; DuckDuckGo serves CAPTCHAs to most +# # self-hosted instances, so override it when results come back empty. +# # +# # Names must match engines enabled on your own instance. SearXNG ignores an +# # engine it does not know instead of reporting an error, so a typo or a +# # disabled engine shows up as fewer results rather than a failure. Check the +# # enabled list at https://your-instance/config before setting this. +# engines: +# - google +# - bing +# - startpage +# - qwant +# # language: en # Result language code (default: 'all') +# # timeRange: month # 'day', 'month', or 'year' +# # timeout: 10000 # HTTP request timeout in milliseconds (1-120000, default: 10000) + +# Memory configuration for user memories +# memory: +# # (optional) Disable memory functionality +# disabled: false +# # (optional) Restrict memory keys to specific values to limit memory storage and improve consistency +# validKeys: ["preferences", "work_info", "personal_info", "skills", "interests", "context"] +# # (optional) Maximum token limit for stored memory values +# tokenLimit: 10000 +# # (optional) Maximum tokens from recent chat sent to the memory agent before truncation +# maxInputTokens: 12000 +# # (optional) Enable personalization features (defaults to true if memory is configured) +# # When false, users will not see the Personalization tab in settings +# personalize: true +# # (optional) Memory agent configuration for automatic memory updates from chat messages. +# # If omitted, users can still create, edit, delete, and reference memories manually. +# agent: +# # Explicitly enables automatic memory updates from chat messages. +# enabled: true +# # Option 1: Use existing agent by ID +# id: "your-memory-agent-id" +# # Option 2: Define agent inline +# # provider: "openai" +# # model: "gpt-4o-mini" +# # instructions: "You are a memory management assistant. Store and manage user information accurately." +# # model_parameters: +# # temperature: 0.1 + +# Apply opt-in content filters to source-classified submitted and reusable content. +# `filters` is base-config-only: database overrides and tombstones cannot add, +# change, or remove this policy for individual users, groups, or roles. +# In multi-replica deployments, use a coordinated deploy or restart and verify +# every replica loaded the same base config before considering policy active; +# local cache invalidation is not a rollout barrier. +# Omit `filters` or a source to leave that scope disabled. Omitting a source's +# `pii` block disables its source-aware detectors; for messages, `inspect` can +# still change how an enabled legacy `messageFilter.pii` attributes old rows. +# Omit `fields` to filter every supported field for an enabled source. +# `starterPatterns` and `customPatterns` can be configured independently +# under any source so each input surface can use a different policy. Starter +# ids are closed to the catalog below. Custom regexes use bounded, +# linear-time syntax; unsupported constructs are rejected at config load. +# `action` defaults to `block`. Set it to `audit` for a shadow rollout that +# records raw-free findings without rejecting or modifying content. Redaction +# is intentionally not an action yet because safe replacement requires a +# mutation-aware boundary and match spans; configuring an unsupported action +# is rejected rather than silently treated as enforcement. +# Omit `starterPatterns` to enable the full starter catalog; set it to `[]` +# to disable starters while retaining any configured custom patterns. +# Enabling or changing this policy does not rewrite or delete stored records. +# Current policy rechecks protected fields when they are resubmitted and when +# records are copied, shared, or become model-bound. Safe partial metadata edits +# can succeed so records remain repairable, but persisted protected fields remain +# unusable on those paths until repaired. Protected prompt or preset fields may +# be blanked with `contentFilterBlocked: true` in management views. Prompt-group +# metadata blocked by policy returns an explicit error on direct GET, while +# collection and reuse responses omit that group. +# Automatic memory maintenance may log and skip a rejected background update +# while allowing the main chat response to continue. +# Legacy assistant rows without provenance default to `model_output`, preserving +# legacy behavior. Inventory or migrate those records before relying on retroactive +# enforcement. Opting into `inspect` treats otherwise unattributed assistant content, +# including selected attachment projections, as submitted; explicit model provenance +# remains exempt. +# Roll out strict file inspection deliberately: `uninspectable: block` can make +# older opaque files unavailable for reuse until inspectable text is present. +# filters: +# messages: +# unattributedAssistantContent: model_output # `model_output` (default) or `inspect` +# pii: +# action: audit # `block` (default) or `audit` +# fields: [name, text, summary, quote, answer, decision_response, decision_reason, content_part, attachment_reference, assembled_context] +# starterPatterns: [sk_prefix, bearer_header, api_key_header] +# customPatterns: +# - id: organization_identifier +# label: Organization identifier +# regex: "ORG-[A-Z0-9]{12}" +# prompts: +# pii: +# fields: [name, description, oneliner, category, command, text, preset_text, system, context, instructions, additional_instructions, greeting, example_input, example_output] +# agentInstructions: +# pii: +# fields: [name, category, description, instructions, additional_instructions, edge_description, edge_prompt, edge_prompt_key, artifacts, support_contact_name, support_contact_email] +# conversationStarters: +# pii: +# fields: [text] +# conversationTitles: +# pii: +# fields: [title] +# feedback: +# pii: +# fields: [text] +# skills: +# pii: +# fields: [name, display_title, description, category, frontmatter, instructions, imported_text, file_name, file_text] +# memories: +# pii: +# fields: [key, value, summary] +# files: +# pii: +# fields: [name, content, extracted_text, transcript, uri] +# # `allow` is the compatibility default. `block` rejects opaque or +# # oversized selected file content before provider/storage side effects. +# uninspectable: block +# toolArguments: +# pii: +# fields: [name, arguments, output] +# modelParameters: +# pii: +# fields: [stop, request_fields, response_format, metadata] +# actionMetadata: +# pii: +# fields: [raw_spec, domain, privacy_policy_url, authorization_type, custom_auth_header, authorization_content_type, authorization_url, client_url, scope, token_exchange_method, api_key, oauth_client_id, oauth_client_secret] + +# Legacy message-only filter configuration. Existing deployments can continue +# using this shape while migrating message policy into `filters.messages`. +# When both shapes are configured, both policies apply. Prefer +# `filters.messages` for centrally managed source-aware policy. +# messageFilter: +# pii: +# # (optional) Pick a subset of the starter catalog by id; omit to +# # enable all starters (sk_prefix, bearer_header, api_key_header). +# starterPatterns: [sk_prefix, bearer_header, api_key_header] +# # (optional) Operator-defined patterns. Each entry needs id, label, +# # and a regex in RE2 syntax and semantics (RE2 is a linear-time engine +# # with no catastrophic backtracking; a few escapes such as \p, \A, and +# # \s differ from JavaScript). Backreferences and lookaround are not +# # supported; the regex is validated against the RE2 engine at config +# # load time and a pattern it cannot compile is rejected. +# customPatterns: +# - id: anthropic_api_key +# label: Anthropic API key +# regex: "sk-ant-[A-Za-z0-9_-]{20,}" diff --git a/librechat.public.yaml b/librechat.public.yaml new file mode 100644 index 00000000000..afd27e820f3 --- /dev/null +++ b/librechat.public.yaml @@ -0,0 +1,98 @@ +# plastic-ai — public environment (AWS profile UCL-Public) config +version: 1.3.16 +cache: true + +fileStrategy: "s3" + +interface: + customWelcome: 'Welcome to LibreChat! Enjoy your experience.' + fileSearch: true + + privacyPolicy: + externalUrl: 'https://librechat.ai/privacy-policy' + openNewTab: true + + termsOfService: + externalUrl: 'https://librechat.ai/tos' + openNewTab: true + modalAcceptance: true + modalTitle: 'Terms of Service for plastic-ai' + modalContent: | + Replace with your Terms of Service. + + modelSelect: true + parameters: true + presets: false + prompts: + use: false + create: false + share: false + public: false + bookmarks: false + multiConvo: true + # File search runs through agents, so they are on here as in the sandbox. + agents: + use: true + create: true + share: false + public: false + peoplePicker: + users: false + groups: false + roles: false + marketplace: + use: false + fileCitations: true + feedback: true + +registration: + socialLogins: ["openid"] + +actions: + allowedDomains: + - 'google.com' + +endpoints: + agents: + capabilities: ["file_search", "web_search", "context", "ask_user_question", "tools"] + + bedrock: + models: + - 'eu.anthropic.claude-sonnet-5' + inferenceProfiles: + 'eu.anthropic.claude-sonnet-5': 'arn:aws:bedrock:eu-west-2:575062583775:inference-profile/eu.anthropic.claude-sonnet-5' + # chaild-test-guardrail-dev-v2; the task role may apply it (bedrock_guardrail_arns). + guardrailConfig: + guardrailIdentifier: 'arn:aws:bedrock:eu-west-2:575062583775:guardrail/qpq72rkzv8qy' + guardrailVersion: '1' + trace: 'enabled' + + custom: + # The PII proxy service in the same VPC (Cloud Map: pii-proxy.plastic-ai.local). + # It takes no credential; the key is a placeholder the client requires. + - name: 'PII Proxy' + apiKey: 'dummy' + baseURL: 'http://pii-proxy.plastic-ai.local:8000/v1' + models: + default: + - 'eu.anthropic.claude-sonnet-5' + fetch: false + headers: + X-Conversation-ID: '{{LIBRECHAT_BODY_CONVERSATIONID}}' + titleConvo: false + modelDisplayLabel: 'Claude Sonnet 5' + +modelSpecs: + list: + - name: 'bedrock-claude-sonnet-5' + label: 'Claude Sonnet 5 (Bedrock)' + description: 'Anthropic Claude Sonnet 5 via AWS Bedrock (eu-west-2, EU geo profile)' + preset: + endpoint: 'bedrock' + model: 'eu.anthropic.claude-sonnet-5' + - name: 'bedrock-claude-sonnet-5-pii-proxy' + label: 'Claude Sonnet 5 (PII Proxy)' + description: 'Claude Sonnet 5 routed through the PII proxy' + preset: + endpoint: 'PII Proxy' + model: 'eu.anthropic.claude-sonnet-5' diff --git a/librechat.sandbox.yaml b/librechat.sandbox.yaml new file mode 100644 index 00000000000..bf815855ff8 --- /dev/null +++ b/librechat.sandbox.yaml @@ -0,0 +1,127 @@ +# plastic-ai — real AWS (Bedrock) config +version: 1.3.16 +cache: true + +fileStrategy: "s3" + +interface: + customWelcome: 'Welcome to LibreChat! Enjoy your experience.' + fileSearch: true + + privacyPolicy: + externalUrl: 'https://librechat.ai/privacy-policy' + openNewTab: true + + termsOfService: + externalUrl: 'https://librechat.ai/tos' + openNewTab: true + modalAcceptance: true + modalTitle: 'Terms of Service for plastic-ai' + modalContent: | + Replace with your Terms of Service. + + modelSelect: true + parameters: true + presets: false + prompts: + use: false + create: false + share: false + public: false + bookmarks: false + multiConvo: true + agents: + use: true + create: true + share: false + public: false + peoplePicker: + users: false + groups: false + roles: false + marketplace: + use: false + fileCitations: true + feedback: true + # The model calls and tool spans behind each response, read from Langfuse and + # shown to the conversation's owner only. Inert until the stack points the app + # at Langfuse (observability_enabled and langfuse_domain in sandbox.tfvars). + traceViewer: + enabled: true + showInputOutput: false + +registration: + socialLogins: ["openid"] + +actions: + allowedDomains: + - 'google.com' + +endpoints: + agents: + capabilities: ["file_search", "web_search", "context", "ask_user_question", "tools"] + + # The sandbox is an AWS channel-program account: Bedrock refuses Anthropic + # models there. These are the models it does serve on demand in eu-west-2, so + # requests stay in London; each was checked with streaming and tool use. + bedrock: + models: + - 'amazon.nova-lite-v1:0' + - 'openai.gpt-oss-120b-1:0' + - 'qwen.qwen3-235b-a22b-2507-v1:0' + - 'moonshotai.kimi-k2.5' + - 'deepseek.v3.2' + - 'mistral.devstral-2-123b' + - 'zai.glm-5' + - 'nvidia.nemotron-super-3-120b' + +modelSpecs: + list: + - name: 'bedrock-nova-lite' + label: 'Nova Lite (Bedrock)' + description: 'Amazon Nova Lite v1 via AWS Bedrock (eu-west-2, on-demand)' + preset: + endpoint: 'bedrock' + model: 'amazon.nova-lite-v1:0' + - name: 'bedrock-gpt-oss-120b' + label: 'gpt-oss-120b (Bedrock)' + description: 'OpenAI open-weight 120B model via AWS Bedrock (eu-west-2, on-demand)' + preset: + endpoint: 'bedrock' + model: 'openai.gpt-oss-120b-1:0' + - name: 'bedrock-qwen3-235b' + label: 'Qwen3 235B (Bedrock)' + description: 'Qwen3 235B A22B 2507 via AWS Bedrock (eu-west-2, on-demand)' + preset: + endpoint: 'bedrock' + model: 'qwen.qwen3-235b-a22b-2507-v1:0' + - name: 'bedrock-kimi-k2-5' + label: 'Kimi K2.5 (Bedrock)' + description: 'Moonshot AI Kimi K2.5 via AWS Bedrock (eu-west-2, on-demand)' + preset: + endpoint: 'bedrock' + model: 'moonshotai.kimi-k2.5' + - name: 'bedrock-deepseek-v3-2' + label: 'DeepSeek V3.2 (Bedrock)' + description: 'DeepSeek V3.2 via AWS Bedrock (eu-west-2, on-demand)' + preset: + endpoint: 'bedrock' + model: 'deepseek.v3.2' + - name: 'bedrock-devstral-2' + label: 'Devstral 2 (Bedrock)' + description: 'Mistral AI Devstral 2 123B, tuned for coding, via AWS Bedrock (eu-west-2, on-demand)' + preset: + endpoint: 'bedrock' + model: 'mistral.devstral-2-123b' + - name: 'bedrock-glm-5' + label: 'GLM-5 (Bedrock)' + description: 'Z.ai GLM-5 via AWS Bedrock (eu-west-2, on-demand)' + preset: + endpoint: 'bedrock' + model: 'zai.glm-5' + - name: 'bedrock-nemotron-super-3' + label: 'Nemotron Super 3 (Bedrock)' + description: 'NVIDIA Nemotron Super 3 120B via AWS Bedrock (eu-west-2, on-demand)' + preset: + endpoint: 'bedrock' + model: 'nvidia.nemotron-super-3-120b' diff --git a/otel/clickhouse/analytics-init.sh b/otel/clickhouse/analytics-init.sh new file mode 100644 index 00000000000..6121bfac473 --- /dev/null +++ b/otel/clickhouse/analytics-init.sh @@ -0,0 +1,37 @@ +#!/bin/sh +# Creates the usage analytics views (analytics.sql) as the ClickHouse admin. +# +# The views read tables other services create: Langfuse's migrations and the +# HyperDX collector's schema. This waits for them, then applies the SQL. It runs +# as a one-shot Compose service locally and as a one-off ECS task on AWS +# (terraform/clickhouse.tf), on the ClickHouse image. +# +# Environment: CLICKHOUSE_HOST, CLICKHOUSE_USER, CLICKHOUSE_PASSWORD, and the SQL +# either inline in ANALYTICS_SQL (ECS has no bind mounts) or as a file at +# ANALYTICS_SQL_FILE. +set -eu + +client() { + clickhouse-client --host "$CLICKHOUSE_HOST" --user "$CLICKHOUSE_USER" --password "$CLICKHOUSE_PASSWORD" "$@" +} + +sql_file=/tmp/analytics.sql +if [ -n "${ANALYTICS_SQL:-}" ]; then + printf '%s' "$ANALYTICS_SQL" > "$sql_file" +else + cp "$ANALYTICS_SQL_FILE" "$sql_file" +fi + +attempts=0 +until [ "$(client --query "SELECT count() FROM system.tables WHERE (database, name) IN (('default', 'events_core'), ('default', 'scores'), ('hyperdx', 'otel_traces'))" 2>/dev/null || echo 0)" = "3" ]; do + attempts=$((attempts + 1)) + if [ "$attempts" -ge 60 ]; then + echo "Gave up after 10 minutes waiting for the Langfuse and HyperDX tables." >&2 + exit 1 + fi + echo "Waiting for the Langfuse and HyperDX tables..." + sleep 10 +done + +client --multiquery < "$sql_file" +echo "Usage analytics views are up to date: $(client --query "SELECT arrayStringConcat(groupArray(name), ', ') FROM system.tables WHERE database = 'analytics'")" diff --git a/otel/clickhouse/analytics.sql b/otel/clickhouse/analytics.sql new file mode 100644 index 00000000000..b0a4434c7af --- /dev/null +++ b/otel/clickhouse/analytics.sql @@ -0,0 +1,313 @@ +-- Usage analytics views, read by HyperDX's usage timeline and dashboards as the +-- hyperdx user (hyperdx.xml). Run by the ClickHouse admin through +-- analytics-init.sh, after Langfuse and the HyperDX collector have created their +-- tables. +-- +-- Each view runs with its definer's rights, so the hyperdx user needs no access +-- to the tables behind it. +-- +-- The views carry conversation content: each turn's message and reply, every +-- model call's full input (the prompt, history included) and output, and +-- feedback with its reason, free-text comment and metadata. Anyone who can sign +-- in to HyperDX can read them. Only span attributes other than the ones listed +-- stay out. +-- +-- Safe to re-run: the views are dropped and created again. Not CREATE OR +-- REPLACE: replacing swaps the definitions with renameat2(), which the EFS +-- (NFS) volume ClickHouse runs on in AWS does not support. Dropping goes in +-- reverse order of use, timeline first, as it reads the other views. + +CREATE DATABASE IF NOT EXISTS analytics; + +DROP VIEW IF EXISTS analytics.timeline; +DROP VIEW IF EXISTS analytics.chat_turns; +DROP VIEW IF EXISTS analytics.feedback; +DROP VIEW IF EXISTS analytics.llm_generations; +DROP VIEW IF EXISTS analytics.spans; +DROP VIEW IF EXISTS analytics.llm_traces; +DROP VIEW IF EXISTS analytics.ui_events; + +-- Named UI actions (client/src/lib/rum/actions.ts) and page views, from the +-- browser telemetry the HyperDX collector stores. +CREATE VIEW analytics.ui_events +DEFINER = CURRENT_USER SQL SECURITY DEFINER +AS SELECT + Timestamp AS time, + SpanName AS action, + SpanAttributes['userId'] AS user_id, + SpanAttributes['role'] AS user_role, + if(SpanName = 'spa-route-change', SpanAttributes['toPath'], SpanAttributes['route']) AS route, + if(SpanName = 'spa-route-change', SpanAttributes['fromPath'], '') AS from_route, + SpanAttributes['conversationId'] AS conversation_id, + SpanAttributes['endpoint'] AS endpoint, + SpanAttributes['model'] AS model, + SpanAttributes['agentId'] AS agent_id, + SpanAttributes['rating'] AS rating, + SpanAttributes['count'] AS file_count, + ServiceName AS service +FROM hyperdx.otel_traces +WHERE SpanName LIKE 'message.%' + OR SpanName IN ('model.switch', 'agent.switch', 'file.upload', 'conversation.new', 'spa-route-change'); + +-- Langfuse 4 stores the app's OpenTelemetry traces in events_full, one row per +-- span (agent, chain, tool, model call), each carrying its trace's user, +-- session (the LibreChat conversation) and trace name; events_core is the same +-- with input, output and metadata cut to 200 characters. Its older traces and +-- observations tables stay empty for this ingestion path. + +-- One row per response the app traced in Langfuse. +CREATE VIEW analytics.llm_traces +DEFINER = CURRENT_USER SQL SECURITY DEFINER +AS SELECT + trace_id, + min(start_time) AS time, + any(trace_name) AS name, + any(user_id) AS user_id, + any(session_id) AS session_id, + any(environment) AS environment, + any(tags) AS tags +FROM default.events_core FINAL +WHERE is_deleted = 0 +GROUP BY trace_id; + +-- Every span of those traces, whatever its type (agent, chain, tool, retriever, +-- model call, event), with its full input and output, in the column layout +-- HyperDX expects of a trace source, so its trace viewer shows each turn as a +-- tree: HyperDX's "Conversation traces" source reads it. SpanAttributes holds +-- everything else Langfuse records about the span: model settings, every usage +-- and cost figure, tool calls and the tools offered, tags, release, version and +-- its metadata (prefixed usage., cost., tool_definition. and metadata.). +CREATE VIEW analytics.spans +DEFINER = CURRENT_USER SQL SECURITY DEFINER +AS SELECT + toDateTime64(start_time, 9) AS Timestamp, + trace_id AS TraceId, + span_id AS SpanId, + parent_span_id AS ParentSpanId, + name AS SpanName, + toString(type) AS SpanKind, + multiIf(parent_span_id = '', 'chat', type = 'GENERATION', 'llm', lower(toString(type))) AS ServiceName, + -- Nanoseconds; a span still running counts as zero. + toUInt64(greatest(0, dateDiff('microsecond', start_time, ifNull(end_time, start_time)))) * 1000 AS Duration, + if(level = 'ERROR', 'Error', 'Unset') AS StatusCode, + status_message AS StatusMessage, + toString(level) AS Level, + user_id AS UserId, + session_id AS ConversationId, + trace_name AS TraceName, + toString(environment) AS Environment, + provided_model_name AS Model, + if(completion_start_time IS NULL, toInt64(0), dateDiff('millisecond', start_time, completion_start_time)) AS TimeToFirstTokenMs, + usage_details AS Usage, + total_cost AS Cost, + tool_call_names AS ToolCallNames, + input AS Input, + output AS Output, + mapFilter((k, v) -> v != '', mapConcat( + map( + 'model_parameters', model_parameters, 'release', release, 'version', version, + 'tags', arrayStringConcat(tags, ','), 'tool_call_names', arrayStringConcat(tool_call_names, ','), + 'tool_calls', arrayStringConcat(tool_calls, '\n'), 'prompt_name', prompt_name, + 'prompt_version', ifNull(toString(prompt_version), ''), 'trace_name', trace_name, + 'environment', toString(environment), 'status_message', status_message + ), + mapApply((k, v) -> (concat('usage.', k), toString(v)), CAST(usage_details, 'Map(String, UInt64)')), + mapApply((k, v) -> (concat('cost.', k), toString(v)), CAST(cost_details, 'Map(String, Decimal(18, 12))')), + mapApply((k, v) -> (concat('tool_definition.', k), v), tool_definitions), + mapFromArrays(arrayMap(n -> concat('metadata.', n), metadata_names), metadata_values) + )) AS SpanAttributes, + mapFilter((k, v) -> v != '', map( + 'service.name', service_name, 'service.version', service_version, + 'telemetry.sdk.name', telemetry_sdk_name, 'telemetry.sdk.version', telemetry_sdk_version + )) AS ResourceAttributes +FROM default.events_full FINAL +WHERE is_deleted = 0; + +-- One row per conversation turn: the trace's root span, whose input is what the +-- user sent and whose output is the reply, as Langfuse stores them (JSON for +-- structured values). +CREATE VIEW analytics.chat_turns +DEFINER = CURRENT_USER SQL SECURITY DEFINER +AS SELECT + TraceId AS trace_id, + Timestamp AS time, + TraceName AS name, + UserId AS user_id, + ConversationId AS session_id, + intDiv(Duration, 1000000) AS duration_ms, + Level AS level, + StatusMessage AS status_message, + Input AS input, + Output AS output, + SpanAttributes AS attributes +FROM analytics.spans +WHERE ParentSpanId = ''; + +-- Model calls within those traces: model, timing, time to first token, token +-- usage, cost, and the full input and output. +CREATE VIEW analytics.llm_generations +DEFINER = CURRENT_USER SQL SECURITY DEFINER +AS SELECT + SpanId AS generation_id, + TraceId AS trace_id, + TraceName AS trace_name, + UserId AS user_id, + ConversationId AS session_id, + Timestamp AS start_time, + intDiv(Duration, 1000000) AS duration_ms, + TimeToFirstTokenMs AS time_to_first_token_ms, + SpanName AS name, + Model AS model, + Usage AS usage_details, + Cost AS total_cost, + Level AS level, + StatusMessage AS status_message, + ToolCallNames AS tool_call_names, + Input AS input, + Output AS output, + SpanAttributes AS attributes +FROM analytics.spans +WHERE SpanKind = 'GENERATION'; + +-- Feedback and other scores on traces (thumbs up/down arrive here). The app +-- puts the reason in metadata['tag'] (packages/api/src/langfuse/feedback.ts) +-- and the reason with any text the user typed in comment. +CREATE VIEW analytics.feedback +DEFINER = CURRENT_USER SQL SECURITY DEFINER +AS SELECT + id AS score_id, + trace_id, + timestamp AS time, + name, + value, + string_value, + data_type, + source, + metadata['tag'] AS tag, + ifNull(comment, '') AS comment, + CAST(metadata, 'Map(String, String)') AS metadata +FROM default.scores FINAL +WHERE is_deleted = 0; + +-- Every event above on one timeline, one row per event, in the column layout +-- HyperDX expects of a log source (Timestamp, ServiceName, SeverityText, Body, +-- LogAttributes). HyperDX's "Usage timeline" source and dashboards read it +-- (otel/hyperdx/provision). ServiceName says where the event came from: `ui` +-- (browser), `chat` (a conversation turn, whose Body is the user's message), +-- `llm` (a model call) or `feedback` (a score). Input and Output hold the turn's +-- or model call's content; Reason and Comment the feedback's; StatusMessage why +-- a turn or call failed. TraceId opens the turn in HyperDX's trace viewer +-- (analytics.spans). Scores take their user and conversation from their trace. +CREATE VIEW analytics.timeline +DEFINER = CURRENT_USER SQL SECURITY DEFINER +AS SELECT + toDateTime64(time, 3) AS Timestamp, + 'ui' AS ServiceName, + 'info' AS SeverityText, + if(action = 'spa-route-change', 'page.view', toString(action)) AS Event, + if(action = 'spa-route-change', concat('page.view ', route), toString(action)) AS Body, + user_id AS UserId, + conversation_id AS ConversationId, + model AS Model, + toUInt64(0) AS InputTokens, + toUInt64(0) AS OutputTokens, + toUInt64(0) AS TotalTokens, + toFloat64(0) AS Cost, + toInt64(0) AS DurationMs, + rating AS Rating, + '' AS Reason, + '' AS Comment, + '' AS Input, + '' AS Output, + '' AS StatusMessage, + toInt64(0) AS TimeToFirstTokenMs, + '' AS TraceId, + mapFilter((k, v) -> v != '', map( + 'user_role', user_role, 'route', route, 'from_route', from_route, + 'endpoint', endpoint, 'agent_id', agent_id, 'file_count', file_count + )) AS LogAttributes +FROM analytics.ui_events +UNION ALL +SELECT + toDateTime64(c.time, 3), + 'chat', + multiIf(c.level = 'ERROR', 'error', c.level = 'WARNING', 'warn', 'info'), + 'chat.turn', + c.input, + c.user_id, + c.session_id, + '', + toUInt64(0), + toUInt64(0), + toUInt64(0), + toFloat64(0), + toInt64(c.duration_ms), + '', + '', + '', + c.input, + c.output, + c.status_message, + toInt64(0), + c.trace_id, + c.attributes +FROM analytics.chat_turns AS c +UNION ALL +SELECT + toDateTime64(g.start_time, 3), + 'llm', + multiIf(g.level = 'ERROR', 'error', g.level = 'WARNING', 'warn', g.level = 'DEBUG', 'debug', 'info'), + 'llm.generation', + concat('llm.generation ', g.model), + g.user_id, + g.session_id, + g.model, + -- Langfuse's `input` counts only uncached prompt tokens; prompt caching + -- puts most of LibreChat's system prompt under the cache keys. + g.usage_details['input'] + g.usage_details['input_cache_read'] + g.usage_details['input_cache_creation'], + g.usage_details['output'], + g.usage_details['total'], + toFloat64(g.total_cost), + toInt64(g.duration_ms), + '', + '', + '', + g.input, + g.output, + g.status_message, + g.time_to_first_token_ms, + g.trace_id, + mapUpdate(g.attributes, map('generation_id', g.generation_id, 'generation_name', g.name)) +FROM analytics.llm_generations AS g +UNION ALL +SELECT + f.time, + 'feedback', + if(f.name = 'user-feedback' AND f.value = 0, 'warn', 'info'), + multiIf(f.name != 'user-feedback', concat('score.', f.name), f.value = 1, 'feedback.thumbs_up', 'feedback.thumbs_down'), + multiIf( + f.name != 'user-feedback', concat('score.', f.name, ' ', toString(f.value)), + concat(if(f.value = 1, 'feedback.thumbs_up', 'feedback.thumbs_down'), if(f.comment != '', concat(' ', f.comment), '')) + ), + ifNull(t.user_id, ''), + ifNull(t.session_id, ''), + '', + toUInt64(0), + toUInt64(0), + toUInt64(0), + toFloat64(0), + toInt64(0), + multiIf(f.name != 'user-feedback', ifNull(f.string_value, toString(f.value)), f.value = 1, 'thumbsUp', 'thumbsDown'), + f.tag, + f.comment, + '', + '', + '', + toInt64(0), + ifNull(f.trace_id, ''), + mapUpdate( + mapFilter((k, v) -> v != '', f.metadata), + mapFilter((k, v) -> v != '', map('score_id', f.score_id, 'score_name', f.name, 'score_source', toString(f.source))) + ) +FROM analytics.feedback AS f +LEFT JOIN analytics.llm_traces AS t ON t.trace_id = f.trace_id; diff --git a/otel/clickhouse/hyperdx.xml b/otel/clickhouse/hyperdx.xml new file mode 100644 index 00000000000..837122294ab --- /dev/null +++ b/otel/clickhouse/hyperdx.xml @@ -0,0 +1,29 @@ + + + + + + + ::/0 + + default + default + + GRANT ALL ON hyperdx.* + GRANT SELECT ON analytics.* + + + + diff --git a/otel/hyperdx/provision/dashboards/llm-usage.json b/otel/hyperdx/provision/dashboards/llm-usage.json new file mode 100644 index 00000000000..7c2de7f5992 --- /dev/null +++ b/otel/hyperdx/provision/dashboards/llm-usage.json @@ -0,0 +1,181 @@ +{ + "name": "LLM usage", + "tags": ["usage"], + "filters": [ + { "id": "user", "name": "User", "type": "QUERY_EXPRESSION", "expression": "UserId", "source": "Usage timeline" }, + { "id": "model", "name": "Model", "type": "QUERY_EXPRESSION", "expression": "Model", "source": "Usage timeline" }, + { "id": "origin", "name": "Event source", "type": "QUERY_EXPRESSION", "expression": "ServiceName", "source": "Usage timeline" } + ], + "tiles": [ + { + "id": "calls", "x": 0, "y": 0, "w": 6, "h": 3, + "config": { + "name": "Model calls", "source": "Usage timeline", "displayType": "number", + "select": [{ "aggFn": "count", "valueExpression": "", "aggCondition": "" }], + "where": "ServiceName = 'llm'", "whereLanguage": "sql" + } + }, + { + "id": "tokens", "x": 6, "y": 0, "w": 6, "h": 3, + "config": { + "name": "Tokens", "source": "Usage timeline", "displayType": "number", + "select": [{ "aggFn": "sum", "valueExpression": "TotalTokens", "aggCondition": "" }], + "where": "ServiceName = 'llm'", "whereLanguage": "sql" + } + }, + { + "id": "cost", "x": 12, "y": 0, "w": 6, "h": 3, + "config": { + "name": "Cost (USD)", "source": "Usage timeline", "displayType": "number", + "select": [{ "aggFn": "sum", "valueExpression": "Cost", "aggCondition": "" }], + "where": "ServiceName = 'llm'", "whereLanguage": "sql" + } + }, + { + "id": "users", "x": 18, "y": 0, "w": 6, "h": 3, + "config": { + "name": "Active users", "source": "Usage timeline", "displayType": "number", + "select": [{ "aggFn": "count_distinct", "valueExpression": "UserId", "aggCondition": "" }], + "where": "UserId != ''", "whereLanguage": "sql" + } + }, + { + "id": "calls-by-model", "x": 0, "y": 3, "w": 12, "h": 8, + "config": { + "name": "Model calls by model", "source": "Usage timeline", "displayType": "stacked_bar", + "select": [{ "aggFn": "count", "valueExpression": "", "aggCondition": "" }], + "where": "ServiceName = 'llm'", "whereLanguage": "sql", "groupBy": "Model", "granularity": "auto" + } + }, + { + "id": "tokens-over-time", "x": 12, "y": 3, "w": 12, "h": 8, + "config": { + "name": "Tokens in and out", "source": "Usage timeline", "displayType": "stacked_bar", + "select": [ + { "aggFn": "sum", "valueExpression": "InputTokens", "aggCondition": "", "alias": "Input" }, + { "aggFn": "sum", "valueExpression": "OutputTokens", "aggCondition": "", "alias": "Output" } + ], + "where": "ServiceName = 'llm'", "whereLanguage": "sql", "granularity": "auto" + } + }, + { + "id": "cost-by-model", "x": 0, "y": 11, "w": 12, "h": 8, + "config": { + "name": "Cost by model (USD)", "source": "Usage timeline", "displayType": "stacked_bar", + "select": [{ "aggFn": "sum", "valueExpression": "Cost", "aggCondition": "" }], + "where": "ServiceName = 'llm'", "whereLanguage": "sql", "groupBy": "Model", "granularity": "auto" + } + }, + { + "id": "latency", "x": 12, "y": 11, "w": 12, "h": 8, + "config": { + "name": "Response time by model (p95, ms)", "source": "Usage timeline", "displayType": "line", + "select": [{ "aggFn": "quantile", "level": 0.95, "valueExpression": "DurationMs", "aggCondition": "" }], + "where": "ServiceName = 'llm'", "whereLanguage": "sql", "groupBy": "Model", "granularity": "auto" + } + }, + { + "id": "feedback", "x": 0, "y": 19, "w": 12, "h": 8, + "config": { + "name": "Feedback", "source": "Usage timeline", "displayType": "stacked_bar", + "select": [{ "aggFn": "count", "valueExpression": "", "aggCondition": "" }], + "where": "ServiceName = 'feedback'", "whereLanguage": "sql", "groupBy": "Event", "granularity": "auto" + } + }, + { + "id": "errors", "x": 12, "y": 19, "w": 12, "h": 8, + "config": { + "name": "Failed model calls", "source": "Usage timeline", "displayType": "stacked_bar", + "select": [{ "aggFn": "count", "valueExpression": "", "aggCondition": "" }], + "where": "ServiceName = 'llm' AND SeverityText = 'error'", "whereLanguage": "sql", "groupBy": "Model", "granularity": "auto" + } + }, + { + "id": "top-users", "x": 0, "y": 27, "w": 12, "h": 9, + "config": { + "name": "Usage by user", "source": "Usage timeline", "displayType": "table", + "select": [ + { "aggFn": "count", "valueExpression": "", "aggCondition": "", "alias": "Model calls" }, + { "aggFn": "sum", "valueExpression": "TotalTokens", "aggCondition": "", "alias": "Tokens" }, + { "aggFn": "sum", "valueExpression": "Cost", "aggCondition": "", "alias": "Cost (USD)" } + ], + "where": "ServiceName = 'llm'", "whereLanguage": "sql", "groupBy": "UserId" + } + }, + { + "id": "ui-actions", "x": 12, "y": 27, "w": 12, "h": 9, + "config": { + "name": "UI actions", "source": "Usage timeline", "displayType": "table", + "select": [ + { "aggFn": "count", "valueExpression": "", "aggCondition": "", "alias": "Events" }, + { "aggFn": "count_distinct", "valueExpression": "UserId", "aggCondition": "", "alias": "Users" } + ], + "where": "ServiceName = 'ui'", "whereLanguage": "sql", "groupBy": "Event" + } + }, + { + "id": "timeline", "x": 0, "y": 36, "w": 24, "h": 14, + "config": { + "name": "Event timeline", "source": "Usage timeline", "displayType": "search", + "select": "Timestamp, ServiceName, UserId, ConversationId, Body, Model, TotalTokens, Cost, DurationMs, Rating, Reason, Comment", + "where": "", "whereLanguage": "lucene" + } + }, + { + "id": "feedback-reasons", "x": 0, "y": 50, "w": 12, "h": 8, + "config": { + "name": "Feedback by reason", "source": "Usage timeline", "displayType": "stacked_bar", + "select": [{ "aggFn": "count", "valueExpression": "", "aggCondition": "" }], + "where": "ServiceName = 'feedback' AND Reason != ''", "whereLanguage": "sql", "groupBy": "Reason", "granularity": "auto" + } + }, + { + "id": "feedback-comments", "x": 12, "y": 50, "w": 12, "h": 8, + "config": { + "name": "Feedback with reasons and comments", "source": "Usage timeline", "displayType": "search", + "select": "Timestamp, UserId, ConversationId, Rating, Reason, Comment", + "where": "ServiceName = 'feedback'", "whereLanguage": "sql" + } + }, + { + "id": "conversations", "x": 0, "y": 58, "w": 24, "h": 14, + "config": { + "name": "Conversations (message and reply)", "source": "Usage timeline", "displayType": "search", + "select": "Timestamp, UserId, ConversationId, Input, Output", + "where": "ServiceName = 'chat'", "whereLanguage": "sql" + } + }, + { + "id": "tool-calls", "x": 0, "y": 72, "w": 12, "h": 8, + "config": { + "name": "Tool calls by tool", "source": "Conversation traces", "displayType": "stacked_bar", + "select": [{ "aggFn": "count", "valueExpression": "", "aggCondition": "" }], + "where": "SpanKind = 'TOOL'", "whereLanguage": "sql", "groupBy": "SpanName", "granularity": "auto" + } + }, + { + "id": "ttft", "x": 12, "y": 72, "w": 12, "h": 8, + "config": { + "name": "Time to first token by model (p95, ms)", "source": "Usage timeline", "displayType": "line", + "select": [{ "aggFn": "quantile", "level": 0.95, "valueExpression": "TimeToFirstTokenMs", "aggCondition": "" }], + "where": "ServiceName = 'llm' AND TimeToFirstTokenMs > 0", "whereLanguage": "sql", "groupBy": "Model", "granularity": "auto" + } + }, + { + "id": "steps", "x": 0, "y": 80, "w": 24, "h": 14, + "config": { + "name": "Agent steps and tool calls (input and output)", "source": "Conversation traces", "displayType": "search", + "select": "Timestamp, UserId, ConversationId, SpanKind, SpanName, Duration, Input, Output", + "where": "ServiceName NOT IN ('chat', 'llm')", "whereLanguage": "sql" + } + }, + { + "id": "failures", "x": 0, "y": 94, "w": 24, "h": 10, + "config": { + "name": "Errors (every step, with its message)", "source": "Conversation traces", "displayType": "search", + "select": "Timestamp, UserId, ConversationId, ServiceName, SpanName, Model, StatusMessage", + "where": "StatusCode = 'Error'", "whereLanguage": "sql" + } + } + ] +} diff --git a/otel/hyperdx/provision/provision.js b/otel/hyperdx/provision/provision.js new file mode 100644 index 00000000000..469b1a16cb1 --- /dev/null +++ b/otel/hyperdx/provision/provision.js @@ -0,0 +1,197 @@ +/** + * Provisions HyperDX's usage views into every HyperDX team: the sources in + * sources.json, the saved searches in searches.json and the dashboards in + * dashboards/. All three are matched by name, so a re-run updates them in place. + * + * HyperDX applies DEFAULT_SOURCES only to a team that has no sources yet, and + * its own dashboard provisioner needs the team's source ids, which differ per + * installation. So the files name their source ("Usage timeline"), their + * connection ("ClickHouse") and the sources a source links to (its + * traceSourceId, logSourceId, ...), and this script resolves those names per + * team. + * + * Runs on the HyperDX image (terraform/hyperdx.tf, and the + * hyperdx-provision service in docker-compose.override.yml), with HyperDX's own + * models and validation. Input is HYPERDX_PROVISION (the files as one JSON + * document: { sources, searches, dashboards }) or HYPERDX_PROVISION_DIR. + */ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const BUILD = '/app/packages/api/build'; +const { connectDB, mongooseConnection } = require(`${BUILD}/models`); +const Team = require(`${BUILD}/models/team`).default; +const Connection = require(`${BUILD}/models/connection`).default; +const Dashboard = require(`${BUILD}/models/dashboard`).default; +const { Source } = require(`${BUILD}/models/source`); +const { SavedSearch } = require(`${BUILD}/models/savedSearch`); +const { readDashboardFiles, syncDashboards } = require(`${BUILD}/tasks/provisionDashboards`); +const { SourceSchema } = require('/app/node_modules/@hyperdx/common-utils/dist/types'); + +/** Fields of a source that link it to another source, by id. */ +const SOURCE_LINKS = ['logSourceId', 'traceSourceId', 'metricSourceId', 'sessionSourceId']; + +function readJson(file) { + return JSON.parse(fs.readFileSync(file, 'utf8')); +} + +function loadInput() { + if (process.env.HYPERDX_PROVISION) { + return JSON.parse(process.env.HYPERDX_PROVISION); + } + const dir = process.env.HYPERDX_PROVISION_DIR; + if (!dir) { + throw new Error('HYPERDX_PROVISION or HYPERDX_PROVISION_DIR is required'); + } + const dashboardsDir = path.join(dir, 'dashboards'); + return { + sources: readJson(path.join(dir, 'sources.json')), + searches: readJson(path.join(dir, 'searches.json')), + dashboards: fs + .readdirSync(dashboardsDir) + .filter((file) => file.endsWith('.json')) + .map((file) => readJson(path.join(dashboardsDir, file))), + }; +} + +async function upsertSource(teamId, definition) { + const connection = await Connection.findOne({ team: teamId, name: definition.connection }); + if (!connection) { + throw new Error(`Source "${definition.name}": the team has no connection named "${definition.connection}"`); + } + const fields = { ...definition, connection: connection._id.toString() }; + const parsed = SourceSchema.safeParse({ ...fields, id: 'provisioned' }); + if (!parsed.success) { + throw new Error(`Source "${definition.name}" is invalid: ${JSON.stringify(parsed.error.issues)}`); + } + + const existing = await Source.findOne({ team: teamId, name: definition.name }); + if (existing && existing.kind !== definition.kind) { + throw new Error(`Source "${definition.name}" exists as a ${existing.kind} source, not ${definition.kind}`); + } + const Model = Source.discriminators[definition.kind]; + if (existing) { + await Model.updateOne({ _id: existing._id }, { $set: { ...fields, team: teamId } }, { runValidators: true }); + return existing._id.toString(); + } + const created = await Model.create({ ...fields, team: teamId }); + return created._id.toString(); +} + +function resolveSource(sourceIds, name, where) { + const id = sourceIds.get(name); + if (!id) { + throw new Error(`${where} refers to source "${name}", which the team does not have`); + } + return id; +} + +/** The dashboard as HyperDX stores it: every source name replaced by the team's id. */ +function withSourceIds(dashboard, sourceIds) { + const where = `Dashboard "${dashboard.name}"`; + return { + ...dashboard, + filters: (dashboard.filters ?? []).map((filter) => ({ + ...filter, + source: resolveSource(sourceIds, filter.source, where), + })), + tiles: dashboard.tiles.map((tile) => ({ + ...tile, + config: tile.config.source + ? { ...tile.config, source: resolveSource(sourceIds, tile.config.source, where) } + : tile.config, + })), + }; +} + +async function provisionDashboards(teamId, dashboards, sourceIds) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hyperdx-dashboards-')); + try { + dashboards.forEach((dashboard, index) => { + fs.writeFileSync(path.join(dir, `${index}.json`), JSON.stringify(withSourceIds(dashboard, sourceIds))); + }); + // HyperDX's own reader validates each file and skips (with a warning) any it + // rejects; a skipped dashboard is a bug in the files, so fail instead. + const valid = readDashboardFiles(dir); + if (valid.length !== dashboards.length) { + throw new Error(`${dashboards.length - valid.length} dashboard file(s) failed HyperDX's validation; see the warnings above`); + } + await syncDashboards(teamId, dir); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + + const names = dashboards.map((dashboard) => dashboard.name); + const stored = await Dashboard.countDocuments({ team: teamId, provisioned: true, name: { $in: names } }); + if (stored !== names.length) { + throw new Error(`Only ${stored} of ${names.length} dashboards were stored; see the errors above`); + } +} + +async function upsertSearch(teamId, search, sourceIds) { + const fields = { + ...search, + source: resolveSource(sourceIds, search.source, `Saved search "${search.name}"`), + team: teamId, + }; + await SavedSearch.updateOne({ team: teamId, name: search.name }, { $set: fields }, { upsert: true, runValidators: true }); +} + +async function findSourceIds(teamId) { + const sources = await Source.find({ team: teamId }, { name: 1 }).lean(); + return new Map(sources.map((source) => [source.name, source._id.toString()])); +} + +async function provisionTeam(teamId, input) { + // Sources link to each other by id, so they are written first without their + // links, then again with each linked source's name resolved to its id. + const unlinked = (definition) => + Object.fromEntries(Object.entries(definition).filter(([key]) => !SOURCE_LINKS.includes(key))); + for (const definition of input.sources) { + await upsertSource(teamId, unlinked(definition)); + } + const sourceIds = await findSourceIds(teamId); + for (const definition of input.sources) { + const links = SOURCE_LINKS.filter((key) => definition[key] != null); + if (links.length === 0) { + continue; + } + const where = `Source "${definition.name}"`; + await upsertSource(teamId, { + ...definition, + ...Object.fromEntries(links.map((key) => [key, resolveSource(sourceIds, definition[key], where)])), + }); + } + + for (const search of input.searches) { + await upsertSearch(teamId, search, sourceIds); + } + await provisionDashboards(teamId, input.dashboards, sourceIds); +} + +async function main() { + const input = loadInput(); + await connectDB(); + try { + const teams = await Team.find({}, { _id: 1 }).lean(); + if (teams.length === 0) { + console.log('No HyperDX team yet: register the first account, then run this again'); + return; + } + for (const team of teams) { + await provisionTeam(team._id.toString(), input); + } + console.log( + `Provisioned ${input.sources.length} source(s), ${input.searches.length} saved search(es) and ` + + `${input.dashboards.length} dashboard(s) into ${teams.length} team(s)`, + ); + } finally { + await mongooseConnection.close(); + } +} + +main().catch((error) => { + console.error(`Failed to provision HyperDX: ${error.message}`); + process.exit(1); +}); diff --git a/otel/hyperdx/provision/searches.json b/otel/hyperdx/provision/searches.json new file mode 100644 index 00000000000..d03d41736e4 --- /dev/null +++ b/otel/hyperdx/provision/searches.json @@ -0,0 +1,11 @@ +[ + { + "name": "Usage timeline", + "source": "Usage timeline", + "select": "Timestamp, ServiceName, UserId, ConversationId, Body, Model, TotalTokens, Cost, DurationMs, Rating, Reason, Comment, Input, Output, StatusMessage, TimeToFirstTokenMs", + "where": "", + "whereLanguage": "lucene", + "orderBy": "Timestamp DESC", + "tags": ["usage"] + } +] diff --git a/otel/hyperdx/provision/sources.json b/otel/hyperdx/provision/sources.json new file mode 100644 index 00000000000..6bc6fcfe6c6 --- /dev/null +++ b/otel/hyperdx/provision/sources.json @@ -0,0 +1,41 @@ +[ + { + "kind": "log", + "name": "Usage timeline", + "connection": "ClickHouse", + "from": { "databaseName": "analytics", "tableName": "timeline" }, + "timestampValueExpression": "Timestamp", + "displayedTimestampValueExpression": "Timestamp", + "defaultTableSelectExpression": "Timestamp, ServiceName, UserId, Body, Model, TotalTokens, Cost, Rating, Reason", + "serviceNameExpression": "ServiceName", + "severityTextExpression": "SeverityText", + "bodyExpression": "Body", + "implicitColumnExpression": "Body", + "eventAttributesExpression": "LogAttributes", + "traceIdExpression": "TraceId", + "traceSourceId": "Conversation traces" + }, + { + "kind": "trace", + "name": "Conversation traces", + "connection": "ClickHouse", + "from": { "databaseName": "analytics", "tableName": "spans" }, + "timestampValueExpression": "Timestamp", + "displayedTimestampValueExpression": "Timestamp", + "defaultTableSelectExpression": "Timestamp, ServiceName, SpanName, UserId, ConversationId, Model, Duration, StatusCode, StatusMessage", + "implicitColumnExpression": "Input", + "durationExpression": "Duration", + "durationPrecision": 9, + "traceIdExpression": "TraceId", + "spanIdExpression": "SpanId", + "parentSpanIdExpression": "ParentSpanId", + "spanNameExpression": "SpanName", + "spanKindExpression": "SpanKind", + "statusCodeExpression": "StatusCode", + "statusMessageExpression": "StatusMessage", + "serviceNameExpression": "ServiceName", + "eventAttributesExpression": "SpanAttributes", + "resourceAttributesExpression": "ResourceAttributes", + "logSourceId": "Usage timeline" + } +] diff --git a/otel/hyperdx/sources.env b/otel/hyperdx/sources.env new file mode 100644 index 00000000000..f716d5ad726 --- /dev/null +++ b/otel/hyperdx/sources.env @@ -0,0 +1 @@ +DEFAULT_SOURCES=[{"from":{"databaseName":"hyperdx","tableName":"otel_logs"},"kind":"log","timestampValueExpression":"Timestamp","name":"Logs","displayedTimestampValueExpression":"Timestamp","implicitColumnExpression":"Body","serviceNameExpression":"ServiceName","bodyExpression":"Body","eventAttributesExpression":"LogAttributes","resourceAttributesExpression":"ResourceAttributes","defaultTableSelectExpression":"Timestamp,ServiceName,SeverityText,Body","severityTextExpression":"SeverityText","traceIdExpression":"TraceId","spanIdExpression":"SpanId","connection":"ClickHouse","traceSourceId":"Traces","sessionSourceId":"Sessions","metricSourceId":"Metrics"},{"from":{"databaseName":"hyperdx","tableName":"otel_traces"},"kind":"trace","timestampValueExpression":"Timestamp","name":"Traces","displayedTimestampValueExpression":"Timestamp","implicitColumnExpression":"SpanName","serviceNameExpression":"ServiceName","eventAttributesExpression":"SpanAttributes","resourceAttributesExpression":"ResourceAttributes","defaultTableSelectExpression":"Timestamp,ServiceName,StatusCode,round(Duration/1e6),SpanName","traceIdExpression":"TraceId","spanIdExpression":"SpanId","durationExpression":"Duration","durationPrecision":9,"parentSpanIdExpression":"ParentSpanId","spanNameExpression":"SpanName","spanKindExpression":"SpanKind","statusCodeExpression":"StatusCode","statusMessageExpression":"StatusMessage","connection":"ClickHouse","logSourceId":"Logs","sessionSourceId":"Sessions","metricSourceId":"Metrics"},{"from":{"databaseName":"hyperdx","tableName":""},"kind":"metric","timestampValueExpression":"TimeUnix","name":"Metrics","resourceAttributesExpression":"ResourceAttributes","metricTables":{"gauge":"otel_metrics_gauge","histogram":"otel_metrics_histogram","sum":"otel_metrics_sum"},"connection":"ClickHouse","logSourceId":"Logs","traceSourceId":"Traces","sessionSourceId":"Sessions"},{"from":{"databaseName":"hyperdx","tableName":"hyperdx_sessions"},"kind":"session","timestampValueExpression":"TimestampTime","name":"Sessions","displayedTimestampValueExpression":"Timestamp","implicitColumnExpression":"Body","serviceNameExpression":"ServiceName","bodyExpression":"Body","eventAttributesExpression":"LogAttributes","resourceAttributesExpression":"ResourceAttributes","defaultTableSelectExpression":"Timestamp,ServiceName,SeverityText,Body","severityTextExpression":"SeverityText","traceIdExpression":"TraceId","spanIdExpression":"SpanId","connection":"ClickHouse","logSourceId":"Logs","traceSourceId":"Traces","metricSourceId":"Metrics"}] diff --git a/otel/hyperdx/user.js b/otel/hyperdx/user.js new file mode 100644 index 00000000000..02a5b68d958 --- /dev/null +++ b/otel/hyperdx/user.js @@ -0,0 +1,36 @@ +/** + * Creates or updates the DocumentDB user HyperDX connects as. It can read and + * write the `hyperdx` database only, so HyperDX never holds the master + * credentials that MONGO_URI carries. + * + * Run as a one-off ECS task on the app image (terraform/hyperdx.tf), + * which already ships the MongoDB driver and the DocumentDB CA bundle. + */ +const { MongoClient } = require('mongodb'); + +const user = 'hyperdx'; +const roles = [{ role: 'readWrite', db: 'hyperdx' }]; + +async function main() { + const { MONGO_URI, MONGO_TLS_CA_FILE, HYPERDX_MONGO_PASSWORD } = process.env; + if (!MONGO_URI || !HYPERDX_MONGO_PASSWORD) { + throw new Error('MONGO_URI and HYPERDX_MONGO_PASSWORD are required'); + } + + const client = new MongoClient(MONGO_URI, MONGO_TLS_CA_FILE ? { tlsCAFile: MONGO_TLS_CA_FILE } : {}); + await client.connect(); + try { + const admin = client.db('admin'); + const { users } = await admin.command({ usersInfo: user }); + const exists = users.length > 0; + await admin.command({ [exists ? 'updateUser' : 'createUser']: user, pwd: HYPERDX_MONGO_PASSWORD, roles }); + console.log(`${exists ? 'Updated' : 'Created'} DocumentDB user "${user}"`); + } finally { + await client.close(); + } +} + +main().catch((error) => { + console.error(`Failed to provision the HyperDX database user: ${error.message}`); + process.exit(1); +}); diff --git a/otel/langfuse/admin.js b/otel/langfuse/admin.js new file mode 100644 index 00000000000..8fe2f24e381 --- /dev/null +++ b/otel/langfuse/admin.js @@ -0,0 +1,58 @@ +/** + * Sets the password of Langfuse's bootstrapped admin to LANGFUSE_INIT_USER_PASSWORD. + * Langfuse's LANGFUSE_INIT_* bootstrap only creates that user, so without this a + * rotated secret holds a password the existing admin does not have. + * + * Run as a one-off ECS task on the Langfuse web image (terraform/langfuse.tf), + * which ships the Prisma client for its own database and the bcryptjs library it + * hashes passwords with; the hash below matches Langfuse's own (bcryptjs, cost 12). + */ +const fs = require('fs'); +const path = require('path'); +const { PrismaClient } = require('@prisma/client'); + +const COST = 12; + +/** bcryptjs is bundled into Langfuse's server build, so it resolves only from pnpm's store. */ +function loadBcrypt() { + const store = '/app/node_modules/.pnpm'; + const dir = fs.readdirSync(store).find((name) => name.startsWith('bcryptjs@')); + if (!dir) { + throw new Error(`bcryptjs is not in ${store}; the Langfuse image layout has changed`); + } + return require(path.join(store, dir, 'node_modules', 'bcryptjs')); +} + +async function main() { + const { LANGFUSE_INIT_USER_EMAIL, LANGFUSE_INIT_USER_PASSWORD } = process.env; + if (!LANGFUSE_INIT_USER_EMAIL || !LANGFUSE_INIT_USER_PASSWORD) { + throw new Error('LANGFUSE_INIT_USER_EMAIL and LANGFUSE_INIT_USER_PASSWORD are required'); + } + + const bcrypt = loadBcrypt(); + const email = LANGFUSE_INIT_USER_EMAIL.toLowerCase(); + const prisma = new PrismaClient(); + try { + const user = await prisma.user.findUnique({ where: { email } }); + if (!user) { + console.log('No Langfuse admin yet; Langfuse creates it with this password when it starts'); + return; + } + if (user.password && (await bcrypt.compare(LANGFUSE_INIT_USER_PASSWORD, user.password))) { + console.log('Langfuse admin password already matches the secret'); + return; + } + await prisma.user.update({ + where: { id: user.id }, + data: { password: await bcrypt.hash(LANGFUSE_INIT_USER_PASSWORD, COST) }, + }); + console.log('Updated the Langfuse admin password'); + } finally { + await prisma.$disconnect(); + } +} + +main().catch((error) => { + console.error(`Failed to sync the Langfuse admin password: ${error.message}`); + process.exit(1); +}); diff --git a/package-lock.json b/package-lock.json index 85504bcc0e4..9d103394d34 100644 --- a/package-lock.json +++ b/package-lock.json @@ -55,6 +55,7 @@ "@anthropic-ai/vertex-sdk": "^0.16.0", "@aws-sdk/client-bedrock-runtime": "^3.1013.0", "@aws-sdk/client-cloudfront": "^3.1042.0", + "@aws-sdk/client-cognito-identity-provider": "^3.1013.0", "@aws-sdk/client-s3": "^3.980.0", "@aws-sdk/cloudfront-signer": "^3.1036.0", "@aws-sdk/credential-providers": "^3.1045.0", @@ -1745,6 +1746,25 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/client-cognito-identity-provider": { + "version": "3.1138.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-cognito-identity-provider/-/client-cognito-identity-provider-3.1138.0.tgz", + "integrity": "sha512-lepa+cIRK1aZsWkQVbvM+OPTWJVcvw2QTdsPKfvDI5Xxjc0tKZHdiHWkP8GYQNxeozmpHKte7VnNcAzqLAJfjg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/credential-provider-node": "^3.972.84", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/fetch-http-handler": "^5.8.0", + "@smithy/node-http-handler": "^4.12.1", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/client-cognito-identity/node_modules/@aws-sdk/util-endpoints": { "version": "3.996.8", "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.8.tgz", @@ -1966,17 +1986,17 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.977.1", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.1.tgz", - "integrity": "sha512-KVtQRtc00ES/y+Sc3vYXeP6pCIcNlBJCZOwvqSy8ZpVGmbM5+IG+AfhuTKQ2oXmIVqZJewaGMMpzPkywC6xg0w==", + "version": "3.978.1", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.978.1.tgz", + "integrity": "sha512-LbY9aGsEiznDWmUc30Nwv3aIX/+dbwTx8KfS0yOC3NPYMO+O91e6jkT1azf34FwjOndq8/Q+RcVVZz5xnerwdg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.974.2", - "@aws-sdk/xml-builder": "^3.972.37", + "@aws-sdk/types": "^3.974.6", + "@aws-sdk/xml-builder": "^3.972.41", "@aws/lambda-invoke-store": "^0.3.0", - "@smithy/core": "^3.29.8", - "@smithy/signature-v4": "^5.6.9", - "@smithy/types": "^4.16.1", + "@smithy/core": "^3.35.0", + "@smithy/signature-v4": "^5.7.3", + "@smithy/types": "^4.19.0", "bowser": "^2.11.0", "tslib": "^2.6.2" }, @@ -2023,15 +2043,15 @@ } }, "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.61", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.61.tgz", - "integrity": "sha512-qihs2ekMb89Nxd2JenCgVFhjbkb3EIo7HEBCBzyZACKVJdrLUZBLOmAE3xr0Sayml8n/jZSzwO/IufIiIzO7PQ==", + "version": "3.972.72", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.72.tgz", + "integrity": "sha512-xTKO/FWJPozTIXbozVnVGoNBhaGba8TBcx+KyUjRVeOlXE+dUc7GTR1cLvu0uTdIdmemzaFbqqCshXeZA1fZew==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.0", - "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.8", - "@smithy/types": "^4.16.1", + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", "tslib": "^2.6.2" }, "engines": { @@ -2039,17 +2059,17 @@ } }, "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.63", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.63.tgz", - "integrity": "sha512-yfozsS8wkWZEi/n6IsrodcFKBWZ0iNAezhJbTReMNc0z1Px17qdeAeuL1/wziCAmCZyXiW7QzP75ggJkBQv8jQ==", + "version": "3.972.74", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.74.tgz", + "integrity": "sha512-u91E/hT8f4d1xy0Jl7VG4nVKJ3lxbrZkoBTeSVoJdWBiSEUMwMS/9+e0H/aJVQV//Lt5wuzP+E69v4aRSsNTmw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.0", - "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.8", - "@smithy/fetch-http-handler": "^5.6.10", - "@smithy/node-http-handler": "^4.9.10", - "@smithy/types": "^4.16.1", + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/fetch-http-handler": "^5.8.0", + "@smithy/node-http-handler": "^4.12.1", + "@smithy/types": "^4.19.0", "tslib": "^2.6.2" }, "engines": { @@ -2057,23 +2077,23 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.973.6", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.6.tgz", - "integrity": "sha512-jGLTW1bj148GL/6/IMlfY2fMYS9FtHOG+NahkFD4y0qkzYudNUahelxryY68/HGMslYuHClk1XaS/3b3eJzEkg==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.977.0", - "@aws-sdk/credential-provider-env": "^3.972.61", - "@aws-sdk/credential-provider-http": "^3.972.63", - "@aws-sdk/credential-provider-login": "^3.972.68", - "@aws-sdk/credential-provider-process": "^3.972.61", - "@aws-sdk/credential-provider-sso": "^3.973.5", - "@aws-sdk/credential-provider-web-identity": "^3.972.67", - "@aws-sdk/nested-clients": "^3.997.35", - "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.8", - "@smithy/credential-provider-imds": "^4.4.13", - "@smithy/types": "^4.16.1", + "version": "3.973.17", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.17.tgz", + "integrity": "sha512-ged4KXdBkvIC81bLvNHHuQKdKak/VXhQTR1NWYTTqW0474nlmsxy9O/vlgTIohDDWH3xpBdtVMZRyjb+DnocDA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/credential-provider-env": "^3.972.72", + "@aws-sdk/credential-provider-http": "^3.972.74", + "@aws-sdk/credential-provider-login": "^3.972.79", + "@aws-sdk/credential-provider-process": "^3.972.72", + "@aws-sdk/credential-provider-sso": "^3.973.16", + "@aws-sdk/credential-provider-web-identity": "^3.972.78", + "@aws-sdk/nested-clients": "^3.997.46", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/credential-provider-imds": "^4.5.2", + "@smithy/types": "^4.19.0", "tslib": "^2.6.2" }, "engines": { @@ -2081,16 +2101,16 @@ } }, "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.68", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.68.tgz", - "integrity": "sha512-w6tNci6g7RqFpLhj1f5xseBvaNojb4Pkgp5Jp5apl9hrJtaf2AA+rX9+qlhlWUK6kcyAFYPA7emO+55zj+S98Q==", + "version": "3.972.79", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.79.tgz", + "integrity": "sha512-L+Z85anONJd8MaiuraO4wRxATCdEejBZ3K3eymzWI5JPXa9sOS9CkIm72PBKqXKX+Z9p9NGMX5AIMXm0LEflgw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.0", - "@aws-sdk/nested-clients": "^3.997.35", - "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.8", - "@smithy/types": "^4.16.1", + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/nested-clients": "^3.997.46", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", "tslib": "^2.6.2" }, "engines": { @@ -2098,55 +2118,72 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.84", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.84.tgz", + "integrity": "sha512-oHt854odINVwzwsh+c5x69j0ajm4DbqqqVJ+O1ECsCIZeMDAbzFpXItaqP7UZstJj/ATdTk/KFSH0LaNAgV+kA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.72", + "@aws-sdk/credential-provider-http": "^3.972.74", + "@aws-sdk/credential-provider-ini": "^3.973.17", + "@aws-sdk/credential-provider-process": "^3.972.72", + "@aws-sdk/credential-provider-sso": "^3.973.16", + "@aws-sdk/credential-provider-web-identity": "^3.972.78", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/credential-provider-imds": "^4.5.2", + "@smithy/types": "^4.19.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { "version": "3.972.72", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.72.tgz", - "integrity": "sha512-blQ7F5QGzylnzeh5549zQLoCAiMHkXFLjFovEMaVy4b2X8JhUu+u9NXro1hyK95YHdVFNmBHKs2hIHtZchxKlQ==", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.72.tgz", + "integrity": "sha512-rLIp2xbMjX/k9/od7APpqq1ZgXXnV0pOL1Th3ZsL8Wu0TRtBsDTVS8iPqcfRFcHakFxPvR04OSTv2ka2qOb/2A==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.61", - "@aws-sdk/credential-provider-http": "^3.972.63", - "@aws-sdk/credential-provider-ini": "^3.973.6", - "@aws-sdk/credential-provider-process": "^3.972.61", - "@aws-sdk/credential-provider-sso": "^3.973.5", - "@aws-sdk/credential-provider-web-identity": "^3.972.67", - "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.8", - "@smithy/credential-provider-imds": "^4.4.13", - "@smithy/types": "^4.16.1", + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.61", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.61.tgz", - "integrity": "sha512-xzRuj+fUVO4nkafKQJVKAF97kGpeQbfjuwmRrtGZNf42/1dkmcz6o7dswBy7alY0htQn5sCL1GWQYEykviWZkA==", + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.973.16", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.16.tgz", + "integrity": "sha512-IGihaJfFZYacJJr/odqILCoK7W/mvrZ7cuK7ECn3sAu4vLC6u0V8bS7mCGbdugJ8Aum2tnvqmx0F2MRFp2rn9g==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.0", - "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.8", - "@smithy/types": "^4.16.1", + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/nested-clients": "^3.997.46", + "@aws-sdk/token-providers": "3.1138.0", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", "tslib": "^2.6.2" }, "engines": { "node": ">=20.0.0" } }, - "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.973.5", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.5.tgz", - "integrity": "sha512-fZRjjWhLFelsDoOYjqShQTrIGYC3Pf9Mx9Czf+1ikfQDgktxjze33dVo1q1/ZQ+T0qbtejVoHNHrfD5aJVpv/w==", + "node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": { + "version": "3.1138.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1138.0.tgz", + "integrity": "sha512-GpyAr0DD63YOEmYFM6Df+gJuIgC92MMTiBK4FTKfxii5MJ9ge20epR7LyroulscYlG89J+ZB2ivFDPjvfQhzdw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.0", - "@aws-sdk/nested-clients": "^3.997.35", - "@aws-sdk/token-providers": "3.1095.0", - "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.8", - "@smithy/types": "^4.16.1", + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/nested-clients": "^3.997.46", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", "tslib": "^2.6.2" }, "engines": { @@ -2154,16 +2191,16 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.67", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.67.tgz", - "integrity": "sha512-FTNZ05gkPBA6CKbU3N4zPgybV+stdazwMOya75CmGdcJL7p8Fw/BdHP8WVxJd0mvzyPK2cg/C3gli58Ir4HgCw==", + "version": "3.972.78", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.78.tgz", + "integrity": "sha512-/y9WvNtlcPBGLR0qc1a+9J/xtYZfVczvLUOuXaVWylzttH7ewsxwHtjmiJSolNrVSDorIxHGHMU61CbonRkmwA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.977.0", - "@aws-sdk/nested-clients": "^3.997.35", - "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.8", - "@smithy/types": "^4.16.1", + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/nested-clients": "^3.997.46", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/types": "^4.19.0", "tslib": "^2.6.2" }, "engines": { @@ -2201,6 +2238,19 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/endpoint-cache": { + "version": "3.972.11", + "resolved": "https://registry.npmjs.org/@aws-sdk/endpoint-cache/-/endpoint-cache-3.972.11.tgz", + "integrity": "sha512-8q1ICxcDjHId3bBryuu/j+1L9y5/3uQnwzLDt5j2ElcjZSoWmFtymdJy7OjLrluSMe0Z4mq5bcH4fxBXvlEHfw==", + "license": "Apache-2.0", + "dependencies": { + "mnemonist": "0.38.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/eventstream-handler-node": { "version": "3.972.30", "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.30.tgz", @@ -2246,6 +2296,22 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/middleware-endpoint-discovery": { + "version": "3.972.30", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-endpoint-discovery/-/middleware-endpoint-discovery-3.972.30.tgz", + "integrity": "sha512-0hmD7/NG2NoVOVHvUb6rtY5POQYr+/9gdHvrYhIBA1zmCqKOBd5Gz3dL+iZ15FHOJbs/5kLplGoePc8PHTlzYA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/endpoint-cache": "^3.972.11", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/middleware-eventstream": { "version": "3.972.25", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.25.tgz", @@ -2564,18 +2630,18 @@ } }, "node_modules/@aws-sdk/nested-clients": { - "version": "3.997.35", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.35.tgz", - "integrity": "sha512-2MJfseVG/aXvIyOIBlYA/Oaf6qFDdsu4D8RKsEUdOQpVuLaor0BdxIBBtJLBNQQEe6Ku3YMvLljwb1MwVUpzRw==", - "license": "Apache-2.0", - "dependencies": { - "@aws-sdk/core": "^3.977.0", - "@aws-sdk/signature-v4-multi-region": "^3.996.42", - "@aws-sdk/types": "^3.974.2", - "@smithy/core": "^3.29.8", - "@smithy/fetch-http-handler": "^5.6.10", - "@smithy/node-http-handler": "^4.9.10", - "@smithy/types": "^4.16.1", + "version": "3.997.46", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.46.tgz", + "integrity": "sha512-oRxtBcka/JGHGs9l9p9IVajGoTP8vTPmoAzdHGy4Qcy9P5vPnDf6nhIeM/COQNY9k/OahImTRaLkHftoXvfcmQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.978.1", + "@aws-sdk/signature-v4-multi-region": "^3.996.47", + "@aws-sdk/types": "^3.974.6", + "@smithy/core": "^3.35.0", + "@smithy/fetch-http-handler": "^5.8.0", + "@smithy/node-http-handler": "^4.12.1", + "@smithy/types": "^4.19.0", "tslib": "^2.6.2" }, "engines": { @@ -2583,14 +2649,14 @@ } }, "node_modules/@aws-sdk/nested-clients/node_modules/@aws-sdk/signature-v4-multi-region": { - "version": "3.996.42", - "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.42.tgz", - "integrity": "sha512-DBV4naZP6HYBlAvPpoQzOP12Wvfou/5rN8yJPXjBTBylU5qwCbh/tXr2MddHoIjgoRkEl/eS+IljiUqvmwey1Q==", + "version": "3.996.47", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.47.tgz", + "integrity": "sha512-Zk08macMvQTHzQJCLJVkOlviVoqwYMrpXv4lmLN7b7sAbiMoOK7Go0NYdR5UeF+MW8LIbRmwrNy9u/5VvX1U5g==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.974.2", - "@smithy/signature-v4": "^5.6.9", - "@smithy/types": "^4.16.1", + "@aws-sdk/types": "^3.974.6", + "@smithy/signature-v4": "^5.7.3", + "@smithy/types": "^4.19.0", "tslib": "^2.6.2" }, "engines": { @@ -2693,12 +2759,12 @@ } }, "node_modules/@aws-sdk/types": { - "version": "3.974.2", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.2.tgz", - "integrity": "sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA==", + "version": "3.974.6", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.6.tgz", + "integrity": "sha512-v/clNZzZnDxGyvpHMOGpJKVXFAExJzUNAAjaWGdcx8QAcXLGwTaOkw33p5SHAi0YAioK32xB3hWwOekRVfmfKg==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.16.1", + "@smithy/types": "^4.19.0", "tslib": "^2.6.2" }, "engines": { @@ -2810,12 +2876,12 @@ } }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.37", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.37.tgz", - "integrity": "sha512-zKq4HQum8JwDyEuyfuI4bbiAcU0KxP6qy+9PR/IsR92IyE/DaBAikzAS50tjxip4bqIIANpCcG+Yyj6CVhXupg==", + "version": "3.972.41", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.41.tgz", + "integrity": "sha512-ctjVSyCMegrWfXlx6VqzSBFI6UqmQ5ZlnfMhdLIiWmhoH8UAQxSCP5N3OpG7X3k4LnS7ou74C4mt20+bfTW2aQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.16.1", + "@smithy/types": "^4.19.0", "tslib": "^2.6.2" }, "engines": { @@ -16967,12 +17033,12 @@ } }, "node_modules/@smithy/core": { - "version": "3.30.0", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.30.0.tgz", - "integrity": "sha512-dl2yRglDxfzH9uJ4fSo4zTaAHa0zH7+V7BZMRWy8hEYIKT1BiqMUK/CN6T3ADQ3kbA5N1tmUulroJ2UtONS7Kw==", + "version": "3.35.0", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.35.0.tgz", + "integrity": "sha512-zRMhfkByhT2snNdr1si24vJitU6Cr9ix2MikUfWmkAgp4jrNP0GcKSP5YvwQ+TlI8AZXER5QOGJn3JsVtSD9/A==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.16.1", + "@smithy/types": "^4.19.0", "tslib": "^2.6.2" }, "engines": { @@ -16980,13 +17046,13 @@ } }, "node_modules/@smithy/credential-provider-imds": { - "version": "4.4.14", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.4.14.tgz", - "integrity": "sha512-QgbuahIb2qxQeZQvNK0sw3aF3JH5zwH8j2lLp5DUasVXexGGMWULAR+7z0omPXFolCP/m5wN9M5lm9EGdSviTQ==", + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz", + "integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.30.0", - "@smithy/types": "^4.16.1", + "@smithy/core": "^3.33.2", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -17064,13 +17130,13 @@ } }, "node_modules/@smithy/fetch-http-handler": { - "version": "5.6.11", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.6.11.tgz", - "integrity": "sha512-o0Zkj1nKqJAoq+a+BrkhU39tRftMNjLwpc/z06Frfl43wpbHrJMaSAVZE4vTqlxtVkNaGaT0bIDxOp7tkFTuQQ==", + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.8.0.tgz", + "integrity": "sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.30.0", - "@smithy/types": "^4.16.1", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", "tslib": "^2.6.2" }, "engines": { @@ -17331,13 +17397,13 @@ } }, "node_modules/@smithy/node-http-handler": { - "version": "4.9.11", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.9.11.tgz", - "integrity": "sha512-slbzbz8taEOzoXv/9y34YNBoE+ZHmddLykCgjDAjvMAsu2nM5s2Gzwa5OGF721tD8s+CKeFUBds5lSj9lcbuDg==", + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.12.1.tgz", + "integrity": "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.30.0", - "@smithy/types": "^4.16.1", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", "tslib": "^2.6.2" }, "engines": { @@ -17423,13 +17489,13 @@ } }, "node_modules/@smithy/signature-v4": { - "version": "5.6.10", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.6.10.tgz", - "integrity": "sha512-EXhWePm3SXJAX38npIy4TXL2Aex/OVgCClTjelN2QHw/U+8CQUH8C7AaxsVzQcYieYPseywn48s89DmvuGjiAg==", + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.3.tgz", + "integrity": "sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.30.0", - "@smithy/types": "^4.16.1", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", "tslib": "^2.6.2" }, "engines": { @@ -17455,9 +17521,9 @@ } }, "node_modules/@smithy/types": { - "version": "4.16.1", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.16.1.tgz", - "integrity": "sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==", + "version": "4.19.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.19.0.tgz", + "integrity": "sha512-r7jh49VJxGerfAcTQA6gXcKc+98zOp/tqRwzYjgOE+iSQsP6cEU1hq2QzbuipmP68QtYdY9wKEhiCQZIzHgZ4Q==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -32544,6 +32610,15 @@ "ufo": "^1.6.1" } }, + "node_modules/mnemonist": { + "version": "0.38.3", + "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.38.3.tgz", + "integrity": "sha512-2K9QYubXx/NAjv4VLq1d1Ly8pWNC5L3BrixtdkyTegXWJIqY+zLNDhhX/A+ZwWt70tB1S8H4BE8FLYEFyNoOBw==", + "license": "MIT", + "dependencies": { + "obliterator": "^1.6.1" + } + }, "node_modules/modern-tar": { "version": "0.8.5", "resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.8.5.tgz", @@ -33460,6 +33535,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obliterator": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-1.6.1.tgz", + "integrity": "sha512-9WXswnqINnnhOG/5SLimUlzuU1hFJUc8zkwyD59Sd+dPOMf05PmnYG/d6Q7HZ+KmgkZJa1PxRso6QdM3sTNHig==", + "license": "MIT" + }, "node_modules/obug": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.2.tgz", diff --git a/packages/api/src/endpoints/bedrock/initialize.spec.ts b/packages/api/src/endpoints/bedrock/initialize.spec.ts index 8bbd3d5f64f..6976f0f3d6b 100644 --- a/packages/api/src/endpoints/bedrock/initialize.spec.ts +++ b/packages/api/src/endpoints/bedrock/initialize.spec.ts @@ -79,6 +79,7 @@ describe('initializeBedrock', () => { delete process.env.BEDROCK_AWS_PROFILE; delete process.env.BEDROCK_AWS_SESSION_TOKEN; delete process.env.BEDROCK_REVERSE_PROXY; + delete process.env.MINISTACK_PORT; delete process.env.PROXY; delete process.env.proxy; delete process.env.HTTP_PROXY; @@ -381,6 +382,60 @@ describe('initializeBedrock', () => { }); describe('Proxy Configuration', () => { + it('should create a BedrockRuntimeClient with the configured HTTP baseURL', async () => { + const params = createMockParams({ + config: { + endpoints: { + [EModelEndpoint.bedrock]: { + baseURL: 'http://ministack:4566', + }, + }, + }, + }); + + const result = (await initializeBedrock(params)) as BedrockLLMConfigResult; + + expect(result.llmConfig).toHaveProperty('client'); + expect(result.llmConfig.client).toHaveProperty('endpoint', 'http://ministack:4566'); + expect(result.llmConfig).not.toHaveProperty('credentials'); + expect(result.llmConfig).not.toHaveProperty('endpointHost'); + }); + + it('should resolve an environment variable embedded in the configured baseURL', async () => { + process.env.MINISTACK_PORT = '4566'; + const params = createMockParams({ + config: { + endpoints: { + [EModelEndpoint.bedrock]: { + baseURL: 'http://ministack:${MINISTACK_PORT}', + }, + }, + }, + }); + + const result = (await initializeBedrock(params)) as BedrockLLMConfigResult; + + expect(result.llmConfig.client).toHaveProperty('endpoint', 'http://ministack:4566'); + }); + + it('should prefer the configured baseURL over BEDROCK_REVERSE_PROXY', async () => { + process.env.BEDROCK_REVERSE_PROXY = 'legacy-bedrock.example.com'; + const params = createMockParams({ + config: { + endpoints: { + [EModelEndpoint.bedrock]: { + baseURL: 'http://ministack:4566', + }, + }, + }, + }); + + const result = (await initializeBedrock(params)) as BedrockLLMConfigResult; + + expect(result.llmConfig.client).toHaveProperty('endpoint', 'http://ministack:4566'); + expect(result.llmConfig).not.toHaveProperty('endpointHost'); + }); + it('should create BedrockRuntimeClient with proxy when PROXY is set', async () => { process.env.PROXY = 'http://proxy:8080'; const params = createMockParams(); diff --git a/packages/api/src/endpoints/bedrock/initialize.ts b/packages/api/src/endpoints/bedrock/initialize.ts index 7ac7ccf947b..58cc5600cbe 100644 --- a/packages/api/src/endpoints/bedrock/initialize.ts +++ b/packages/api/src/endpoints/bedrock/initialize.ts @@ -29,9 +29,9 @@ type ParsedBedrockUserCredentials = Partial