Compare commits

..

27 Commits

Author SHA1 Message Date
Dane Urban
f6d6bd3b3c Prevent the removal and hiding of default model 2026-03-05 19:10:22 -08:00
Jamison Lahman
f59aaa902d chore(playwright): tighten how elements are hidden (#9117) 2026-03-05 23:58:07 +00:00
Nikolas Garza
57349bdbd1 chore: OnyxError cleanup (#9071)
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-05 23:21:38 +00:00
Wenxi
192639a801 chore: bump recommended models (#9112) 2026-03-05 23:02:18 +00:00
Jamison Lahman
c10ffbb464 fix(safari): chat background blur ignores text (#9111)
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-05 15:54:25 -08:00
dependabot[bot]
091f41fd1f chore(deps): bump google-cloud-aiplatform from 1.121.0 to 1.133.0 (#8658)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Jamison Lahman <jamison@lahman.dev>
2026-03-05 22:54:42 +00:00
dependabot[bot]
45d77be4eb chore(deps): bump ajv in /backend/onyx/server/features/build/sandbox/kubernetes/docker/templates/outputs/web (#8655)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-05 15:14:50 -08:00
dependabot[bot]
413fa85134 chore(deps): bump minimatch in /backend/onyx/server/features/build/sandbox/kubernetes/docker/templates/outputs/web (#8828)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-05 15:13:57 -08:00
dependabot[bot]
108cde4f55 chore(deps): bump j178/prek-action from 1.0.12 to 1.1.1 (#8477)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Jamison Lahman <jamison@lahman.dev>
2026-03-05 15:13:00 -08:00
dependabot[bot]
f88ce32bd4 chore(deps): bump @hono/node-server from 1.19.9 to 1.19.10 in /backend/onyx/server/features/build/sandbox/kubernetes/docker/templates/outputs/web (#9048)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-05 14:51:22 -08:00
dependabot[bot]
911f3439ea chore(deps): bump helm/kind-action from 1.13.0 to 1.14.0 (#8917)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-05 14:50:06 -08:00
dependabot[bot]
b02590d2b2 chore(deps): bump aws-actions/configure-aws-credentials from 5.1.1 to 6.0.0 (#8478)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-05 14:49:29 -08:00
dependabot[bot]
2d75b4b1f8 chore(deps): bump dompurify from 3.3.1 to 3.3.2 in /widget (#9106)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-05 14:45:53 -08:00
dependabot[bot]
7e3f7d01c2 chore(deps): bump authlib from 1.6.6 to 1.6.7 (#9049)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Jamison Lahman <jamison@lahman.dev>
2026-03-05 22:14:44 +00:00
Jamison Lahman
9d6ce26ea3 fix(fe): show modal body on Safari/desktop (#9035) 2026-03-05 21:35:43 +00:00
roshan
41713d42a2 chore: upgrade golangci-lint to v2.10.1 for Go 1.26 support (#9107)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-05 21:22:56 +00:00
roshan
8afc283410 fix(chrome-extension): open login in new tab when session expires (#9091)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-05 21:18:21 +00:00
Jamison Lahman
b5c873077e chore(devtools): upgrade ods: 0.6.2->0.6.3 (#9105) 2026-03-05 21:04:51 +00:00
Jamison Lahman
20a4dd32eb chore(devtools): pull release branch and support PR # args (#9102)
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-05 12:37:51 -08:00
Jamison Lahman
fde0d44bc1 chore(devtools): upgrade ods to go 1.26 (#9103) 2026-03-05 20:24:57 +00:00
Jamison Lahman
8fd91b6e83 chore(devtools): ods desktop (#9100) 2026-03-05 19:38:02 +00:00
Justin Tahara
8247fdd45b fix(llm): Handle Bedrock tool content in message history without toolConfig (#9063) 2026-03-05 19:06:35 +00:00
Jamison Lahman
8c5859ba4d fix(fe): disable projects modal button unless project is named (#9093) 2026-03-05 10:29:15 -08:00
Jamison Lahman
62ef6f59bb chore(playwright): screenshot tests for user settings pages (#9078) 2026-03-05 08:35:46 -08:00
Jamison Lahman
7eabfa125c fix(fe): properly wrap copy and edit buttons on mobile (#9073) 2026-03-05 04:36:11 +00:00
SubashMohan
ee18114739 feat(table): add DataTable config-driven wrapper component (#9020)
Co-authored-by: Nik <nikolas.garza5@gmail.com>
2026-03-05 04:21:38 +00:00
Nikolas Garza
f7630f5648 fix: EE route gating for upgrading CE users (#9026) 2026-03-05 03:44:16 +00:00
65 changed files with 2238 additions and 745 deletions

View File

@@ -213,7 +213,7 @@ jobs:
- name: Configure AWS credentials
if: startsWith(matrix.platform, 'macos-')
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708
uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7
with:
role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }}
aws-region: us-east-2
@@ -384,7 +384,7 @@ jobs:
persist-credentials: false
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708
uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7
with:
role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }}
aws-region: us-east-2
@@ -458,7 +458,7 @@ jobs:
persist-credentials: false
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708
uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7
with:
role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }}
aws-region: us-east-2
@@ -527,7 +527,7 @@ jobs:
- uses: runs-on/action@cd2b598b0515d39d78c38a02d529db87d2196d1e # ratchet:runs-on/action@v2
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708
uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7
with:
role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }}
aws-region: us-east-2
@@ -597,7 +597,7 @@ jobs:
persist-credentials: false
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708
uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7
with:
role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }}
aws-region: us-east-2
@@ -679,7 +679,7 @@ jobs:
persist-credentials: false
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708
uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7
with:
role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }}
aws-region: us-east-2
@@ -756,7 +756,7 @@ jobs:
- uses: runs-on/action@cd2b598b0515d39d78c38a02d529db87d2196d1e # ratchet:runs-on/action@v2
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708
uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7
with:
role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }}
aws-region: us-east-2
@@ -823,7 +823,7 @@ jobs:
persist-credentials: false
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708
uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7
with:
role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }}
aws-region: us-east-2
@@ -896,7 +896,7 @@ jobs:
persist-credentials: false
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708
uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7
with:
role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }}
aws-region: us-east-2
@@ -964,7 +964,7 @@ jobs:
- uses: runs-on/action@cd2b598b0515d39d78c38a02d529db87d2196d1e # ratchet:runs-on/action@v2
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708
uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7
with:
role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }}
aws-region: us-east-2
@@ -1034,7 +1034,7 @@ jobs:
persist-credentials: false
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708
uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7
with:
role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }}
aws-region: us-east-2
@@ -1107,7 +1107,7 @@ jobs:
persist-credentials: false
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708
uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7
with:
role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }}
aws-region: us-east-2
@@ -1176,7 +1176,7 @@ jobs:
- uses: runs-on/action@cd2b598b0515d39d78c38a02d529db87d2196d1e # ratchet:runs-on/action@v2
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708
uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7
with:
role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }}
aws-region: us-east-2
@@ -1246,7 +1246,7 @@ jobs:
persist-credentials: false
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708
uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7
with:
role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }}
aws-region: us-east-2
@@ -1326,7 +1326,7 @@ jobs:
persist-credentials: false
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708
uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7
with:
role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }}
aws-region: us-east-2
@@ -1400,7 +1400,7 @@ jobs:
- uses: runs-on/action@cd2b598b0515d39d78c38a02d529db87d2196d1e # ratchet:runs-on/action@v2
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708
uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7
with:
role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }}
aws-region: us-east-2
@@ -1465,7 +1465,7 @@ jobs:
- uses: runs-on/action@cd2b598b0515d39d78c38a02d529db87d2196d1e # ratchet:runs-on/action@v2
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708
uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7
with:
role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }}
aws-region: us-east-2
@@ -1520,7 +1520,7 @@ jobs:
- uses: runs-on/action@cd2b598b0515d39d78c38a02d529db87d2196d1e # ratchet:runs-on/action@v2
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708
uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7
with:
role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }}
aws-region: us-east-2
@@ -1580,7 +1580,7 @@ jobs:
persist-credentials: false
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708
uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7
with:
role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }}
aws-region: us-east-2
@@ -1637,7 +1637,7 @@ jobs:
- uses: runs-on/action@cd2b598b0515d39d78c38a02d529db87d2196d1e # ratchet:runs-on/action@v2
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708
uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7
with:
role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }}
aws-region: us-east-2

View File

@@ -71,7 +71,7 @@ jobs:
- name: Create kind cluster
if: steps.list-changed.outputs.changed == 'true'
uses: helm/kind-action@92086f6be054225fa813e0a4b13787fc9088faab # ratchet:helm/kind-action@v1.13.0
uses: helm/kind-action@ef37e7f390d99f746eb8b610417061a60e82a6cc # ratchet:helm/kind-action@v1.14.0
- name: Pre-install cluster status check
if: steps.list-changed.outputs.changed == 'true'

View File

@@ -461,7 +461,7 @@ jobs:
# --- Visual Regression Diff ---
- name: Configure AWS credentials
if: always()
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708
uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7
with:
role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }}
aws-region: us-east-2

View File

@@ -38,9 +38,9 @@ jobs:
- name: Install node dependencies
working-directory: ./web
run: npm ci
- uses: j178/prek-action@9d6a3097e0c1865ecce00cfb89fe80f2ee91b547 # ratchet:j178/prek-action@v1
- uses: j178/prek-action@0bb87d7f00b0c99306c8bcb8b8beba1eb581c037 # ratchet:j178/prek-action@v1
with:
prek-version: '0.2.21'
prek-version: '0.3.4'
extra-args: ${{ github.event_name == 'pull_request' && format('--from-ref {0} --to-ref {1}', github.event.pull_request.base.sha, github.event.pull_request.head.sha) || github.event_name == 'merge_group' && format('--from-ref {0} --to-ref {1}', github.event.merge_group.base_sha, github.event.merge_group.head_sha) || github.ref_name == 'main' && '--all-files' || '' }}
- name: Check Actions
uses: giner/check-actions@28d366c7cbbe235f9624a88aa31a628167eee28c # ratchet:giner/check-actions@v1.0.1

View File

@@ -73,7 +73,7 @@ jobs:
persist-credentials: false
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708
uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7
with:
role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }}
aws-region: us-east-2
@@ -116,7 +116,7 @@ jobs:
persist-credentials: false
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708
uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7
with:
role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }}
aws-region: us-east-2
@@ -158,7 +158,7 @@ jobs:
persist-credentials: false
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708
uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7
with:
role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }}
aws-region: us-east-2
@@ -264,7 +264,7 @@ jobs:
persist-credentials: false
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708
uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7
with:
role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }}
aws-region: us-east-2

View File

@@ -110,7 +110,7 @@ jobs:
persist-credentials: false
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708
uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7
with:
role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }}
aws-region: us-east-2
@@ -180,7 +180,7 @@ jobs:
persist-credentials: false
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708
uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7
with:
role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }}
aws-region: us-east-2
@@ -244,7 +244,7 @@ jobs:
- uses: runs-on/action@cd2b598b0515d39d78c38a02d529db87d2196d1e # ratchet:runs-on/action@v2
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@61815dcd50bd041e203e49132bacad1fd04d2708
uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7
with:
role-to-assume: ${{ secrets.AWS_OIDC_ROLE_ARN }}
aws-region: us-east-2

View File

@@ -119,9 +119,10 @@ repos:
]
- repo: https://github.com/golangci/golangci-lint
rev: 9f61b0f53f80672872fced07b6874397c3ed197b # frozen: v2.7.2
rev: 5d1e709b7be35cb2025444e19de266b056b7b7ee # frozen: v2.10.1
hooks:
- id: golangci-lint
language_version: "1.26.0"
entry: bash -c "find tools/ -name go.mod -print0 | xargs -0 -I{} bash -c 'cd \"$(dirname {})\" && golangci-lint run ./...'"
- repo: https://github.com/astral-sh/ruff-pre-commit

View File

@@ -4,7 +4,6 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI
from httpx_oauth.clients.google import GoogleOAuth2
from ee.onyx.configs.app_configs import LICENSE_ENFORCEMENT_ENABLED
from ee.onyx.server.analytics.api import router as analytics_router
from ee.onyx.server.auth_check import check_ee_router_auth
from ee.onyx.server.billing.api import router as billing_router
@@ -153,12 +152,9 @@ def get_application() -> FastAPI:
# License management
include_router_with_global_prefix_prepended(application, license_router)
# Unified billing API - available when license system is enabled
# Works for both self-hosted and cloud deployments
# TODO(ENG-3533): Once frontend migrates to /admin/billing/*, this becomes the
# primary billing API and /tenants/* billing endpoints can be removed
if LICENSE_ENFORCEMENT_ENABLED:
include_router_with_global_prefix_prepended(application, billing_router)
# Unified billing API - always registered in EE.
# Each endpoint is protected by the `current_admin_user` dependency (admin auth).
include_router_with_global_prefix_prepended(application, billing_router)
if MULTI_TENANT:
# Tenant management

View File

@@ -246,7 +246,11 @@ async def get_billing_information(
)
except OnyxError as e:
# Open circuit breaker on connection failures (self-hosted only)
if e.status_code in (502, 503, 504):
if e.status_code in (
OnyxErrorCode.BAD_GATEWAY.status_code,
OnyxErrorCode.SERVICE_UNAVAILABLE.status_code,
OnyxErrorCode.GATEWAY_TIMEOUT.status_code,
):
_open_billing_circuit()
raise

View File

@@ -2,6 +2,7 @@ import base64
import uuid
from fastapi import Depends
from fastapi import HTTPException
from fastapi.responses import JSONResponse
from ee.onyx.server.oauth.api_router import router
@@ -12,8 +13,6 @@ from onyx.auth.users import current_admin_user
from onyx.configs.app_configs import DEV_MODE
from onyx.configs.constants import DocumentSource
from onyx.db.models import User
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.redis.redis_pool import get_redis_client
from onyx.utils.logger import setup_logger
from shared_configs.contextvars import get_current_tenant_id
@@ -72,15 +71,15 @@ def prepare_authorization_request(
oauth_url = None
if not oauth_url:
raise OnyxError(
OnyxErrorCode.NOT_FOUND,
f"The document source type {connector} does not have OAuth implemented",
raise HTTPException(
status_code=404,
detail=f"The document source type {connector} does not have OAuth implemented",
)
if not session:
raise OnyxError(
OnyxErrorCode.INTERNAL_ERROR,
f"The document source type {connector} failed to generate an OAuth session.",
raise HTTPException(
status_code=500,
detail=f"The document source type {connector} failed to generate an OAuth session.",
)
r = get_redis_client(tenant_id=tenant_id)

View File

@@ -8,6 +8,7 @@ from typing import cast
import requests
from fastapi import Depends
from fastapi import HTTPException
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from pydantic import ValidationError
@@ -26,8 +27,6 @@ from onyx.db.credentials import fetch_credential_by_id_for_user
from onyx.db.credentials import update_credential_json
from onyx.db.engine.sql_engine import get_session
from onyx.db.models import User
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.redis.redis_pool import get_redis_client
from onyx.server.documents.models import CredentialBase
from onyx.utils.logger import setup_logger
@@ -155,9 +154,9 @@ def confluence_oauth_callback(
after visiting the oauth authorization url."""
if not ConfluenceCloudOAuth.CLIENT_ID or not ConfluenceCloudOAuth.CLIENT_SECRET:
raise OnyxError(
OnyxErrorCode.INTERNAL_ERROR,
"Confluence Cloud client ID or client secret is not configured.",
raise HTTPException(
status_code=500,
detail="Confluence Cloud client ID or client secret is not configured.",
)
r = get_redis_client(tenant_id=tenant_id)
@@ -178,9 +177,9 @@ def confluence_oauth_callback(
session_json_bytes = cast(bytes, r.get(r_key))
if not session_json_bytes:
raise OnyxError(
OnyxErrorCode.VALIDATION_ERROR,
f"Confluence Cloud OAuth failed - OAuth state key not found: key={r_key}",
raise HTTPException(
status_code=400,
detail=f"Confluence Cloud OAuth failed - OAuth state key not found: key={r_key}",
)
session_json = session_json_bytes.decode("utf-8")
@@ -269,10 +268,7 @@ def confluence_oauth_accessible_resources(
credential = fetch_credential_by_id_for_user(credential_id, user, db_session)
if not credential:
raise OnyxError(
OnyxErrorCode.CREDENTIAL_NOT_FOUND,
f"Credential {credential_id} not found.",
)
raise HTTPException(400, f"Credential {credential_id} not found.")
credential_dict = (
credential.credential_json.get_value(apply_mask=False)
@@ -340,9 +336,9 @@ def confluence_oauth_finalize(
credential = fetch_credential_by_id_for_user(credential_id, user, db_session)
if not credential:
raise OnyxError(
OnyxErrorCode.CREDENTIAL_NOT_FOUND,
f"Confluence Cloud OAuth failed - credential {credential_id} not found.",
raise HTTPException(
status_code=400,
detail=f"Confluence Cloud OAuth failed - credential {credential_id} not found.",
)
existing_credential_json = (

View File

@@ -6,6 +6,7 @@ from typing import cast
import requests
from fastapi import Depends
from fastapi import HTTPException
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from sqlalchemy.orm import Session
@@ -34,8 +35,6 @@ from onyx.connectors.google_utils.shared_constants import (
from onyx.db.credentials import create_credential
from onyx.db.engine.sql_engine import get_session
from onyx.db.models import User
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.redis.redis_pool import get_redis_client
from onyx.server.documents.models import CredentialBase
from shared_configs.contextvars import get_current_tenant_id
@@ -120,9 +119,9 @@ def handle_google_drive_oauth_callback(
tenant_id: str | None = Depends(get_current_tenant_id),
) -> JSONResponse:
if not GoogleDriveOAuth.CLIENT_ID or not GoogleDriveOAuth.CLIENT_SECRET:
raise OnyxError(
OnyxErrorCode.INTERNAL_ERROR,
"Google Drive client ID or client secret is not configured.",
raise HTTPException(
status_code=500,
detail="Google Drive client ID or client secret is not configured.",
)
r = get_redis_client(tenant_id=tenant_id)
@@ -143,9 +142,9 @@ def handle_google_drive_oauth_callback(
session_json_bytes = cast(bytes, r.get(r_key))
if not session_json_bytes:
raise OnyxError(
OnyxErrorCode.VALIDATION_ERROR,
f"Google Drive OAuth failed - OAuth state key not found: key={r_key}",
raise HTTPException(
status_code=400,
detail=f"Google Drive OAuth failed - OAuth state key not found: key={r_key}",
)
session_json = session_json_bytes.decode("utf-8")

View File

@@ -4,6 +4,7 @@ from typing import cast
import requests
from fastapi import Depends
from fastapi import HTTPException
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from sqlalchemy.orm import Session
@@ -18,8 +19,6 @@ from onyx.configs.constants import DocumentSource
from onyx.db.credentials import create_credential
from onyx.db.engine.sql_engine import get_session
from onyx.db.models import User
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.redis.redis_pool import get_redis_client
from onyx.server.documents.models import CredentialBase
from shared_configs.contextvars import get_current_tenant_id
@@ -104,9 +103,9 @@ def handle_slack_oauth_callback(
tenant_id: str | None = Depends(get_current_tenant_id),
) -> JSONResponse:
if not SlackOAuth.CLIENT_ID or not SlackOAuth.CLIENT_SECRET:
raise OnyxError(
OnyxErrorCode.INTERNAL_ERROR,
"Slack client ID or client secret is not configured.",
raise HTTPException(
status_code=500,
detail="Slack client ID or client secret is not configured.",
)
r = get_redis_client(tenant_id=tenant_id)
@@ -127,9 +126,9 @@ def handle_slack_oauth_callback(
session_json_bytes = cast(bytes, r.get(r_key))
if not session_json_bytes:
raise OnyxError(
OnyxErrorCode.VALIDATION_ERROR,
f"Slack OAuth failed - OAuth state key not found: key={r_key}",
raise HTTPException(
status_code=400,
detail=f"Slack OAuth failed - OAuth state key not found: key={r_key}",
)
session_json = session_json_bytes.decode("utf-8")
@@ -156,9 +155,9 @@ def handle_slack_oauth_callback(
response_data = response.json()
if not response_data.get("ok"):
raise OnyxError(
OnyxErrorCode.VALIDATION_ERROR,
f"Slack OAuth failed: {response_data.get('error')}",
raise HTTPException(
status_code=400,
detail=f"Slack OAuth failed: {response_data.get('error')}",
)
# Extract token and team information

View File

@@ -36,7 +36,6 @@ from onyx.db.memory import add_memory
from onyx.db.memory import update_memory_at_index
from onyx.db.memory import UserMemoryContext
from onyx.db.models import Persona
from onyx.llm.constants import LlmProviderNames
from onyx.llm.interfaces import LLM
from onyx.llm.interfaces import LLMUserIdentity
from onyx.llm.interfaces import ToolChoiceOptions
@@ -84,28 +83,6 @@ def _looks_like_xml_tool_call_payload(text: str | None) -> bool:
)
def _should_keep_bedrock_tool_definitions(
llm: object, simple_chat_history: list[ChatMessageSimple]
) -> bool:
"""Bedrock requires tool config when history includes toolUse/toolResult blocks."""
model_provider = getattr(getattr(llm, "config", None), "model_provider", None)
if model_provider not in {
LlmProviderNames.BEDROCK,
LlmProviderNames.BEDROCK_CONVERSE,
}:
return False
return any(
(
msg.message_type == MessageType.ASSISTANT
and msg.tool_calls
and len(msg.tool_calls) > 0
)
or msg.message_type == MessageType.TOOL_CALL_RESPONSE
for msg in simple_chat_history
)
def _try_fallback_tool_extraction(
llm_step_result: LlmStepResult,
tool_choice: ToolChoiceOptions,
@@ -686,12 +663,7 @@ def run_llm_loop(
elif out_of_cycles or ran_image_gen:
# Last cycle, no tools allowed, just answer!
tool_choice = ToolChoiceOptions.NONE
# Bedrock requires tool config in requests that include toolUse/toolResult history.
final_tools = (
tools
if _should_keep_bedrock_tool_definitions(llm, simple_chat_history)
else []
)
final_tools = []
else:
tool_choice = ToolChoiceOptions.AUTO
final_tools = tools

View File

@@ -267,10 +267,34 @@ def upsert_llm_provider(
mc.name for mc in llm_provider_upsert_request.model_configurations
}
default_model = fetch_default_llm_model(db_session)
# Build a lookup of requested visibility by model name
requested_visibility = {
mc.name: mc.is_visible
for mc in llm_provider_upsert_request.model_configurations
}
# Delete removed models
removed_ids = [
mc.id for name, mc in existing_by_name.items() if name not in models_to_exist
]
# Prevent removing and hiding the default model
if default_model:
for name, mc in existing_by_name.items():
if mc.id == default_model.id:
if name not in models_to_exist:
raise ValueError(
f"Cannot remove the default model '{name}'. "
"Please change the default model before removing."
)
if not requested_visibility.get(name, True):
raise ValueError(
f"Cannot hide the default model '{name}'. "
"Please change the default model before hiding."
)
if removed_ids:
db_session.query(ModelConfiguration).filter(
ModelConfiguration.id.in_(removed_ids)

View File

@@ -48,10 +48,11 @@ class OnyxError(Exception):
*,
status_code_override: int | None = None,
) -> None:
resolved_message = message or error_code.code
super().__init__(resolved_message)
self.error_code = error_code
self.message = message or error_code.code
self.message = resolved_message
self._status_code_override = status_code_override
super().__init__(self.message)
@property
def status_code(self) -> int:

View File

@@ -2516,6 +2516,10 @@
"model_vendor": "openai",
"model_version": "2025-10-06"
},
"gpt-5.4": {
"display_name": "GPT-5.4",
"model_vendor": "openai"
},
"gpt-5.2-pro-2025-12-11": {
"display_name": "GPT-5.2 Pro",
"model_vendor": "openai",

View File

@@ -92,6 +92,98 @@ def _prompt_to_dicts(prompt: LanguageModelInput) -> list[dict[str, Any]]:
return [prompt.model_dump(exclude_none=True)]
def _normalize_content(raw: Any) -> str:
"""Normalize a message content field to a plain string.
Content can be a string, None, or a list of content-block dicts
(e.g. [{"type": "text", "text": "..."}]).
"""
if raw is None:
return ""
if isinstance(raw, str):
return raw
if isinstance(raw, list):
return "\n".join(
block.get("text", "") if isinstance(block, dict) else str(block)
for block in raw
)
return str(raw)
def _strip_tool_content_from_messages(
messages: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Convert tool-related messages to plain text.
Bedrock's Converse API requires toolConfig when messages contain
toolUse/toolResult content blocks. When no tools are provided for the
current request, we must convert any tool-related history into plain text
to avoid the "toolConfig field must be defined" error.
This is the same approach used by _OllamaHistoryMessageFormatter.
"""
result: list[dict[str, Any]] = []
for msg in messages:
role = msg.get("role")
tool_calls = msg.get("tool_calls")
if role == "assistant" and tool_calls:
# Convert structured tool calls to text representation
tool_call_lines = []
for tc in tool_calls:
func = tc.get("function", {})
name = func.get("name", "unknown")
args = func.get("arguments", "{}")
tc_id = tc.get("id", "")
tool_call_lines.append(
f"[Tool Call] name={name} id={tc_id} args={args}"
)
existing_content = _normalize_content(msg.get("content"))
parts = (
[existing_content] + tool_call_lines
if existing_content
else tool_call_lines
)
new_msg = {
"role": "assistant",
"content": "\n".join(parts),
}
result.append(new_msg)
elif role == "tool":
# Convert tool response to user message with text content
tool_call_id = msg.get("tool_call_id", "")
content = _normalize_content(msg.get("content"))
tool_result_text = f"[Tool Result] id={tool_call_id}\n{content}"
# Merge into previous user message if it is also a converted
# tool result to avoid consecutive user messages (Bedrock requires
# strict user/assistant alternation).
if (
result
and result[-1]["role"] == "user"
and "[Tool Result]" in result[-1].get("content", "")
):
result[-1]["content"] += "\n\n" + tool_result_text
else:
result.append({"role": "user", "content": tool_result_text})
else:
result.append(msg)
return result
def _messages_contain_tool_content(messages: list[dict[str, Any]]) -> bool:
"""Check if any messages contain tool-related content blocks."""
for msg in messages:
if msg.get("role") == "tool":
return True
if msg.get("role") == "assistant" and msg.get("tool_calls"):
return True
return False
def _is_vertex_model_rejecting_output_config(model_name: str) -> bool:
normalized_model_name = model_name.lower()
return any(
@@ -404,13 +496,30 @@ class LitellmLLM(LLM):
else nullcontext()
)
with env_ctx:
messages = _prompt_to_dicts(prompt)
# Bedrock's Converse API requires toolConfig when messages
# contain toolUse/toolResult content blocks. When no tools are
# provided for this request but the history contains tool
# content from previous turns, strip it to plain text.
is_bedrock = self._model_provider in {
LlmProviderNames.BEDROCK,
LlmProviderNames.BEDROCK_CONVERSE,
}
if (
is_bedrock
and not tools
and _messages_contain_tool_content(messages)
):
messages = _strip_tool_content_from_messages(messages)
response = litellm.completion(
mock_response=get_llm_mock_response() or MOCK_LLM_RESPONSE,
model=model,
base_url=self._api_base or None,
api_version=self._api_version or None,
custom_llm_provider=self._custom_llm_provider or None,
messages=_prompt_to_dicts(prompt),
messages=messages,
tools=tools,
tool_choice=tool_choice,
stream=stream,

View File

@@ -1,12 +1,12 @@
{
"version": "1.1",
"updated_at": "2026-02-05T00:00:00Z",
"updated_at": "2026-03-05T00:00:00Z",
"providers": {
"openai": {
"default_model": { "name": "gpt-5.2" },
"default_model": { "name": "gpt-5.4" },
"additional_visible_models": [
{ "name": "gpt-5-mini" },
{ "name": "gpt-4.1" }
{ "name": "gpt-5.4" },
{ "name": "gpt-5.2" }
]
},
"anthropic": {

View File

@@ -5,6 +5,7 @@ from typing import cast
from fastapi import APIRouter
from fastapi import Depends
from fastapi import HTTPException
from fastapi import Query
from fastapi import Request
from pydantic import BaseModel
@@ -18,8 +19,6 @@ from onyx.connectors.interfaces import OAuthConnector
from onyx.db.credentials import create_credential
from onyx.db.engine.sql_engine import get_session
from onyx.db.models import User
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.redis.redis_pool import get_redis_client
from onyx.server.documents.models import CredentialBase
from onyx.utils.logger import setup_logger
@@ -70,10 +69,12 @@ def _get_additional_kwargs(
# validate
connector_cls.AdditionalOauthKwargs(**additional_kwargs_dict)
except ValidationError:
raise OnyxError(
OnyxErrorCode.VALIDATION_ERROR,
f"Invalid additional kwargs. Got {additional_kwargs_dict}, expected "
f"{connector_cls.AdditionalOauthKwargs.model_json_schema()}",
raise HTTPException(
status_code=400,
detail=(
f"Invalid additional kwargs. Got {additional_kwargs_dict}, expected "
f"{connector_cls.AdditionalOauthKwargs.model_json_schema()}"
),
)
return additional_kwargs_dict
@@ -96,9 +97,7 @@ def oauth_authorize(
oauth_connectors = _discover_oauth_connectors()
if source not in oauth_connectors:
raise OnyxError(
OnyxErrorCode.VALIDATION_ERROR, f"Unknown OAuth source: {source}"
)
raise HTTPException(status_code=400, detail=f"Unknown OAuth source: {source}")
connector_cls = oauth_connectors[source]
base_url = WEB_DOMAIN
@@ -148,9 +147,7 @@ def oauth_callback(
oauth_connectors = _discover_oauth_connectors()
if source not in oauth_connectors:
raise OnyxError(
OnyxErrorCode.VALIDATION_ERROR, f"Unknown OAuth source: {source}"
)
raise HTTPException(status_code=400, detail=f"Unknown OAuth source: {source}")
connector_cls = oauth_connectors[source]
@@ -160,7 +157,7 @@ def oauth_callback(
bytes, redis_client.get(_OAUTH_STATE_KEY_FMT.format(state=state))
)
if not oauth_state_bytes:
raise OnyxError(OnyxErrorCode.VALIDATION_ERROR, "Invalid OAuth state")
raise HTTPException(status_code=400, detail="Invalid OAuth state")
oauth_state = json.loads(oauth_state_bytes.decode("utf-8"))
desired_return_url = cast(str, oauth_state[_DESIRED_RETURN_URL_KEY])

View File

@@ -961,9 +961,9 @@
"license": "MIT"
},
"node_modules/@hono/node-server": {
"version": "1.19.9",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz",
"integrity": "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==",
"version": "1.19.10",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.10.tgz",
"integrity": "sha512-hZ7nOssGqRgyV3FVVQdfi+U4q02uB23bpnYpdvNXkYTRRyWx84b7yf1ans+dnJ/7h41sGL3CeQTfO+ZGxuO+Iw==",
"license": "MIT",
"engines": {
"node": ">=18.14.1"
@@ -1573,27 +1573,6 @@
}
}
},
"node_modules/@isaacs/balanced-match": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
"integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==",
"license": "MIT",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@isaacs/brace-expansion": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz",
"integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==",
"license": "MIT",
"dependencies": {
"@isaacs/balanced-match": "^4.0.1"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -1680,9 +1659,9 @@
}
},
"node_modules/@modelcontextprotocol/sdk/node_modules/ajv": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
@@ -3855,6 +3834,27 @@
"path-browserify": "^1.0.1"
}
},
"node_modules/@ts-morph/common/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@ts-morph/common/node_modules/brace-expansion": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
"integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==",
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@ts-morph/common/node_modules/fast-glob": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
@@ -3884,15 +3884,15 @@
}
},
"node_modules/@ts-morph/common/node_modules/minimatch": {
"version": "10.1.1",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz",
"integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==",
"version": "10.2.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz",
"integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==",
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/brace-expansion": "^5.0.0"
"brace-expansion": "^5.0.2"
},
"engines": {
"node": "20 || >=22"
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
@@ -4234,13 +4234,13 @@
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
"brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
@@ -4619,9 +4619,9 @@
}
},
"node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz",
"integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4653,9 +4653,9 @@
}
},
"node_modules/ajv-formats/node_modules/ajv": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
@@ -8831,9 +8831,9 @@
}
},
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"dev": true,
"license": "ISC",
"dependencies": {

View File

@@ -60,9 +60,11 @@ class Settings(BaseModel):
deep_research_enabled: bool | None = None
search_ui_enabled: bool | None = None
# Enterprise features flag - set by license enforcement at runtime
# When LICENSE_ENFORCEMENT_ENABLED=true, this reflects license status
# When LICENSE_ENFORCEMENT_ENABLED=false, defaults to False
# Whether EE features are unlocked for use.
# Depends on license status: True when the user has a valid license
# (ACTIVE, GRACE_PERIOD, PAYMENT_REMINDER), False when there's no license
# or the license is expired (GATED_ACCESS).
# This controls UI visibility of EE features (user groups, analytics, RBAC, etc.).
ee_features_enabled: bool = False
temperature_override_enabled: bool | None = False

View File

@@ -65,7 +65,7 @@ attrs==25.4.0
# jsonschema
# referencing
# zeep
authlib==1.6.6
authlib==1.6.7
# via fastmcp
babel==2.17.0
# via courlan
@@ -109,9 +109,7 @@ brotli==1.2.0
bytecode==0.17.0
# via ddtrace
cachetools==6.2.2
# via
# google-auth
# py-key-value-aio
# via py-key-value-aio
caio==0.9.25
# via aiofile
celery==5.5.1
@@ -190,6 +188,7 @@ courlan==1.3.2
cryptography==46.0.5
# via
# authlib
# google-auth
# msal
# msoffcrypto-tool
# pdfminer-six
@@ -306,7 +305,7 @@ google-api-core==2.28.1
# google-cloud-storage
google-api-python-client==2.86.0
# via onyx
google-auth==2.43.0
google-auth==2.48.0
# via
# google-api-core
# google-api-python-client
@@ -325,7 +324,7 @@ google-auth-httplib2==0.1.0
# onyx
google-auth-oauthlib==1.0.0
# via onyx
google-cloud-aiplatform==1.121.0
google-cloud-aiplatform==1.133.0
# via onyx
google-cloud-bigquery==3.38.0
# via google-cloud-aiplatform
@@ -1002,9 +1001,7 @@ sendgrid==6.12.5
sentry-sdk==2.14.0
# via onyx
shapely==2.0.6
# via
# google-cloud-aiplatform
# onyx
# via onyx
shellingham==1.5.4
# via typer
simple-salesforce==1.12.6

View File

@@ -59,8 +59,6 @@ botocore==1.39.11
# s3transfer
brotli==1.2.0
# via onyx
cachetools==6.2.2
# via google-auth
celery-types==0.19.0
# via onyx
certifi==2025.11.12
@@ -100,7 +98,9 @@ comm==0.2.3
contourpy==1.3.3
# via matplotlib
cryptography==46.0.5
# via pyjwt
# via
# google-auth
# pyjwt
cycler==0.12.1
# via matplotlib
debugpy==1.8.17
@@ -152,7 +152,7 @@ google-api-core==2.28.1
# google-cloud-core
# google-cloud-resource-manager
# google-cloud-storage
google-auth==2.43.0
google-auth==2.48.0
# via
# google-api-core
# google-cloud-aiplatform
@@ -162,7 +162,7 @@ google-auth==2.43.0
# google-cloud-storage
# google-genai
# kubernetes
google-cloud-aiplatform==1.121.0
google-cloud-aiplatform==1.133.0
# via onyx
google-cloud-bigquery==3.38.0
# via google-cloud-aiplatform
@@ -311,13 +311,12 @@ numpy==2.4.1
# contourpy
# matplotlib
# pandas-stubs
# shapely
# voyageai
oauthlib==3.2.2
# via
# kubernetes
# requests-oauthlib
onyx-devtools==0.6.2
onyx-devtools==0.6.3
# via onyx
openai==2.14.0
# via
@@ -510,8 +509,6 @@ s3transfer==0.13.1
# via boto3
sentry-sdk==2.14.0
# via onyx
shapely==2.0.6
# via google-cloud-aiplatform
six==1.17.0
# via
# kubernetes

View File

@@ -53,8 +53,6 @@ botocore==1.39.11
# s3transfer
brotli==1.2.0
# via onyx
cachetools==6.2.2
# via google-auth
certifi==2025.11.12
# via
# httpcore
@@ -79,7 +77,9 @@ colorama==0.4.6 ; sys_platform == 'win32'
# click
# tqdm
cryptography==46.0.5
# via pyjwt
# via
# google-auth
# pyjwt
decorator==5.2.1
# via retry
discord-py==2.4.0
@@ -111,7 +111,7 @@ google-api-core==2.28.1
# google-cloud-core
# google-cloud-resource-manager
# google-cloud-storage
google-auth==2.43.0
google-auth==2.48.0
# via
# google-api-core
# google-cloud-aiplatform
@@ -121,7 +121,7 @@ google-auth==2.43.0
# google-cloud-storage
# google-genai
# kubernetes
google-cloud-aiplatform==1.121.0
google-cloud-aiplatform==1.133.0
# via onyx
google-cloud-bigquery==3.38.0
# via google-cloud-aiplatform
@@ -221,9 +221,7 @@ multidict==6.7.0
# aiohttp
# yarl
numpy==2.4.1
# via
# shapely
# voyageai
# via voyageai
oauthlib==3.2.2
# via
# kubernetes
@@ -345,8 +343,6 @@ s3transfer==0.13.1
# via boto3
sentry-sdk==2.14.0
# via onyx
shapely==2.0.6
# via google-cloud-aiplatform
six==1.17.0
# via
# kubernetes

View File

@@ -57,8 +57,6 @@ botocore==1.39.11
# s3transfer
brotli==1.2.0
# via onyx
cachetools==6.2.2
# via google-auth
celery==5.5.1
# via sentry-sdk
certifi==2025.11.12
@@ -95,7 +93,9 @@ colorama==0.4.6 ; sys_platform == 'win32'
# click
# tqdm
cryptography==46.0.5
# via pyjwt
# via
# google-auth
# pyjwt
decorator==5.2.1
# via retry
discord-py==2.4.0
@@ -136,7 +136,7 @@ google-api-core==2.28.1
# google-cloud-core
# google-cloud-resource-manager
# google-cloud-storage
google-auth==2.43.0
google-auth==2.48.0
# via
# google-api-core
# google-cloud-aiplatform
@@ -146,7 +146,7 @@ google-auth==2.43.0
# google-cloud-storage
# google-genai
# kubernetes
google-cloud-aiplatform==1.121.0
google-cloud-aiplatform==1.133.0
# via onyx
google-cloud-bigquery==3.38.0
# via google-cloud-aiplatform
@@ -263,7 +263,6 @@ numpy==2.4.1
# onyx
# scikit-learn
# scipy
# shapely
# transformers
# voyageai
nvidia-cublas-cu12==12.8.4.1 ; platform_machine == 'x86_64' and sys_platform == 'linux'
@@ -452,8 +451,6 @@ sentry-sdk==2.14.0
# via onyx
setuptools==80.9.0 ; python_full_version >= '3.12'
# via torch
shapely==2.0.6
# via google-cloud-aiplatform
six==1.17.0
# via
# kubernetes

View File

@@ -0,0 +1,238 @@
"""
Tests that the default model cannot be removed or hidden via provider upsert.
When a model is set as the default (for any flow type), attempts to remove it
from the provider's model list or set its visibility to False should raise a
ValueError (which the API layer converts to OnyxError VALIDATION_ERROR).
"""
from collections.abc import Generator
from uuid import uuid4
import pytest
from sqlalchemy.orm import Session
from onyx.db.llm import fetch_existing_llm_provider
from onyx.db.llm import remove_llm_provider
from onyx.db.llm import update_default_provider
from onyx.db.llm import update_default_vision_provider
from onyx.db.llm import upsert_llm_provider
from onyx.llm.constants import LlmProviderNames
from onyx.server.manage.llm.models import LLMProviderUpsertRequest
from onyx.server.manage.llm.models import LLMProviderView
from onyx.server.manage.llm.models import ModelConfigurationUpsertRequest
def _create_test_provider(
db_session: Session,
name: str,
models: list[ModelConfigurationUpsertRequest] | None = None,
) -> LLMProviderView:
"""Helper to create a test LLM provider with multiple models."""
if models is None:
models = [
ModelConfigurationUpsertRequest(
name="gpt-4o", is_visible=True, supports_image_input=True
),
ModelConfigurationUpsertRequest(
name="gpt-4o-mini", is_visible=True, supports_image_input=False
),
]
return upsert_llm_provider(
LLMProviderUpsertRequest(
name=name,
provider=LlmProviderNames.OPENAI,
api_key="sk-test-key-00000000000000000000000000000000000",
api_key_changed=True,
model_configurations=models,
),
db_session=db_session,
)
def _cleanup_provider(db_session: Session, name: str) -> None:
"""Helper to clean up a test provider by name."""
provider = fetch_existing_llm_provider(name=name, db_session=db_session)
if provider:
remove_llm_provider(db_session, provider.id)
@pytest.fixture
def provider_name() -> Generator[str, None, None]:
"""Generate a unique provider name for each test."""
yield f"test-provider-{uuid4().hex[:8]}"
class TestDefaultModelProtection:
"""Tests that the default model cannot be removed or hidden."""
def test_cannot_remove_default_text_model(
self,
db_session: Session,
provider_name: str,
) -> None:
"""Removing the default text model from a provider should raise ValueError."""
try:
provider = _create_test_provider(db_session, provider_name)
update_default_provider(provider.id, "gpt-4o", db_session)
# Try to update the provider without the default model
with pytest.raises(ValueError, match="Cannot remove the default model"):
upsert_llm_provider(
LLMProviderUpsertRequest(
id=provider.id,
name=provider_name,
provider=LlmProviderNames.OPENAI,
api_key="sk-test-key-00000000000000000000000000000000000",
api_key_changed=True,
model_configurations=[
ModelConfigurationUpsertRequest(
name="gpt-4o-mini", is_visible=True
),
],
),
db_session=db_session,
)
finally:
db_session.rollback()
_cleanup_provider(db_session, provider_name)
def test_cannot_hide_default_text_model(
self,
db_session: Session,
provider_name: str,
) -> None:
"""Setting is_visible=False on the default text model should raise ValueError."""
try:
provider = _create_test_provider(db_session, provider_name)
update_default_provider(provider.id, "gpt-4o", db_session)
# Try to hide the default model
with pytest.raises(ValueError, match="Cannot hide the default model"):
upsert_llm_provider(
LLMProviderUpsertRequest(
id=provider.id,
name=provider_name,
provider=LlmProviderNames.OPENAI,
api_key="sk-test-key-00000000000000000000000000000000000",
api_key_changed=True,
model_configurations=[
ModelConfigurationUpsertRequest(
name="gpt-4o", is_visible=False
),
ModelConfigurationUpsertRequest(
name="gpt-4o-mini", is_visible=True
),
],
),
db_session=db_session,
)
finally:
db_session.rollback()
_cleanup_provider(db_session, provider_name)
def test_cannot_remove_default_vision_model(
self,
db_session: Session,
provider_name: str,
) -> None:
"""Removing the default vision model from a provider should raise ValueError."""
try:
provider = _create_test_provider(db_session, provider_name)
# Set gpt-4o as both the text and vision default
update_default_provider(provider.id, "gpt-4o", db_session)
update_default_vision_provider(provider.id, "gpt-4o", db_session)
# Try to remove the default vision model
with pytest.raises(ValueError, match="Cannot remove the default model"):
upsert_llm_provider(
LLMProviderUpsertRequest(
id=provider.id,
name=provider_name,
provider=LlmProviderNames.OPENAI,
api_key="sk-test-key-00000000000000000000000000000000000",
api_key_changed=True,
model_configurations=[
ModelConfigurationUpsertRequest(
name="gpt-4o-mini", is_visible=True
),
],
),
db_session=db_session,
)
finally:
db_session.rollback()
_cleanup_provider(db_session, provider_name)
def test_can_remove_non_default_model(
self,
db_session: Session,
provider_name: str,
) -> None:
"""Removing a non-default model should succeed."""
try:
provider = _create_test_provider(db_session, provider_name)
update_default_provider(provider.id, "gpt-4o", db_session)
# Remove gpt-4o-mini (not default) — should succeed
updated = upsert_llm_provider(
LLMProviderUpsertRequest(
id=provider.id,
name=provider_name,
provider=LlmProviderNames.OPENAI,
api_key="sk-test-key-00000000000000000000000000000000000",
api_key_changed=True,
model_configurations=[
ModelConfigurationUpsertRequest(
name="gpt-4o", is_visible=True, supports_image_input=True
),
],
),
db_session=db_session,
)
model_names = {mc.name for mc in updated.model_configurations}
assert "gpt-4o" in model_names
assert "gpt-4o-mini" not in model_names
finally:
db_session.rollback()
_cleanup_provider(db_session, provider_name)
def test_can_hide_non_default_model(
self,
db_session: Session,
provider_name: str,
) -> None:
"""Hiding a non-default model should succeed."""
try:
provider = _create_test_provider(db_session, provider_name)
update_default_provider(provider.id, "gpt-4o", db_session)
# Hide gpt-4o-mini (not default) — should succeed
updated = upsert_llm_provider(
LLMProviderUpsertRequest(
id=provider.id,
name=provider_name,
provider=LlmProviderNames.OPENAI,
api_key="sk-test-key-00000000000000000000000000000000000",
api_key_changed=True,
model_configurations=[
ModelConfigurationUpsertRequest(
name="gpt-4o", is_visible=True, supports_image_input=True
),
ModelConfigurationUpsertRequest(
name="gpt-4o-mini", is_visible=False
),
],
),
db_session=db_session,
)
model_visibility = {
mc.name: mc.is_visible for mc in updated.model_configurations
}
assert model_visibility["gpt-4o"] is True
assert model_visibility["gpt-4o-mini"] is False
finally:
db_session.rollback()
_cleanup_provider(db_session, provider_name)

View File

@@ -281,9 +281,10 @@ class TestApplyLicenseStatusToSettings:
}
class TestSettingsDefaultEEDisabled:
"""Verify the Settings model defaults ee_features_enabled to False."""
class TestSettingsDefaults:
"""Verify Settings model defaults for CE deployments."""
def test_default_ee_features_disabled(self) -> None:
"""CE default: ee_features_enabled is False."""
settings = Settings()
assert settings.ee_features_enabled is False

View File

@@ -2,7 +2,6 @@
import pytest
from onyx.chat.llm_loop import _should_keep_bedrock_tool_definitions
from onyx.chat.llm_loop import _try_fallback_tool_extraction
from onyx.chat.llm_loop import construct_message_history
from onyx.chat.models import ChatLoadedFile
@@ -14,22 +13,11 @@ from onyx.chat.models import LlmStepResult
from onyx.chat.models import ToolCallSimple
from onyx.configs.constants import MessageType
from onyx.file_store.models import ChatFileType
from onyx.llm.constants import LlmProviderNames
from onyx.llm.interfaces import ToolChoiceOptions
from onyx.server.query_and_chat.placement import Placement
from onyx.tools.models import ToolCallKickoff
class _StubConfig:
def __init__(self, model_provider: str) -> None:
self.model_provider = model_provider
class _StubLLM:
def __init__(self, model_provider: str) -> None:
self.config = _StubConfig(model_provider=model_provider)
def create_message(
content: str, message_type: MessageType, token_count: int | None = None
) -> ChatMessageSimple:
@@ -946,37 +934,6 @@ class TestForgottenFileMetadata:
assert "moby_dick.txt" in forgotten.message
class TestBedrockToolConfigGuard:
def test_bedrock_with_tool_history_keeps_tool_definitions(self) -> None:
llm = _StubLLM(LlmProviderNames.BEDROCK)
history = [
create_message("Question", MessageType.USER, 5),
create_assistant_with_tool_call("tc_1", "search", 5),
create_tool_response("tc_1", "Tool output", 5),
]
assert _should_keep_bedrock_tool_definitions(llm, history) is True
def test_bedrock_without_tool_history_does_not_keep_tool_definitions(self) -> None:
llm = _StubLLM(LlmProviderNames.BEDROCK)
history = [
create_message("Question", MessageType.USER, 5),
create_message("Answer", MessageType.ASSISTANT, 5),
]
assert _should_keep_bedrock_tool_definitions(llm, history) is False
def test_non_bedrock_with_tool_history_does_not_keep_tool_definitions(self) -> None:
llm = _StubLLM(LlmProviderNames.OPENAI)
history = [
create_message("Question", MessageType.USER, 5),
create_assistant_with_tool_call("tc_1", "search", 5),
create_tool_response("tc_1", "Tool output", 5),
]
assert _should_keep_bedrock_tool_definitions(llm, history) is False
class TestFallbackToolExtraction:
def _tool_defs(self) -> list[dict]:
return [

View File

@@ -1214,3 +1214,218 @@ def test_multithreaded_invoke_without_custom_config_skips_env_lock() -> None:
# The env lock context manager should never have been called
mock_env_lock.assert_not_called()
# ---- Tests for Bedrock tool content stripping ----
def test_messages_contain_tool_content_with_tool_role() -> None:
from onyx.llm.multi_llm import _messages_contain_tool_content
messages: list[dict[str, Any]] = [
{"role": "user", "content": "Hello"},
{"role": "assistant", "content": "I'll search for that."},
{"role": "tool", "content": "search results", "tool_call_id": "tc_1"},
]
assert _messages_contain_tool_content(messages) is True
def test_messages_contain_tool_content_with_tool_calls() -> None:
from onyx.llm.multi_llm import _messages_contain_tool_content
messages: list[dict[str, Any]] = [
{"role": "user", "content": "Hello"},
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "tc_1",
"type": "function",
"function": {"name": "search", "arguments": "{}"},
}
],
},
]
assert _messages_contain_tool_content(messages) is True
def test_messages_contain_tool_content_without_tools() -> None:
from onyx.llm.multi_llm import _messages_contain_tool_content
messages: list[dict[str, Any]] = [
{"role": "user", "content": "Hello"},
{"role": "assistant", "content": "Hi there!"},
]
assert _messages_contain_tool_content(messages) is False
def test_strip_tool_content_converts_assistant_tool_calls_to_text() -> None:
from onyx.llm.multi_llm import _strip_tool_content_from_messages
messages: list[dict[str, Any]] = [
{"role": "user", "content": "Search for cats"},
{
"role": "assistant",
"content": "Let me search.",
"tool_calls": [
{
"id": "tc_1",
"type": "function",
"function": {
"name": "search",
"arguments": '{"query": "cats"}',
},
}
],
},
{
"role": "tool",
"content": "Found 3 results about cats.",
"tool_call_id": "tc_1",
},
{"role": "assistant", "content": "Here are the results."},
]
result = _strip_tool_content_from_messages(messages)
assert len(result) == 4
# First message unchanged
assert result[0] == {"role": "user", "content": "Search for cats"}
# Assistant with tool calls → plain text
assert result[1]["role"] == "assistant"
assert "tool_calls" not in result[1]
assert "Let me search." in result[1]["content"]
assert "[Tool Call]" in result[1]["content"]
assert "search" in result[1]["content"]
assert "tc_1" in result[1]["content"]
# Tool response → user message
assert result[2]["role"] == "user"
assert "[Tool Result]" in result[2]["content"]
assert "tc_1" in result[2]["content"]
assert "Found 3 results about cats." in result[2]["content"]
# Final assistant message unchanged
assert result[3] == {"role": "assistant", "content": "Here are the results."}
def test_strip_tool_content_handles_assistant_with_no_text_content() -> None:
from onyx.llm.multi_llm import _strip_tool_content_from_messages
messages: list[dict[str, Any]] = [
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "tc_1",
"type": "function",
"function": {"name": "search", "arguments": "{}"},
}
],
},
]
result = _strip_tool_content_from_messages(messages)
assert result[0]["role"] == "assistant"
assert "[Tool Call]" in result[0]["content"]
assert "tool_calls" not in result[0]
def test_strip_tool_content_passes_through_non_tool_messages() -> None:
from onyx.llm.multi_llm import _strip_tool_content_from_messages
messages: list[dict[str, Any]] = [
{"role": "system", "content": "You are helpful."},
{"role": "user", "content": "Hello"},
{"role": "assistant", "content": "Hi!"},
]
result = _strip_tool_content_from_messages(messages)
assert result == messages
def test_strip_tool_content_handles_list_content_blocks() -> None:
from onyx.llm.multi_llm import _strip_tool_content_from_messages
messages: list[dict[str, Any]] = [
{
"role": "assistant",
"content": [{"type": "text", "text": "Searching now."}],
"tool_calls": [
{
"id": "tc_1",
"type": "function",
"function": {"name": "search", "arguments": "{}"},
}
],
},
{
"role": "tool",
"content": [
{"type": "text", "text": "result A"},
{"type": "text", "text": "result B"},
],
"tool_call_id": "tc_1",
},
]
result = _strip_tool_content_from_messages(messages)
# Assistant: list content flattened + tool call appended
assert result[0]["role"] == "assistant"
assert "Searching now." in result[0]["content"]
assert "[Tool Call]" in result[0]["content"]
assert isinstance(result[0]["content"], str)
# Tool: list content flattened into user message
assert result[1]["role"] == "user"
assert "result A" in result[1]["content"]
assert "result B" in result[1]["content"]
assert isinstance(result[1]["content"], str)
def test_strip_tool_content_merges_consecutive_tool_results() -> None:
"""Bedrock requires strict user/assistant alternation. Multiple parallel
tool results must be merged into a single user message."""
from onyx.llm.multi_llm import _strip_tool_content_from_messages
messages: list[dict[str, Any]] = [
{"role": "user", "content": "weather and news?"},
{
"role": "assistant",
"content": None,
"tool_calls": [
{
"id": "tc_1",
"type": "function",
"function": {"name": "search_weather", "arguments": "{}"},
},
{
"id": "tc_2",
"type": "function",
"function": {"name": "search_news", "arguments": "{}"},
},
],
},
{"role": "tool", "content": "sunny 72F", "tool_call_id": "tc_1"},
{"role": "tool", "content": "headline news", "tool_call_id": "tc_2"},
{"role": "assistant", "content": "Here are the results."},
]
result = _strip_tool_content_from_messages(messages)
# user, assistant (flattened), user (merged tool results), assistant
assert len(result) == 4
roles = [m["role"] for m in result]
assert roles == ["user", "assistant", "user", "assistant"]
# Both tool results merged into one user message
merged = result[2]["content"]
assert "tc_1" in merged
assert "sunny 72F" in merged
assert "tc_2" in merged
assert "headline news" in merged

View File

@@ -11,7 +11,7 @@ dependencies = [
"aioboto3==15.1.0",
"cohere==5.6.1",
"fastapi==0.133.1",
"google-cloud-aiplatform==1.121.0",
"google-cloud-aiplatform==1.133.0",
"google-genai==1.52.0",
"litellm==1.81.6",
"openai==2.14.0",
@@ -144,7 +144,7 @@ dev = [
"matplotlib==3.10.8",
"mypy-extensions==1.0.0",
"mypy==1.13.0",
"onyx-devtools==0.6.2",
"onyx-devtools==0.6.3",
"openapi-generator-cli==7.17.0",
"pandas-stubs~=2.3.3",
"pre-commit==3.2.2",

View File

@@ -6,6 +6,7 @@ import (
"os"
"os/exec"
"regexp"
"strconv"
"strings"
log "github.com/sirupsen/logrus"
@@ -33,11 +34,15 @@ func NewCherryPickCommand() *cobra.Command {
opts := &CherryPickOptions{}
cmd := &cobra.Command{
Use: "cherry-pick <commit-sha> [<commit-sha>...]",
Use: "cherry-pick <commit-or-pr> [<commit-or-pr>...]",
Aliases: []string{"cp"},
Short: "Cherry-pick one or more commits to a release branch",
Short: "Cherry-pick one or more commits (or PRs) to a release branch",
Long: `Cherry-pick one or more commits to a release branch and create a PR.
Arguments can be commit SHAs or GitHub PR numbers. A purely numeric argument
with fewer than 6 digits is treated as a PR number and resolved to its merge
commit automatically.
This command will:
1. Find the nearest stable version tag
2. Fetch the corresponding release branch(es)
@@ -54,7 +59,8 @@ If a cherry-pick hits a merge conflict, resolve it manually, then run:
Example usage:
$ ods cherry-pick foo123 bar456 --release 2.5 --release 2.6
$ ods cp foo123 --release 2.5`,
$ ods cp foo123 --release 2.5
$ ods cp 1234 --release 2.5 # cherry-pick merge commit of PR #1234`,
Args: func(cmd *cobra.Command, args []string) error {
cont, _ := cmd.Flags().GetBool("continue")
if cont {
@@ -90,11 +96,12 @@ Example usage:
func runCherryPick(cmd *cobra.Command, args []string, opts *CherryPickOptions) {
git.CheckGitHubCLI()
commitSHAs := args
// Resolve any PR numbers (e.g. "1234") to their merge commit SHAs
commitSHAs, labels := resolveArgs(args)
if len(commitSHAs) == 1 {
log.Debugf("Cherry-picking commit: %s", commitSHAs[0])
log.Debugf("Cherry-picking %s (%s)", labels[0], commitSHAs[0])
} else {
log.Debugf("Cherry-picking %d commits: %s", len(commitSHAs), strings.Join(commitSHAs, ", "))
log.Debugf("Cherry-picking %d commits: %s", len(commitSHAs), strings.Join(labels, ", "))
}
if opts.DryRun {
@@ -294,6 +301,11 @@ func runCherryPickContinue() {
log.Infof("Resuming cherry-pick (original branch: %s, releases: %v)", state.OriginalBranch, state.Releases)
// If a rebase is in progress (REBASE_HEAD exists), it must be resolved first
if git.IsRebaseInProgress() {
log.Fatal("A git rebase is in progress. Resolve it first:\n To continue: git rebase --continue\n To abort: git rebase --abort\nThen re-run: ods cherry-pick --continue")
}
// If git cherry-pick is still in progress (CHERRY_PICK_HEAD exists), continue it
if git.IsCherryPickInProgress() {
log.Info("Continuing in-progress cherry-pick...")
@@ -327,6 +339,23 @@ func cherryPickToRelease(commitSHAs, commitMessages []string, branchSuffix, vers
return "", fmt.Errorf("failed to checkout existing hotfix branch: %w", err)
}
// Only rebase when the branch has no unique commits (pure fast-forward).
// If unique commits exist (e.g. after --continue resolved a cherry-pick
// conflict), rebasing would re-apply them and risk the same conflicts.
remoteRef := fmt.Sprintf("origin/%s", releaseBranch)
uniqueCount, err := git.CountUniqueCommits(hotfixBranch, remoteRef)
if err != nil {
log.Warnf("Could not determine unique commits, skipping rebase: %v", err)
} else if uniqueCount == 0 {
log.Infof("Rebasing %s onto %s", hotfixBranch, releaseBranch)
if err := git.RunCommand("rebase", "--quiet", remoteRef); err != nil {
_ = git.RunCommand("rebase", "--abort")
return "", fmt.Errorf("failed to rebase hotfix branch onto %s (rebase aborted, re-run to retry): %w", releaseBranch, err)
}
} else {
log.Infof("Branch %s has %d unique commit(s), skipping rebase", hotfixBranch, uniqueCount)
}
// Check which commits need to be cherry-picked
commitsToCherry := []string{}
for _, sha := range commitSHAs {
@@ -364,7 +393,6 @@ func cherryPickToRelease(commitSHAs, commitMessages []string, branchSuffix, vers
return "", nil
}
// Push the hotfix branch
log.Infof("Pushing hotfix branch: %s", hotfixBranch)
pushArgs := []string{"push", "-u", "origin", hotfixBranch}
if noVerify {
@@ -432,6 +460,40 @@ func performCherryPick(commitSHAs []string) error {
return nil
}
// isPRNumber returns true if the argument looks like a GitHub PR number
// (purely numeric with fewer than 6 digits).
func isPRNumber(arg string) bool {
if len(arg) == 0 || len(arg) >= 6 {
return false
}
n, err := strconv.Atoi(arg)
return err == nil && n > 0
}
// resolveArgs resolves arguments that may be PR numbers into commit SHAs.
// Returns the resolved commit SHAs and a display-friendly label for logging
// (e.g. "PR #1234" instead of raw SHA).
func resolveArgs(args []string) (commitSHAs []string, labels []string) {
commitSHAs = make([]string, len(args))
labels = make([]string, len(args))
for i, arg := range args {
if isPRNumber(arg) {
log.Infof("Resolving PR #%s to merge commit...", arg)
sha, err := git.ResolvePRToMergeCommit(arg)
if err != nil {
log.Fatalf("Failed to resolve PR #%s: %v", arg, err)
}
log.Infof("PR #%s → %s", arg, sha)
commitSHAs[i] = sha
labels[i] = fmt.Sprintf("PR #%s", arg)
} else {
commitSHAs[i] = arg
labels[i] = arg
}
}
return commitSHAs, labels
}
// normalizeVersion ensures the version has a 'v' prefix
func normalizeVersion(version string) string {
if !strings.HasPrefix(version, "v") {

144
tools/ods/cmd/desktop.go Normal file
View File

@@ -0,0 +1,144 @@
package cmd
import (
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"github.com/onyx-dot-app/onyx/tools/ods/internal/paths"
)
type desktopPackageJSON struct {
Scripts map[string]string `json:"scripts"`
}
// NewDesktopCommand creates a command that runs npm scripts from the desktop directory.
func NewDesktopCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "desktop <script> [args...]",
Short: "Run desktop/package.json npm scripts",
Long: desktopHelpDescription(),
Args: cobra.MinimumNArgs(1),
ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
if len(args) > 0 {
return nil, cobra.ShellCompDirectiveNoFileComp
}
return desktopScriptNames(), cobra.ShellCompDirectiveNoFileComp
},
Run: func(cmd *cobra.Command, args []string) {
runDesktopScript(args)
},
}
cmd.Flags().SetInterspersed(false)
return cmd
}
func runDesktopScript(args []string) {
desktopDir, err := desktopDir()
if err != nil {
log.Fatalf("Failed to find desktop directory: %v", err)
}
scriptName := args[0]
scriptArgs := args[1:]
if len(scriptArgs) > 0 && scriptArgs[0] == "--" {
scriptArgs = scriptArgs[1:]
}
npmArgs := []string{"run", scriptName}
if len(scriptArgs) > 0 {
// npm requires "--" to forward flags to the underlying script.
npmArgs = append(npmArgs, "--")
npmArgs = append(npmArgs, scriptArgs...)
}
log.Debugf("Running in %s: npm %v", desktopDir, npmArgs)
desktopCmd := exec.Command("npm", npmArgs...)
desktopCmd.Dir = desktopDir
desktopCmd.Stdout = os.Stdout
desktopCmd.Stderr = os.Stderr
desktopCmd.Stdin = os.Stdin
if err := desktopCmd.Run(); err != nil {
// For wrapped commands, preserve the child process's exit code and
// avoid duplicating already-printed stderr output.
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
if code := exitErr.ExitCode(); code != -1 {
os.Exit(code)
}
}
log.Fatalf("Failed to run npm: %v", err)
}
}
func desktopScriptNames() []string {
scripts, err := loadDesktopScripts()
if err != nil {
return nil
}
names := make([]string, 0, len(scripts))
for name := range scripts {
names = append(names, name)
}
sort.Strings(names)
return names
}
func desktopHelpDescription() string {
description := `Run npm scripts from desktop/package.json.
Examples:
ods desktop dev
ods desktop build
ods desktop build:dmg`
scripts := desktopScriptNames()
if len(scripts) == 0 {
return description + "\n\nAvailable scripts: (unable to load)"
}
return description + "\n\nAvailable scripts:\n " + strings.Join(scripts, "\n ")
}
func loadDesktopScripts() (map[string]string, error) {
desktopDir, err := desktopDir()
if err != nil {
return nil, err
}
packageJSONPath := filepath.Join(desktopDir, "package.json")
data, err := os.ReadFile(packageJSONPath)
if err != nil {
return nil, fmt.Errorf("failed to read %s: %w", packageJSONPath, err)
}
var pkg desktopPackageJSON
if err := json.Unmarshal(data, &pkg); err != nil {
return nil, fmt.Errorf("failed to parse %s: %w", packageJSONPath, err)
}
if pkg.Scripts == nil {
return nil, nil
}
return pkg.Scripts, nil
}
func desktopDir() (string, error) {
root, err := paths.GitRoot()
if err != nil {
return "", err
}
return filepath.Join(root, "desktop"), nil
}

View File

@@ -50,6 +50,7 @@ func NewRootCommand() *cobra.Command {
cmd.AddCommand(NewPullCommand())
cmd.AddCommand(NewRunCICommand())
cmd.AddCommand(NewScreenshotDiffCommand())
cmd.AddCommand(NewDesktopCommand())
cmd.AddCommand(NewWebCommand())
cmd.AddCommand(NewWhoisCommand())

View File

@@ -1,14 +1,14 @@
module github.com/onyx-dot-app/onyx/tools/ods
go 1.24.11
go 1.26.0
require (
github.com/sirupsen/logrus v1.9.3
github.com/spf13/cobra v1.10.1
github.com/spf13/pflag v1.0.9
)
require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/spf13/pflag v1.0.9 // indirect
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect
)

View File

@@ -6,6 +6,7 @@ import (
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
log "github.com/sirupsen/logrus"
@@ -173,6 +174,26 @@ func IsCherryPickInProgress() bool {
return cmd.Run() == nil
}
// CountUniqueCommits returns the number of commits on branch that are not on upstream.
func CountUniqueCommits(branch, upstream string) (int, error) {
cmd := exec.Command("git", "rev-list", "--count", fmt.Sprintf("%s..%s", upstream, branch))
output, err := cmd.Output()
if err != nil {
return 0, fmt.Errorf("git rev-list --count failed: %w", err)
}
count, err := strconv.Atoi(strings.TrimSpace(string(output)))
if err != nil {
return 0, fmt.Errorf("failed to parse commit count: %w", err)
}
return count, nil
}
// IsRebaseInProgress checks if a rebase is currently in progress
func IsRebaseInProgress() bool {
cmd := exec.Command("git", "rev-parse", "--verify", "--quiet", "REBASE_HEAD")
return cmd.Run() == nil
}
// HasStagedChanges checks if there are staged changes in the index
func HasStagedChanges() bool {
cmd := exec.Command("git", "diff", "--quiet", "--cached")
@@ -216,6 +237,23 @@ func IsCommitAppliedOnBranch(commitSHA, branchName string) bool {
return false
}
// ResolvePRToMergeCommit resolves a GitHub PR number to its merge commit SHA
func ResolvePRToMergeCommit(prNumber string) (string, error) {
cmd := exec.Command("gh", "pr", "view", prNumber, "--json", "mergeCommit", "--jq", ".mergeCommit.oid")
output, err := cmd.Output()
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
return "", fmt.Errorf("gh pr view failed: %w: %s", err, string(exitErr.Stderr))
}
return "", fmt.Errorf("gh pr view failed: %w", err)
}
sha := strings.TrimSpace(string(output))
if sha == "" || sha == "null" {
return "", fmt.Errorf("PR #%s has no merge commit (is it merged?)", prNumber)
}
return sha, nil
}
// RunCherryPickContinue runs git cherry-pick --continue --no-edit
func RunCherryPickContinue() error {
return RunCommandVerboseOnError("cherry-pick", "--continue", "--no-edit")

View File

@@ -1,5 +1,5 @@
[build-system]
requires = ["hatchling", "go-bin~=1.24.11", "manygo"]
requires = ["hatchling", "go-bin~=1.26.0", "manygo"]
build-backend = "hatchling.build"
[project]

49
uv.lock generated
View File

@@ -453,14 +453,14 @@ wheels = [
[[package]]
name = "authlib"
version = "1.6.6"
version = "1.6.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bb/9b/b1661026ff24bc641b76b78c5222d614776b0c085bcfdac9bd15a1cb4b35/authlib-1.6.6.tar.gz", hash = "sha256:45770e8e056d0f283451d9996fbb59b70d45722b45d854d58f32878d0a40c38e", size = 164894, upload-time = "2025-12-12T08:01:41.464Z" }
sdist = { url = "https://files.pythonhosted.org/packages/49/dc/ed1681bf1339dd6ea1ce56136bad4baabc6f7ad466e375810702b0237047/authlib-1.6.7.tar.gz", hash = "sha256:dbf10100011d1e1b34048c9d120e83f13b35d69a826ae762b93d2fb5aafc337b", size = 164950, upload-time = "2026-02-06T14:04:14.171Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/51/321e821856452f7386c4e9df866f196720b1ad0c5ea1623ea7399969ae3b/authlib-1.6.6-py2.py3-none-any.whl", hash = "sha256:7d9e9bc535c13974313a87f53e8430eb6ea3d1cf6ae4f6efcd793f2e949143fd", size = 244005, upload-time = "2025-12-12T08:01:40.209Z" },
{ url = "https://files.pythonhosted.org/packages/f8/00/3ed12264094ec91f534fae429945efbaa9f8c666f3aa7061cc3b2a26a0cd/authlib-1.6.7-py2.py3-none-any.whl", hash = "sha256:c637340d9a02789d2efa1d003a7437d10d3e565237bcb5fcbc6c134c7b95bab0", size = 244115, upload-time = "2026-02-06T14:04:12.141Z" },
]
[[package]]
@@ -756,12 +756,20 @@ sdist = { url = "https://files.pythonhosted.org/packages/92/88/b8527e1b00c1811db
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/90/543f556fcfcfa270713eef906b6352ab048e1e557afec12925c991dc93c2/caio-0.9.25-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d6956d9e4a27021c8bd6c9677f3a59eb1d820cc32d0343cea7961a03b1371965", size = 36839, upload-time = "2025-12-26T15:21:40.267Z" },
{ url = "https://files.pythonhosted.org/packages/51/3b/36f3e8ec38dafe8de4831decd2e44c69303d2a3892d16ceda42afed44e1b/caio-0.9.25-cp311-cp311-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bf84bfa039f25ad91f4f52944452a5f6f405e8afab4d445450978cd6241d1478", size = 80255, upload-time = "2025-12-26T15:22:20.271Z" },
{ url = "https://files.pythonhosted.org/packages/df/ce/65e64867d928e6aff1b4f0e12dba0ef6d5bf412c240dc1df9d421ac10573/caio-0.9.25-cp311-cp311-manylinux_2_34_aarch64.whl", hash = "sha256:ae3d62587332bce600f861a8de6256b1014d6485cfd25d68c15caf1611dd1f7c", size = 80052, upload-time = "2026-03-04T22:08:20.402Z" },
{ url = "https://files.pythonhosted.org/packages/46/90/e278863c47e14ec58309aa2e38a45882fbe67b4cc29ec9bc8f65852d3e45/caio-0.9.25-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:fc220b8533dcf0f238a6b1a4a937f92024c71e7b10b5a2dfc1c73604a25709bc", size = 78273, upload-time = "2026-03-04T22:08:21.368Z" },
{ url = "https://files.pythonhosted.org/packages/d3/25/79c98ebe12df31548ba4eaf44db11b7cad6b3e7b4203718335620939083c/caio-0.9.25-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fb7ff95af4c31ad3f03179149aab61097a71fd85e05f89b4786de0359dffd044", size = 36983, upload-time = "2025-12-26T15:21:36.075Z" },
{ url = "https://files.pythonhosted.org/packages/a3/2b/21288691f16d479945968a0a4f2856818c1c5be56881d51d4dac9b255d26/caio-0.9.25-cp312-cp312-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:97084e4e30dfa598449d874c4d8e0c8d5ea17d2f752ef5e48e150ff9d240cd64", size = 82012, upload-time = "2025-12-26T15:22:20.983Z" },
{ url = "https://files.pythonhosted.org/packages/03/c4/8a1b580875303500a9c12b9e0af58cb82e47f5bcf888c2457742a138273c/caio-0.9.25-cp312-cp312-manylinux_2_34_aarch64.whl", hash = "sha256:4fa69eba47e0f041b9d4f336e2ad40740681c43e686b18b191b6c5f4c5544bfb", size = 81502, upload-time = "2026-03-04T22:08:22.381Z" },
{ url = "https://files.pythonhosted.org/packages/d1/1c/0fe770b8ffc8362c48134d1592d653a81a3d8748d764bec33864db36319d/caio-0.9.25-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:6bebf6f079f1341d19f7386db9b8b1f07e8cc15ae13bfdaff573371ba0575d69", size = 80200, upload-time = "2026-03-04T22:08:23.382Z" },
{ url = "https://files.pythonhosted.org/packages/31/57/5e6ff127e6f62c9f15d989560435c642144aa4210882f9494204bc892305/caio-0.9.25-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d6c2a3411af97762a2b03840c3cec2f7f728921ff8adda53d7ea2315a8563451", size = 36979, upload-time = "2025-12-26T15:21:35.484Z" },
{ url = "https://files.pythonhosted.org/packages/a3/9f/f21af50e72117eb528c422d4276cbac11fb941b1b812b182e0a9c70d19c5/caio-0.9.25-cp313-cp313-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0998210a4d5cd5cb565b32ccfe4e53d67303f868a76f212e002a8554692870e6", size = 81900, upload-time = "2025-12-26T15:22:21.919Z" },
{ url = "https://files.pythonhosted.org/packages/9c/12/c39ae2a4037cb10ad5eb3578eb4d5f8c1a2575c62bba675f3406b7ef0824/caio-0.9.25-cp313-cp313-manylinux_2_34_aarch64.whl", hash = "sha256:1a177d4777141b96f175fe2c37a3d96dec7911ed9ad5f02bac38aaa1c936611f", size = 81523, upload-time = "2026-03-04T22:08:25.187Z" },
{ url = "https://files.pythonhosted.org/packages/22/59/f8f2e950eb4f1a5a3883e198dca514b9d475415cb6cd7b78b9213a0dd45a/caio-0.9.25-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:9ed3cfb28c0e99fec5e208c934e5c157d0866aa9c32aa4dc5e9b6034af6286b7", size = 80243, upload-time = "2026-03-04T22:08:26.449Z" },
{ url = "https://files.pythonhosted.org/packages/69/ca/a08fdc7efdcc24e6a6131a93c85be1f204d41c58f474c42b0670af8c016b/caio-0.9.25-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fab6078b9348e883c80a5e14b382e6ad6aabbc4429ca034e76e730cf464269db", size = 36978, upload-time = "2025-12-26T15:21:41.055Z" },
{ url = "https://files.pythonhosted.org/packages/5e/6c/d4d24f65e690213c097174d26eda6831f45f4734d9d036d81790a27e7b78/caio-0.9.25-cp314-cp314-manylinux2010_x86_64.manylinux2014_x86_64.manylinux_2_12_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:44a6b58e52d488c75cfaa5ecaa404b2b41cc965e6c417e03251e868ecd5b6d77", size = 81832, upload-time = "2025-12-26T15:22:22.757Z" },
{ url = "https://files.pythonhosted.org/packages/87/a4/e534cf7d2d0e8d880e25dd61e8d921ffcfe15bd696734589826f5a2df727/caio-0.9.25-cp314-cp314-manylinux_2_34_aarch64.whl", hash = "sha256:628a630eb7fb22381dd8e3c8ab7f59e854b9c806639811fc3f4310c6bd711d79", size = 81565, upload-time = "2026-03-04T22:08:27.483Z" },
{ url = "https://files.pythonhosted.org/packages/3f/ed/bf81aeac1d290017e5e5ac3e880fd56ee15e50a6d0353986799d1bc5cfd5/caio-0.9.25-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:0ba16aa605ccb174665357fc729cf500679c2d94d5f1458a6f0d5ca48f2060a7", size = 80071, upload-time = "2026-03-04T22:08:28.751Z" },
{ url = "https://files.pythonhosted.org/packages/86/93/1f76c8d1bafe3b0614e06b2195784a3765bbf7b0a067661af9e2dd47fc33/caio-0.9.25-py3-none-any.whl", hash = "sha256:06c0bb02d6b929119b1cfbe1ca403c768b2013a369e2db46bfa2a5761cf82e40", size = 19087, upload-time = "2025-12-26T15:22:00.221Z" },
]
@@ -2123,16 +2131,16 @@ wheels = [
[[package]]
name = "google-auth"
version = "2.43.0"
version = "2.48.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cachetools" },
{ name = "cryptography" },
{ name = "pyasn1-modules" },
{ name = "rsa" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ff/ef/66d14cf0e01b08d2d51ffc3c20410c4e134a1548fc246a6081eae585a4fe/google_auth-2.43.0.tar.gz", hash = "sha256:88228eee5fc21b62a1b5fe773ca15e67778cb07dc8363adcb4a8827b52d81483", size = 296359, upload-time = "2025-11-06T00:13:36.587Z" }
sdist = { url = "https://files.pythonhosted.org/packages/0c/41/242044323fbd746615884b1c16639749e73665b718209946ebad7ba8a813/google_auth-2.48.0.tar.gz", hash = "sha256:4f7e706b0cd3208a3d940a19a822c37a476ddba5450156c3e6624a71f7c841ce", size = 326522, upload-time = "2026-01-26T19:22:47.157Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6f/d1/385110a9ae86d91cc14c5282c61fe9f4dc41c0b9f7d423c6ad77038c4448/google_auth-2.43.0-py2.py3-none-any.whl", hash = "sha256:af628ba6fa493f75c7e9dbe9373d148ca9f4399b5ea29976519e0a3848eddd16", size = 223114, upload-time = "2025-11-06T00:13:35.209Z" },
{ url = "https://files.pythonhosted.org/packages/83/1d/d6466de3a5249d35e832a52834115ca9d1d0de6abc22065f049707516d47/google_auth-2.48.0-py3-none-any.whl", hash = "sha256:2e2a537873d449434252a9632c28bfc268b0adb1e53f9fb62afc5333a975903f", size = 236499, upload-time = "2026-01-26T19:22:45.099Z" },
]
[[package]]
@@ -2164,7 +2172,7 @@ wheels = [
[[package]]
name = "google-cloud-aiplatform"
version = "1.121.0"
version = "1.133.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "docstring-parser" },
@@ -2178,12 +2186,11 @@ dependencies = [
{ name = "proto-plus" },
{ name = "protobuf" },
{ name = "pydantic" },
{ name = "shapely" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/86/d1bad9a342122f0f5913cd8b7758ab340aac3f579cffb800d294da605a7c/google_cloud_aiplatform-1.121.0.tar.gz", hash = "sha256:65710396238fa461dbea9b2af9ed23f95458d70d9684e75519c7c9c1601ff308", size = 9705200, upload-time = "2025-10-15T20:27:59.262Z" }
sdist = { url = "https://files.pythonhosted.org/packages/d4/be/31ce7fd658ddebafbe5583977ddee536b2bacc491ad10b5a067388aec66f/google_cloud_aiplatform-1.133.0.tar.gz", hash = "sha256:3a6540711956dd178daaab3c2c05db476e46d94ac25912b8cf4f59b00b058ae0", size = 9921309, upload-time = "2026-01-08T22:11:25.079Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bd/f6/806b39f86f912133a3071ffa9ff99801a12868216069e26c83a48943116b/google_cloud_aiplatform-1.121.0-py2.py3-none-any.whl", hash = "sha256:1e7105dfd17963207e966550c9544264508efdfded29cf4924c5b86ff4a22efd", size = 8067568, upload-time = "2025-10-15T20:27:54.842Z" },
{ url = "https://files.pythonhosted.org/packages/01/5b/ef74ff65aebb74eaba51078e33ddd897247ba0d1197fd5a7953126205519/google_cloud_aiplatform-1.133.0-py2.py3-none-any.whl", hash = "sha256:dfc81228e987ca10d1c32c7204e2131b3c8d6b7c8e0b4e23bf7c56816bc4c566", size = 8184595, upload-time = "2026-01-08T22:11:22.067Z" },
]
[[package]]
@@ -4622,7 +4629,7 @@ requires-dist = [
{ name = "google-api-python-client", marker = "extra == 'backend'", specifier = "==2.86.0" },
{ name = "google-auth-httplib2", marker = "extra == 'backend'", specifier = "==0.1.0" },
{ name = "google-auth-oauthlib", marker = "extra == 'backend'", specifier = "==1.0.0" },
{ name = "google-cloud-aiplatform", specifier = "==1.121.0" },
{ name = "google-cloud-aiplatform", specifier = "==1.133.0" },
{ name = "google-genai", specifier = "==1.52.0" },
{ name = "hatchling", marker = "extra == 'dev'", specifier = "==1.28.0" },
{ name = "httpcore", marker = "extra == 'backend'", specifier = "==1.0.9" },
@@ -4655,7 +4662,7 @@ requires-dist = [
{ name = "numpy", marker = "extra == 'model-server'", specifier = "==2.4.1" },
{ name = "oauthlib", marker = "extra == 'backend'", specifier = "==3.2.2" },
{ name = "office365-rest-python-client", marker = "extra == 'backend'", specifier = "==2.6.2" },
{ name = "onyx-devtools", marker = "extra == 'dev'", specifier = "==0.6.2" },
{ name = "onyx-devtools", marker = "extra == 'dev'", specifier = "==0.6.3" },
{ name = "openai", specifier = "==2.14.0" },
{ name = "openapi-generator-cli", marker = "extra == 'dev'", specifier = "==7.17.0" },
{ name = "openinference-instrumentation", marker = "extra == 'backend'", specifier = "==0.1.42" },
@@ -4760,20 +4767,20 @@ requires-dist = [{ name = "onyx", extras = ["backend", "dev", "ee"], editable =
[[package]]
name = "onyx-devtools"
version = "0.6.2"
version = "0.6.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "fastapi" },
{ name = "openapi-generator-cli" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/cc/20/d9f6089616044b0fb6e097cbae82122de24f3acd97820be4868d5c28ee3f/onyx_devtools-0.6.2-py3-none-any.whl", hash = "sha256:e48d14695d39d62ec3247a4c76ea56604bc5fb635af84c4ff3e9628bcc67b4fb", size = 3785941, upload-time = "2026-02-25T22:33:43.585Z" },
{ url = "https://files.pythonhosted.org/packages/d6/f5/f754a717f6b011050eb52ef09895cfa2f048f567f4aa3d5e0f773657dea4/onyx_devtools-0.6.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:505f9910a04868ab62d99bb483dc37c9f4ad94fa80e6ac0e6a10b86351c31420", size = 3832182, upload-time = "2026-02-25T22:33:43.283Z" },
{ url = "https://files.pythonhosted.org/packages/6a/35/6e653398c62078e87ebb0d03dc944df6691d92ca427c92867309d2d803b7/onyx_devtools-0.6.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:edec98e3acc0fa22cf9102c2070409ea7bcf99d7ded72bd8cb184ece8171c36a", size = 3576948, upload-time = "2026-02-25T22:33:42.962Z" },
{ url = "https://files.pythonhosted.org/packages/3c/97/cff707c5c3d2acd714365b1023f0100676abc99816a29558319e8ef01d5f/onyx_devtools-0.6.2-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:97abab61216866cdccd8c0a7e27af328776083756ce4fb57c4bd723030449e3b", size = 3439359, upload-time = "2026-02-25T22:33:44.684Z" },
{ url = "https://files.pythonhosted.org/packages/fc/98/3b768d18e5599178834b966b447075626d224e048d6eb264d89d19abacb4/onyx_devtools-0.6.2-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:681b038ab6f1457409d14b2490782c7a8014fc0f0f1b9cd69bb2b7199f99aef1", size = 3785959, upload-time = "2026-02-25T22:33:44.342Z" },
{ url = "https://files.pythonhosted.org/packages/d6/38/9b047f9e61c14ccf22b8f386c7a57da3965f90737453f3a577a97da45cdf/onyx_devtools-0.6.2-py3-none-win_amd64.whl", hash = "sha256:a2063be6be104b50a7538cf0d26c7f7ab9159d53327dd6f3e91db05d793c95f3", size = 3878776, upload-time = "2026-02-25T22:33:45.229Z" },
{ url = "https://files.pythonhosted.org/packages/9d/0f/742f644bae84f5f8f7b500094a2f58da3ff8027fc739944622577e2e2850/onyx_devtools-0.6.2-py3-none-win_arm64.whl", hash = "sha256:00fb90a49a15c932b5cacf818b1b4918e5b5c574bde243dc1828b57690dd5046", size = 3501112, upload-time = "2026-02-25T22:33:41.512Z" },
{ url = "https://files.pythonhosted.org/packages/84/e2/e7619722c3ccd18eb38100f776fb3dd6b4ae0fbbee09fca5af7c69a279b5/onyx_devtools-0.6.3-py3-none-any.whl", hash = "sha256:d3a5422945d9da12cafc185f64b39f6e727ee4cc92b37427deb7a38f9aad4966", size = 3945381, upload-time = "2026-03-05T20:39:25.896Z" },
{ url = "https://files.pythonhosted.org/packages/f2/09/513d2dabedc1e54ad4376830fc9b34a3d9c164bdbcdedfcdbb8b8154dc5a/onyx_devtools-0.6.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:efe300e9f3a2e7ae75f88a4f9e0a5c4c471478296cb1615b6a1f03d247582e13", size = 3978761, upload-time = "2026-03-05T20:39:28.822Z" },
{ url = "https://files.pythonhosted.org/packages/39/41/e757602a0de032d74ed01c7ee57f30e57728fb9cd4f922f50d2affda3889/onyx_devtools-0.6.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:594066eed3f917cfab5a8c7eac3d4a210df30259f2049f664787749709345e19", size = 3665378, upload-time = "2026-03-05T20:44:22.696Z" },
{ url = "https://files.pythonhosted.org/packages/33/1c/c93b65d0b32e202596a2647922a75c7011cb982f899ddfcfd171f792c58f/onyx_devtools-0.6.3-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:384ef66030b55c0fd68b3898782b5b4b868ff3de119569dfc8544e2ce534b98a", size = 3540890, upload-time = "2026-03-05T20:39:28.886Z" },
{ url = "https://files.pythonhosted.org/packages/f4/33/760eb656013f7f0cdff24570480d3dc4e52bbd8e6147ea1e8cf6fad7554f/onyx_devtools-0.6.3-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:82e218f3a49f64910c2c4c34d5dc12d1ea1520a27e0b0f6e4c0949ff9abaf0e1", size = 3945396, upload-time = "2026-03-05T20:39:34.323Z" },
{ url = "https://files.pythonhosted.org/packages/1a/eb/f54b3675c464df8a51194ff75afc97c2417659e3a209dc46948b47c28860/onyx_devtools-0.6.3-py3-none-win_amd64.whl", hash = "sha256:8af614ae7229290ef2417cb85270184a1e826ed9a3a34658da93851edb36df57", size = 4045936, upload-time = "2026-03-05T20:39:28.375Z" },
{ url = "https://files.pythonhosted.org/packages/04/b8/5bee38e748f3d4b8ec935766224db1bbc1214c91092e5822c080fccd9130/onyx_devtools-0.6.3-py3-none-win_arm64.whl", hash = "sha256:717589db4b42528d33ae96f8006ee6aad3555034dcfee724705b6576be6a6ec4", size = 3608268, upload-time = "2026-03-05T20:39:28.731Z" },
]
[[package]]

View File

@@ -1,190 +0,0 @@
"use client";
import { useState } from "react";
import Text from "@/refresh-components/texts/Text";
import TableQualifier from "@/refresh-components/table/TableQualifier";
import { TableSizeProvider } from "@/refresh-components/table/TableSizeContext";
import type { TableSize } from "@/refresh-components/table/TableSizeContext";
import type { QualifierContentType } from "@/refresh-components/table/types";
import { SvgCheckCircle } from "@opal/icons";
// ---------------------------------------------------------------------------
// Content type configurations
// ---------------------------------------------------------------------------
interface ContentConfig {
label: string;
content: QualifierContentType;
extraProps: Record<string, unknown>;
}
const CONTENT_TYPES: ContentConfig[] = [
{
label: "Simple",
content: "simple",
extraProps: {},
},
{
label: "Icon",
content: "icon",
extraProps: { icon: SvgCheckCircle },
},
{
label: "Image",
content: "image",
extraProps: {
imageSrc: "https://picsum.photos/36",
imageAlt: "Placeholder",
},
},
{
label: "Avatar Icon",
content: "avatar-icon",
extraProps: {},
},
{
label: "Avatar User",
content: "avatar-user",
extraProps: { initials: "AJ" },
},
];
// ---------------------------------------------------------------------------
// Row of qualifier states for a single content type
// ---------------------------------------------------------------------------
interface QualifierRowProps {
config: ContentConfig;
}
function QualifierRow({ config }: QualifierRowProps) {
const [selectableSelected, setSelectableSelected] = useState(false);
const [permanentSelected, setPermanentSelected] = useState(true);
return (
<div className="space-y-2">
<Text mainUiAction text02>
{config.label}
</Text>
<div className="flex items-start gap-8">
{/* Default */}
<div className="flex w-20 flex-col items-center gap-2">
<TableQualifier
content={config.content}
selectable={false}
selected={false}
disabled={false}
{...config.extraProps}
/>
<Text secondaryBody text04>
Default
</Text>
</div>
{/* Selectable (hover to reveal checkbox) */}
<div className="flex w-20 flex-col items-center gap-2">
<TableQualifier
content={config.content}
selectable={true}
selected={selectableSelected}
disabled={false}
onSelectChange={setSelectableSelected}
{...config.extraProps}
/>
<Text secondaryBody text04>
Selectable
</Text>
</div>
{/* Selected */}
<div className="flex w-20 flex-col items-center gap-2">
<TableQualifier
content={config.content}
selectable={true}
selected={permanentSelected}
disabled={false}
onSelectChange={setPermanentSelected}
{...config.extraProps}
/>
<Text secondaryBody text04>
Selected
</Text>
</div>
{/* Disabled (unselected) */}
<div className="flex w-20 flex-col items-center gap-2">
<TableQualifier
content={config.content}
selectable={true}
selected={false}
disabled={true}
{...config.extraProps}
/>
<Text secondaryBody text04>
Disabled
</Text>
</div>
{/* Disabled (selected) */}
<div className="flex w-20 flex-col items-center gap-2">
<TableQualifier
content={config.content}
selectable={true}
selected={true}
disabled={true}
{...config.extraProps}
/>
<Text secondaryBody text04>
Disabled+Sel
</Text>
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Size section — all content types at a given size
// ---------------------------------------------------------------------------
interface SizeSectionProps {
size: TableSize;
title: string;
}
function SizeSection({ size, title }: SizeSectionProps) {
return (
<div className="space-y-6">
<Text headingH3>{title}</Text>
<TableSizeProvider size={size}>
<div className="flex flex-col gap-8">
{CONTENT_TYPES.map((config) => (
<QualifierRow key={`${size}-${config.content}`} config={config} />
))}
</div>
</TableSizeProvider>
</div>
);
}
// ---------------------------------------------------------------------------
// Page
// ---------------------------------------------------------------------------
export default function TableQualifierDemoPage() {
return (
<div className="p-6 space-y-10">
<div className="space-y-4">
<Text headingH2>TableQualifier Demo</Text>
<Text mainContentMuted text03>
All content types, sizes, and interactive states. Hover selectable
variants to reveal the checkbox; click to toggle.
</Text>
</div>
<SizeSection size="regular" title="Regular (36px)" />
<SizeSection size="small" title="Small (28px)" />
</div>
);
}

View File

@@ -1,11 +1,12 @@
"use client";
import React, { useEffect, useRef, useState } from "react";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { FileDescriptor } from "@/app/app/interfaces";
import "katex/dist/katex.min.css";
import MessageSwitcher from "@/app/app/message/MessageSwitcher";
import Text from "@/refresh-components/texts/Text";
import { cn } from "@/lib/utils";
import useScreenSize from "@/hooks/useScreenSize";
import CopyIconButton from "@/refresh-components/buttons/CopyIconButton";
import { Button } from "@opal/components";
import { SvgEdit } from "@opal/icons";
@@ -137,6 +138,7 @@ const HumanMessage = React.memo(function HumanMessage({
const [content, setContent] = useState(initialContent);
const [isEditing, setIsEditing] = useState(false);
const { isMobile } = useScreenSize();
// Use nodeId for switching (finding position in siblings)
const indexInSiblings = otherMessagesCanSwitchTo?.indexOf(nodeId);
@@ -168,100 +170,104 @@ const HumanMessage = React.memo(function HumanMessage({
return undefined;
};
const copyEditButton = useMemo(
() => (
<div className="flex flex-row flex-shrink px-1 opacity-0 group-hover:opacity-100 transition-opacity">
<CopyIconButton
getCopyText={() => content}
prominence="tertiary"
data-testid="HumanMessage/copy-button"
/>
<Button
icon={SvgEdit}
prominence="tertiary"
tooltip="Edit"
onClick={() => setIsEditing(true)}
data-testid="HumanMessage/edit-button"
/>
</div>
),
[content]
);
return (
<div
id="onyx-human-message"
className="group flex flex-col justify-end w-full relative"
>
<FileDisplay alignBubble files={files || []} />
<div className="md:flex md:flex-wrap relative justify-end break-words">
{isEditing ? (
<MessageEditing
content={content}
onSubmitEdit={(editedContent) => {
// Don't update UI for edits that can't be persisted
if (messageId === undefined || messageId === null) {
setIsEditing(false);
return;
}
onEdit?.(editedContent, messageId);
setContent(editedContent);
{isEditing ? (
<MessageEditing
content={content}
onSubmitEdit={(editedContent) => {
// Don't update UI for edits that can't be persisted
if (messageId === undefined || messageId === null) {
setIsEditing(false);
}}
onCancelEdit={() => setIsEditing(false)}
/>
) : (
<>
<div className="md:max-w-[37.5rem] flex basis-[100%] md:basis-auto justify-end md:order-1">
<div
className={
"max-w-[30rem] md:max-w-[37.5rem] whitespace-break-spaces break-anywhere rounded-t-16 rounded-bl-16 bg-background-tint-02 py-2 px-3"
return;
}
onEdit?.(editedContent, messageId);
setContent(editedContent);
setIsEditing(false);
}}
onCancelEdit={() => setIsEditing(false)}
/>
) : (
<div className="flex justify-end">
{onEdit && !isMobile && copyEditButton}
<div className="md:max-w-[37.5rem]">
<div
className={
"max-w-[30rem] md:max-w-[37.5rem] whitespace-break-spaces break-anywhere rounded-t-16 rounded-bl-16 bg-background-tint-02 py-2 px-3"
}
onCopy={(e) => {
const selection = window.getSelection();
if (selection) {
e.preventDefault();
const text = selection
.toString()
.replace(/\n{2,}/g, "\n")
.trim();
e.clipboardData.setData("text/plain", text);
}
onCopy={(e) => {
const selection = window.getSelection();
if (selection) {
e.preventDefault();
const text = selection
.toString()
.replace(/\n{2,}/g, "\n")
.trim();
e.clipboardData.setData("text/plain", text);
}
}}
}}
>
<Text
as="p"
className="inline-block align-middle"
mainContentBody
>
<Text
as="p"
className="inline-block align-middle"
mainContentBody
>
{content}
</Text>
</div>
{content}
</Text>
</div>
{onEdit && (
<div className="absolute md:relative right-0 z-content flex flex-row p-1 opacity-0 group-hover:opacity-100 transition-opacity">
<CopyIconButton
getCopyText={() => content}
prominence="tertiary"
data-testid="HumanMessage/copy-button"
/>
<Button
icon={SvgEdit}
prominence="tertiary"
tooltip="Edit"
onClick={() => setIsEditing(true)}
data-testid="HumanMessage/edit-button"
/>
</div>
)}
</>
)}
<div className="md:min-w-[100%] flex justify-end order-1 mt-1">
{currentMessageInd !== undefined &&
onMessageSelection &&
otherMessagesCanSwitchTo &&
otherMessagesCanSwitchTo.length > 1 && (
<MessageSwitcher
disableForStreaming={disableSwitchingForStreaming}
currentPage={currentMessageInd + 1}
totalPages={otherMessagesCanSwitchTo.length}
handlePrevious={() => {
stopGenerating();
const prevMessage = getPreviousMessage();
if (prevMessage !== undefined) {
onMessageSelection(prevMessage);
}
}}
handleNext={() => {
stopGenerating();
const nextMessage = getNextMessage();
if (nextMessage !== undefined) {
onMessageSelection(nextMessage);
}
}}
/>
)}
</div>
</div>
)}
<div className="flex justify-end pt-1">
{!isEditing && onEdit && isMobile && copyEditButton}
{currentMessageInd !== undefined &&
onMessageSelection &&
otherMessagesCanSwitchTo &&
otherMessagesCanSwitchTo.length > 1 && (
<MessageSwitcher
disableForStreaming={disableSwitchingForStreaming}
currentPage={currentMessageInd + 1}
totalPages={otherMessagesCanSwitchTo.length}
handlePrevious={() => {
stopGenerating();
const prevMessage = getPreviousMessage();
if (prevMessage !== undefined) {
onMessageSelection(prevMessage);
}
}}
handleNext={() => {
stopGenerating();
const nextMessage = getNextMessage();
if (nextMessage !== undefined) {
onMessageSelection(nextMessage);
}
}}
/>
)}
</div>
</div>
);

View File

@@ -7,7 +7,7 @@ import SidebarTab from "@/refresh-components/buttons/SidebarTab";
import { SvgSliders } from "@opal/icons";
import { useUser } from "@/providers/UserProvider";
import { useAuthType } from "@/lib/hooks";
import { AuthType } from "@/lib/constants";
import { Section } from "@/layouts/general-layouts";
interface LayoutProps {
children: React.ReactNode;
@@ -28,9 +28,12 @@ export default function Layout({ children }: LayoutProps) {
<SettingsLayouts.Header icon={SvgSliders} title="Settings" separator />
<SettingsLayouts.Body>
<div className="grid grid-cols-[auto_1fr]">
<Section flexDirection="row" alignItems="start" gap={1.5}>
{/* Left: Tab Navigation */}
<div className="flex flex-col px-2 w-[12.5rem]">
<div
data-testid="settings-left-tab-navigation"
className="flex flex-col px-2 min-w-[12.5rem]"
>
<SidebarTab
href="/app/settings/general"
selected={pathname === "/app/settings/general"}
@@ -60,8 +63,8 @@ export default function Layout({ children }: LayoutProps) {
</div>
{/* Right: Tab Content */}
<div className="px-4">{children}</div>
</div>
{children}
</Section>
</SettingsLayouts.Body>
</SettingsLayouts.Root>
</AppLayouts.Root>

View File

@@ -0,0 +1,18 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { toast } from "@/hooks/useToast";
export default function EEFeatureRedirect() {
const router = useRouter();
useEffect(() => {
toast.error(
"This feature requires a license. Please upgrade your plan to access."
);
router.replace("/app");
}, [router]);
return null;
}

View File

@@ -1,5 +1,6 @@
import { SERVER_SIDE_ONLY__PAID_ENTERPRISE_FEATURES_ENABLED } from "@/lib/constants";
import { fetchStandardSettingsSS } from "@/components/settings/lib";
import EEFeatureRedirect from "@/app/ee/EEFeatureRedirect";
export default async function AdminLayout({
children,
@@ -8,13 +9,7 @@ export default async function AdminLayout({
}) {
// First check build-time constant (fast path)
if (!SERVER_SIDE_ONLY__PAID_ENTERPRISE_FEATURES_ENABLED) {
return (
<div className="flex h-screen">
<div className="mx-auto my-auto text-lg font-bold text-red-500">
This functionality is only available in the Enterprise Edition :(
</div>
</div>
);
return <EEFeatureRedirect />;
}
// Then check runtime license status (for license enforcement mode)
@@ -31,13 +26,7 @@ export default async function AdminLayout({
return children;
}
return (
<div className="flex h-screen">
<div className="mx-auto my-auto text-lg font-bold text-red-500">
This functionality requires an active Enterprise license.
</div>
</div>
);
return <EEFeatureRedirect />;
}
}
} catch (error) {

View File

@@ -68,7 +68,9 @@ export default function CreateProjectModal({
<Button prominence="secondary" onClick={() => modal.toggle(false)}>
Cancel
</Button>
<Button onClick={handleSubmit}>Create Project</Button>
<Button disabled={!projectName.trim()} onClick={handleSubmit}>
Create Project
</Button>
</Modal.Footer>
</Modal.Content>
</Modal>

View File

@@ -11,21 +11,8 @@ import {
* Hook to fetch billing information from Stripe.
*
* Works for both cloud and self-hosted deployments:
* - Cloud: fetches from /api/tenants/billing-information (legacy endpoint)
* - Cloud: fetches from /api/tenants/billing-information
* - Self-hosted: fetches from /api/admin/billing/billing-information
*
* Returns subscription status, seats, billing period, etc.
*
* @example
* ```tsx
* const { data, isLoading, error, refresh } = useBillingInformation();
*
* if (isLoading) return <Loading />;
* if (error) return <Error />;
* if (!data || !hasActiveSubscription(data)) return <NoSubscription />;
*
* return <BillingDetails billing={data} />;
* ```
*/
export function useBillingInformation() {
const url = NEXT_PUBLIC_CLOUD_ENABLED
@@ -38,16 +25,9 @@ export function useBillingInformation() {
revalidateOnFocus: false,
revalidateOnReconnect: false,
dedupingInterval: 30000,
// Don't auto-retry on errors (circuit breaker will block requests anyway)
shouldRetryOnError: false,
// Keep previous data while revalidating to prevent UI flashing
keepPreviousData: true,
});
return {
data,
isLoading,
error,
refresh: mutate,
};
return { data, isLoading, error, refresh: mutate };
}

View File

@@ -0,0 +1,64 @@
"use client";
import { useEffect, useState } from "react";
export interface BrowserInfo {
isSafari: boolean;
isFirefox: boolean;
isChrome: boolean;
isChromium: boolean;
isEdge: boolean;
isOpera: boolean;
isIOS: boolean;
isMac: boolean;
isWindows: boolean;
}
const DEFAULT_BROWSER_INFO: BrowserInfo = {
isSafari: false,
isFirefox: false,
isChrome: false,
isChromium: false,
isEdge: false,
isOpera: false,
isIOS: false,
isMac: false,
isWindows: false,
};
export default function useBrowserInfo(): BrowserInfo {
const [browserInfo, setBrowserInfo] =
useState<BrowserInfo>(DEFAULT_BROWSER_INFO);
useEffect(() => {
const userAgent = window.navigator.userAgent;
const isEdge = /Edg/i.test(userAgent);
const isOpera = /OPR|Opera/i.test(userAgent);
const isFirefox = /Firefox|FxiOS/i.test(userAgent);
const isChrome = /Chrome|CriOS/i.test(userAgent) && !isEdge && !isOpera;
const isChromium = /Chromium/i.test(userAgent) || isChrome;
const isSafari =
/Safari/i.test(userAgent) &&
!isChromium &&
!isEdge &&
!isOpera &&
!isFirefox;
const isIOS = /iPhone|iPad|iPod/i.test(userAgent);
const isMac = /Macintosh|Mac OS X/i.test(userAgent);
const isWindows = /Win/i.test(userAgent);
setBrowserInfo({
isSafari,
isFirefox,
isChrome,
isChromium,
isEdge,
isOpera,
isIOS,
isMac,
isWindows,
});
}, []);
return browserInfo;
}

View File

@@ -7,23 +7,9 @@ import { LicenseStatus } from "@/lib/billing/interfaces";
/**
* Hook to fetch license status for self-hosted deployments.
*
* Returns license information including seats, expiry, and status.
* Only fetches for self-hosted deployments (cloud uses tenant auth instead).
*
* @example
* ```tsx
* const { data, isLoading, error, refresh } = useLicense();
*
* if (isLoading) return <Loading />;
* if (error) return <Error />;
* if (!data?.has_license) return <NoLicense />;
*
* return <LicenseDetails license={data} />;
* ```
* Skips the fetch on cloud deployments (uses tenant auth instead).
*/
export function useLicense() {
// Only fetch license for self-hosted deployments
// Cloud deployments use tenant-based auth, not license files
const url = NEXT_PUBLIC_CLOUD_ENABLED ? null : "/api/license";
const { data, error, mutate, isLoading } = useSWR<LicenseStatus>(
@@ -38,20 +24,14 @@ export function useLicense() {
}
);
// Return empty state for cloud deployments
if (NEXT_PUBLIC_CLOUD_ENABLED) {
if (!url) {
return {
data: null,
data: undefined,
isLoading: false,
error: undefined,
refresh: () => Promise.resolve(undefined),
};
}
return {
data,
isLoading,
error,
refresh: mutate,
};
return { data, isLoading, error, refresh: mutate };
}

View File

@@ -46,8 +46,8 @@ export interface Settings {
// Onyx Craft (Build Mode) feature flag
onyx_craft_enabled?: boolean;
// Enterprise features flag - controlled by license enforcement at runtime
// True when user has a valid license, False for community edition
// Whether EE features are unlocked (user has a valid enterprise license).
// Controls UI visibility of EE features like user groups, analytics, RBAC.
ee_features_enabled?: boolean;
// Seat usage - populated when seat limit is exceeded

View File

@@ -64,6 +64,7 @@ import { AppMode, useAppMode } from "@/providers/AppModeProvider";
import useAppFocus from "@/hooks/useAppFocus";
import { useQueryController } from "@/providers/QueryControllerProvider";
import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled";
import useBrowserInfo from "@/hooks/useBrowserInfo";
/**
* App Header Component
@@ -527,8 +528,16 @@ function Root({ children, enableBackground }: AppRootProps) {
const { hasBackground, appBackgroundUrl } = useAppBackground();
const { resolvedTheme } = useTheme();
const appFocus = useAppFocus();
const { isSafari } = useBrowserInfo();
const isLightMode = resolvedTheme === "light";
const showBackground = hasBackground && enableBackground;
const horizontalBlurMask = `linear-gradient(
to right,
transparent 0%,
black max(0%, calc(50% - 25rem)),
black min(100%, calc(50% + 25rem)),
transparent 100%
)`;
return (
/* NOTE: Some elements, markdown tables in particular, refer to this `@container` in order to
@@ -568,25 +577,25 @@ function Root({ children, enableBackground }: AppRootProps) {
{showBackground && appFocus.isChat() && (
<>
<div className="absolute inset-0 backdrop-blur-[1px] pointer-events-none" />
<div
className="absolute z-0 inset-0 backdrop-blur-md transition-all duration-600 pointer-events-none"
style={{
maskImage: `linear-gradient(
to right,
transparent 0%,
black max(0%, calc(50% - 25rem)),
black min(100%, calc(50% + 25rem)),
transparent 100%
)`,
WebkitMaskImage: `linear-gradient(
to right,
transparent 0%,
black max(0%, calc(50% - 25rem)),
black min(100%, calc(50% + 25rem)),
transparent 100%
)`,
}}
/>
{isSafari ? (
<div
className="absolute z-0 inset-0 bg-cover bg-center bg-fixed pointer-events-none"
style={{
backgroundImage: `url(${appBackgroundUrl})`,
filter: "blur(16px)",
maskImage: horizontalBlurMask,
WebkitMaskImage: horizontalBlurMask,
}}
/>
) : (
<div
className="absolute z-0 inset-0 backdrop-blur-md transition-all duration-600 pointer-events-none"
style={{
maskImage: horizontalBlurMask,
WebkitMaskImage: horizontalBlurMask,
}}
/>
)}
</>
)}

View File

@@ -35,7 +35,7 @@ export const widthClassmap: Record<Length, string> = {
export const heightClassmap: Record<Length, string> = {
auto: "h-auto",
fit: "h-fit",
full: "h-full",
full: "h-full min-h-0",
};
/**

View File

@@ -42,8 +42,13 @@ export const NEXT_PUBLIC_CUSTOM_REFRESH_URL =
// NOTE: this should ONLY be used on the server-side. If used client side,
// it will not be accurate (will always be false).
// Mirrors backend logic: EE is enabled if EITHER the legacy flag OR license
// enforcement is active. LICENSE_ENFORCEMENT_ENABLED defaults to true on the
// backend, so we treat undefined as enabled here to match.
export const SERVER_SIDE_ONLY__PAID_ENTERPRISE_FEATURES_ENABLED =
process.env.ENABLE_PAID_ENTERPRISE_EDITION_FEATURES?.toLowerCase() === "true";
process.env.ENABLE_PAID_ENTERPRISE_EDITION_FEATURES?.toLowerCase() ===
"true" ||
process.env.LICENSE_ENFORCEMENT_ENABLED?.toLowerCase() !== "false";
// NOTE: since this is a `NEXT_PUBLIC_` variable, it will be set at
// build-time
// TODO: consider moving this to an API call so that the api_server

View File

@@ -51,16 +51,6 @@ function ToastContainer() {
}, ANIMATION_DURATION);
}, []);
// NOTE (@raunakab):
//
// Keep this here for debugging purposes.
// useOnMount(() => {
// toast.success("Test success toast", { duration: Infinity });
// toast.error("Test error toast", { duration: Infinity });
// toast.warning("Test warning toast", { duration: Infinity });
// toast.info("Test info toast", { duration: Infinity });
// });
if (visible.length === 0) return null;
return (

View File

@@ -515,10 +515,16 @@ const ModalBody = React.forwardRef<HTMLDivElement, ModalBodyProps>(
ref={ref}
className={cn(
twoTone && "bg-background-tint-01",
"h-full min-h-0 overflow-y-auto w-full"
"flex-auto min-h-0 overflow-y-auto w-full"
)}
>
<Section padding={1} gap={1} alignItems="start" {...props}>
<Section
height="auto"
padding={1}
gap={1}
alignItems="start"
{...props}
>
{children}
</Section>
</div>

View File

@@ -0,0 +1,455 @@
"use client";
"use no memo";
import { useMemo } from "react";
import { flexRender } from "@tanstack/react-table";
import useDataTable, {
toOnyxSortDirection,
} from "@/refresh-components/table/hooks/useDataTable";
import useColumnWidths from "@/refresh-components/table/hooks/useColumnWidths";
import useDraggableRows from "@/refresh-components/table/hooks/useDraggableRows";
import Table from "@/refresh-components/table/Table";
import TableHeader from "@/refresh-components/table/TableHeader";
import TableBody from "@/refresh-components/table/TableBody";
import TableRow from "@/refresh-components/table/TableRow";
import TableHead from "@/refresh-components/table/TableHead";
import TableCell from "@/refresh-components/table/TableCell";
import TableQualifier from "@/refresh-components/table/TableQualifier";
import QualifierContainer from "@/refresh-components/table/QualifierContainer";
import ActionsContainer from "@/refresh-components/table/ActionsContainer";
import DragOverlayRow from "@/refresh-components/table/DragOverlayRow";
import Footer from "@/refresh-components/table/Footer";
import { TableSizeProvider } from "@/refresh-components/table/TableSizeContext";
import { ColumnVisibilityPopover } from "@/refresh-components/table/ColumnVisibilityPopover";
import { SortingPopover } from "@/refresh-components/table/SortingPopover";
import type { WidthConfig } from "@/refresh-components/table/hooks/useColumnWidths";
import type { ColumnDef } from "@tanstack/react-table";
import type {
DataTableProps,
DataTableFooterConfig,
OnyxColumnDef,
OnyxDataColumn,
OnyxQualifierColumn,
OnyxActionsColumn,
} from "@/refresh-components/table/types";
import type { TableSize } from "@/refresh-components/table/TableSizeContext";
const noopGetRowId = () => "";
// ---------------------------------------------------------------------------
// Internal: resolve size-dependent widths and build TanStack columns
// ---------------------------------------------------------------------------
interface ProcessedColumns<TData> {
tanstackColumns: ColumnDef<TData, any>[];
widthConfig: WidthConfig;
qualifierColumn: OnyxQualifierColumn<TData> | null;
/** Map from column ID → OnyxColumnDef for dispatch in render loops. */
columnKindMap: Map<string, OnyxColumnDef<TData>>;
}
function processColumns<TData>(
columns: OnyxColumnDef<TData>[],
size: TableSize
): ProcessedColumns<TData> {
const tanstackColumns: ColumnDef<TData, any>[] = [];
const fixedColumnIds = new Set<string>();
const columnWeights: Record<string, number> = {};
const columnMinWidths: Record<string, number> = {};
const columnKindMap = new Map<string, OnyxColumnDef<TData>>();
let qualifierColumn: OnyxQualifierColumn<TData> | null = null;
for (const col of columns) {
const resolvedWidth =
typeof col.width === "function" ? col.width(size) : col.width;
// Clone def to avoid mutating the caller's column definitions
const clonedDef: ColumnDef<TData, any> = {
...col.def,
id: col.id,
size:
"fixed" in resolvedWidth ? resolvedWidth.fixed : resolvedWidth.weight,
};
tanstackColumns.push(clonedDef);
const id = col.id;
columnKindMap.set(id, col);
if ("fixed" in resolvedWidth) {
fixedColumnIds.add(id);
} else {
columnWeights[id] = resolvedWidth.weight;
columnMinWidths[id] = resolvedWidth.minWidth ?? 50;
}
if (col.kind === "qualifier") qualifierColumn = col;
}
return {
tanstackColumns,
widthConfig: { fixedColumnIds, columnWeights, columnMinWidths },
qualifierColumn,
columnKindMap,
};
}
// ---------------------------------------------------------------------------
// DataTable component
// ---------------------------------------------------------------------------
/**
* Config-driven table component that wires together `useDataTable`,
* `useColumnWidths`, and `useDraggableRows` automatically.
*
* Full flexibility via the column definitions from `createTableColumns()`.
*
* @example
* ```tsx
* const tc = createTableColumns<TeamMember>();
* const columns = [
* tc.qualifier({ content: "avatar-user", getInitials: (r) => r.initials }),
* tc.column("name", { header: "Name", weight: 23, minWidth: 120 }),
* tc.column("email", { header: "Email", weight: 28 }),
* tc.actions(),
* ];
*
* <DataTable data={items} columns={columns} footer={{ mode: "selection" }} />
* ```
*/
export default function DataTable<TData>(props: DataTableProps<TData>) {
const {
data,
columns,
pageSize,
initialSorting,
initialColumnVisibility,
draggable,
footer,
size = "regular",
onRowClick,
height,
headerBackground,
} = props;
const effectivePageSize = pageSize ?? (footer ? 10 : data.length);
// 1. Process columns (memoized on columns + size)
const { tanstackColumns, widthConfig, qualifierColumn, columnKindMap } =
useMemo(() => processColumns(columns, size), [columns, size]);
// 2. Call useDataTable
const {
table,
currentPage,
totalPages,
totalItems,
setPage,
pageSize: resolvedPageSize,
selectionState,
selectedCount,
clearSelection,
toggleAllPageRowsSelected,
isAllPageRowsSelected,
} = useDataTable({
data,
columns: tanstackColumns,
pageSize: effectivePageSize,
initialSorting,
initialColumnVisibility,
});
// 3. Call useColumnWidths
const { containerRef, columnWidths, createResizeHandler } = useColumnWidths({
headers: table.getHeaderGroups()[0]?.headers ?? [],
...widthConfig,
});
// 4. Call useDraggableRows (conditional)
const draggableReturn = useDraggableRows({
data,
getRowId: draggable?.getRowId ?? noopGetRowId,
enabled: !!draggable && table.getState().sorting.length === 0,
onReorder: draggable?.onReorder,
});
const hasDraggable = !!draggable;
const rowVariant = hasDraggable ? "table" : "list";
const isSelectable =
qualifierColumn != null && qualifierColumn.selectable !== false;
// ---------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------
function renderContent() {
return (
<div>
<div
className="overflow-x-auto"
ref={containerRef}
style={{
...(height != null
? {
maxHeight:
typeof height === "number" ? `${height}px` : height,
overflowY: "auto" as const,
}
: undefined),
...(headerBackground
? ({
"--table-header-bg": headerBackground,
} as React.CSSProperties)
: undefined),
}}
>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header, headerIndex) => {
const colDef = columnKindMap.get(header.id);
// Qualifier header
if (colDef?.kind === "qualifier") {
if (qualifierColumn?.header === false) {
return (
<QualifierContainer key={header.id} type="head" />
);
}
return (
<QualifierContainer key={header.id} type="head">
<TableQualifier
content={
qualifierColumn?.headerContentType ?? "simple"
}
selectable={isSelectable}
selected={isSelectable && isAllPageRowsSelected}
onSelectChange={
isSelectable
? (checked) =>
toggleAllPageRowsSelected(checked)
: undefined
}
/>
</QualifierContainer>
);
}
// Actions header
if (colDef?.kind === "actions") {
const actionsDef = colDef as OnyxActionsColumn<TData>;
return (
<ActionsContainer key={header.id} type="head">
{actionsDef.showColumnVisibility !== false && (
<ColumnVisibilityPopover
table={table}
columnVisibility={
table.getState().columnVisibility
}
size={size}
/>
)}
{actionsDef.showSorting !== false && (
<SortingPopover
table={table}
sorting={table.getState().sorting}
size={size}
footerText={actionsDef.sortingFooterText}
/>
)}
</ActionsContainer>
);
}
// Data / Display header
const canSort = header.column.getCanSort();
const sortDir = header.column.getIsSorted();
const nextHeader = headerGroup.headers[headerIndex + 1];
const canResize =
header.column.getCanResize() &&
!!nextHeader &&
!widthConfig.fixedColumnIds.has(nextHeader.id);
const dataCol =
colDef?.kind === "data"
? (colDef as OnyxDataColumn<TData>)
: null;
return (
<TableHead
key={header.id}
width={columnWidths[header.id]}
sorted={
canSort ? toOnyxSortDirection(sortDir) : undefined
}
onSort={
canSort
? () => header.column.toggleSorting()
: undefined
}
icon={dataCol?.icon}
resizable={canResize}
onResizeStart={
canResize
? createResizeHandler(header.id, nextHeader.id)
: undefined
}
>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody
dndSortable={hasDraggable ? draggableReturn : undefined}
renderDragOverlay={
hasDraggable
? (activeId) => {
const row = table
.getRowModel()
.rows.find(
(r) => draggable!.getRowId(r.original) === activeId
);
if (!row) return null;
return <DragOverlayRow row={row} variant={rowVariant} />;
}
: undefined
}
>
{table.getRowModel().rows.map((row) => {
const rowId = hasDraggable
? draggable!.getRowId(row.original)
: undefined;
return (
<TableRow
key={row.id}
variant={rowVariant}
sortableId={rowId}
selected={row.getIsSelected()}
onClick={() => {
if (onRowClick) {
onRowClick(row.original);
} else if (isSelectable) {
row.toggleSelected();
}
}}
>
{row.getVisibleCells().map((cell) => {
const cellColDef = columnKindMap.get(cell.column.id);
// Qualifier cell
if (cellColDef?.kind === "qualifier") {
const qDef = cellColDef as OnyxQualifierColumn<TData>;
return (
<QualifierContainer
key={cell.id}
type="cell"
onClick={(e) => e.stopPropagation()}
>
<TableQualifier
content={qDef.content}
initials={qDef.getInitials?.(row.original)}
icon={qDef.getIcon?.(row.original)}
imageSrc={qDef.getImageSrc?.(row.original)}
selectable={isSelectable}
selected={isSelectable && row.getIsSelected()}
onSelectChange={
isSelectable
? (checked) => {
row.toggleSelected(checked);
}
: undefined
}
/>
</QualifierContainer>
);
}
// Actions cell
if (cellColDef?.kind === "actions") {
return (
<ActionsContainer key={cell.id} type="cell">
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</ActionsContainer>
);
}
// Data / Display cell
return (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
);
})}
</TableRow>
);
})}
</TableBody>
</Table>
</div>
{footer && renderFooter(footer)}
</div>
);
}
function renderFooter(footerConfig: DataTableFooterConfig) {
if (footerConfig.mode === "selection") {
return (
<Footer
mode="selection"
multiSelect={footerConfig.multiSelect !== false}
selectionState={selectionState}
selectedCount={selectedCount}
onClear={footerConfig.onClear ?? clearSelection}
onView={footerConfig.onView}
pageSize={resolvedPageSize}
totalItems={totalItems}
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setPage}
/>
);
}
// Summary mode
const rangeStart =
totalItems === 0
? 0
: !isFinite(resolvedPageSize)
? 1
: (currentPage - 1) * resolvedPageSize + 1;
const rangeEnd = !isFinite(resolvedPageSize)
? totalItems
: Math.min(currentPage * resolvedPageSize, totalItems);
return (
<Footer
mode="summary"
rangeStart={rangeStart}
rangeEnd={rangeEnd}
totalItems={totalItems}
currentPage={currentPage}
totalPages={totalPages}
onPageChange={setPage}
/>
);
}
return <TableSizeProvider size={size}>{renderContent()}</TableSizeProvider>;
}

View File

@@ -0,0 +1,317 @@
# DataTable
Config-driven table built on [TanStack Table](https://tanstack.com/table). Handles column sizing (weight-based proportional distribution), drag-and-drop row reordering, pagination, row selection, column visibility, and sorting out of the box.
## Quick Start
```tsx
import DataTable from "@/refresh-components/table/DataTable";
import { createTableColumns } from "@/refresh-components/table/columns";
interface Person {
name: string;
email: string;
role: string;
}
// Define columns at module scope (stable reference, no re-renders)
const tc = createTableColumns<Person>();
const columns = [
tc.qualifier(),
tc.column("name", { header: "Name", weight: 30, minWidth: 120 }),
tc.column("email", { header: "Email", weight: 40, minWidth: 150 }),
tc.column("role", { header: "Role", weight: 30, minWidth: 80 }),
tc.actions(),
];
function PeopleTable({ data }: { data: Person[] }) {
return (
<DataTable
data={data}
columns={columns}
pageSize={10}
footer={{ mode: "selection" }}
/>
);
}
```
## Column Builder API
`createTableColumns<TData>()` returns a typed builder with four methods. Each returns an `OnyxColumnDef<TData>` that you pass to the `columns` prop.
### `tc.qualifier(config?)`
Leading column for avatars, icons, images, or checkboxes.
| Option | Type | Default | Description |
|---|---|---|---|
| `content` | `"simple" \| "icon" \| "image" \| "avatar-icon" \| "avatar-user"` | `"simple"` | Body row content type |
| `headerContentType` | same as `content` | `"simple"` | Header row content type |
| `getInitials` | `(row: TData) => string` | - | Extract initials (for `"avatar-user"`) |
| `getIcon` | `(row: TData) => IconFunctionComponent` | - | Extract icon (for `"icon"` / `"avatar-icon"`) |
| `getImageSrc` | `(row: TData) => string` | - | Extract image src (for `"image"`) |
| `selectable` | `boolean` | `true` | Show selection checkboxes |
| `header` | `boolean` | `true` | Render qualifier content in the header |
Width is fixed: 56px at `"regular"` size, 40px at `"small"`.
```ts
tc.qualifier({
content: "avatar-user",
getInitials: (row) => row.initials,
})
```
### `tc.column(accessor, config)`
Data column with sorting, resizing, and hiding. The `accessor` is a type-safe deep key into `TData`.
| Option | Type | Default | Description |
|---|---|---|---|
| `header` | `string` | **required** | Column header label |
| `cell` | `(value: TValue, row: TData) => ReactNode` | renders value as string | Custom cell renderer |
| `enableSorting` | `boolean` | `true` | Allow sorting |
| `enableResizing` | `boolean` | `true` | Allow column resize |
| `enableHiding` | `boolean` | `true` | Allow hiding via actions popover |
| `icon` | `(sorted: SortDirection) => IconFunctionComponent` | - | Override the sort indicator icon |
| `weight` | `number` | `20` | Proportional width weight |
| `minWidth` | `number` | `50` | Minimum width in pixels |
```ts
tc.column("email", {
header: "Email",
weight: 28,
minWidth: 150,
cell: (value) => <Content sizePreset="main-ui" variant="body" title={value} prominence="muted" />,
})
```
### `tc.displayColumn(config)`
Non-accessor column for custom content (e.g. computed values, action buttons per row).
| Option | Type | Default | Description |
|---|---|---|---|
| `id` | `string` | **required** | Unique column ID |
| `header` | `string` | - | Optional header label |
| `cell` | `(row: TData) => ReactNode` | **required** | Cell renderer |
| `width` | `ColumnWidth` | **required** | `{ weight, minWidth? }` or `{ fixed }` |
| `enableHiding` | `boolean` | `true` | Allow hiding |
```ts
tc.displayColumn({
id: "fullName",
header: "Full Name",
cell: (row) => `${row.firstName} ${row.lastName}`,
width: { weight: 25, minWidth: 100 },
})
```
### `tc.actions(config?)`
Fixed-width column rendered at the trailing edge. Houses column visibility and sorting popovers in the header.
| Option | Type | Default | Description |
|---|---|---|---|
| `showColumnVisibility` | `boolean` | `true` | Show the column visibility popover |
| `showSorting` | `boolean` | `true` | Show the sorting popover |
| `sortingFooterText` | `string` | - | Footer text inside the sorting popover |
Width is fixed: 88px at `"regular"`, 20px at `"small"`.
```ts
tc.actions({
sortingFooterText: "Everyone will see agents in this order.",
})
```
## DataTable Props
`DataTableProps<TData>`:
| Prop | Type | Default | Description |
|---|---|---|---|
| `data` | `TData[]` | **required** | Row data |
| `columns` | `OnyxColumnDef<TData>[]` | **required** | Columns from `createTableColumns()` |
| `pageSize` | `number` | `10` (with footer) or `data.length` (without) | Rows per page. `Infinity` disables pagination |
| `initialSorting` | `SortingState` | `[]` | TanStack sorting state |
| `initialColumnVisibility` | `VisibilityState` | `{}` | Map of column ID to `false` to hide initially |
| `draggable` | `DataTableDraggableConfig<TData>` | - | Enable drag-and-drop (see below) |
| `footer` | `DataTableFooterConfig` | - | Footer mode (see below) |
| `size` | `"regular" \| "small"` | `"regular"` | Table density variant |
| `onRowClick` | `(row: TData) => void` | toggles selection | Called on row click, replaces default selection toggle |
| `height` | `number \| string` | - | Max height for scrollable body (header stays pinned). `300` or `"50vh"` |
| `headerBackground` | `string` | - | CSS color for the sticky header (prevents content showing through) |
## Footer Config
The `footer` prop accepts a discriminated union on `mode`.
### Selection mode
For tables with selectable rows. Shows a selection message + count pagination.
```ts
footer={{
mode: "selection",
multiSelect: true, // default true
onView: () => { ... }, // optional "View" button
onClear: () => { ... }, // optional "Clear" button (falls back to default clearSelection)
}}
```
### Summary mode
For read-only tables. Shows "Showing X~Y of Z" + list pagination.
```ts
footer={{ mode: "summary" }}
```
## Draggable Config
Enable drag-and-drop row reordering. DnD is automatically disabled when column sorting is active.
```ts
<DataTable
data={items}
columns={columns}
draggable={{
getRowId: (row) => row.id,
onReorder: (ids, changedOrders) => {
// ids: new ordered array of all row IDs
// changedOrders: { [id]: newIndex } for rows that moved
setItems(ids.map((id) => items.find((r) => r.id === id)!));
},
}}
/>
```
| Option | Type | Description |
|---|---|---|
| `getRowId` | `(row: TData) => string` | Extract a unique string ID from each row |
| `onReorder` | `(ids: string[], changedOrders: Record<string, number>) => void \| Promise<void>` | Called after a successful reorder |
## Sizing
The `size` prop (`"regular"` or `"small"`) affects:
- Qualifier column width (56px vs 40px)
- Actions column width (88px vs 20px)
- Footer text styles and pagination size
- All child components via `TableSizeContext`
Column widths can be responsive to size using a function:
```ts
// In types.ts, width accepts:
width: ColumnWidth | ((size: TableSize) => ColumnWidth)
// Example (this is what qualifier/actions use internally):
width: (size) => size === "small" ? { fixed: 40 } : { fixed: 56 }
```
### Width system
Data columns use **weight-based proportional distribution**. A column with `weight: 40` gets twice the space of one with `weight: 20`. When the container is narrower than the sum of `minWidth` values, columns clamp to their minimums.
Fixed columns (`{ fixed: N }`) take exactly N pixels and don't participate in proportional distribution.
Resizing uses **splitter semantics**: dragging a column border grows that column and shrinks its neighbor by the same amount, keeping total width constant.
## Advanced Examples
### Scrollable table with pinned header
```tsx
<DataTable
data={allRows}
columns={columns}
height={300}
headerBackground="var(--background-tint-00)"
/>
```
### Hidden columns on load
```tsx
<DataTable
data={data}
columns={columns}
initialColumnVisibility={{ department: false, joinDate: false }}
footer={{ mode: "selection" }}
/>
```
### Icon-based data column
```tsx
const STATUS_ICONS = {
active: SvgCheckCircle,
pending: SvgClock,
inactive: SvgAlertCircle,
} as const;
tc.column("status", {
header: "Status",
weight: 14,
minWidth: 80,
cell: (value) => (
<Content
sizePreset="main-ui"
variant="body"
icon={STATUS_ICONS[value]}
title={value.charAt(0).toUpperCase() + value.slice(1)}
/>
),
})
```
### Non-selectable qualifier with icons
```ts
tc.qualifier({
content: "icon",
getIcon: (row) => row.icon,
selectable: false,
header: false,
})
```
### Small variant in a bordered container
```tsx
<div className="border border-border-01 rounded-lg overflow-hidden">
<DataTable
data={data}
columns={columns}
size="small"
pageSize={10}
footer={{ mode: "selection" }}
/>
</div>
```
### Custom row click handler
```tsx
<DataTable
data={data}
columns={columns}
onRowClick={(row) => router.push(`/users/${row.id}`)}
/>
```
## Source Files
| File | Purpose |
|---|---|
| `DataTable.tsx` | Main component |
| `columns.ts` | `createTableColumns` builder |
| `types.ts` | All TypeScript interfaces |
| `hooks/useDataTable.ts` | TanStack table wrapper hook |
| `hooks/useColumnWidths.ts` | Weight-based width system |
| `hooks/useDraggableRows.ts` | DnD hook (`@dnd-kit`) |
| `Footer.tsx` | Selection / Summary footer modes |
| `TableSizeContext.tsx` | Size context provider |

View File

@@ -13,6 +13,7 @@ import { usePathname, useRouter } from "next/navigation";
import { SvgAlertTriangle, SvgLogOut } from "@opal/icons";
import { Content } from "@opal/layouts";
import { useCurrentUser } from "@/hooks/useCurrentUser";
import { getExtensionContext } from "@/lib/extension/utils";
export default function AppHealthBanner() {
const router = useRouter();
@@ -39,7 +40,18 @@ export default function AppHealthBanner() {
// Function to handle the "Log in" button click
function handleLogin() {
setShowLoggedOutModal(false);
router.push("/auth/login");
const { isExtension } = getExtensionContext();
if (isExtension) {
// In the Chrome extension, open login in a new tab so OAuth popups
// work correctly (the extension iframe has no navigable URL origin).
window.open(
window.location.origin + "/auth/login",
"_blank",
"noopener,noreferrer"
);
} else {
router.push("/auth/login");
}
}
// Function to set up expiration timeout

View File

@@ -188,50 +188,42 @@ export default function ShareChatSessionModal({
<Section
justifyContent="start"
alignItems="stretch"
gap={1}
height="auto"
gap={0.12}
>
<Section
justifyContent="start"
alignItems="stretch"
height="auto"
gap={0.12}
>
<PrivacyOption
icon={SvgLock}
title="Private"
description="Only you have access to this chat."
selected={selectedPrivacy === "private"}
onClick={() => setSelectedPrivacy("private")}
ariaLabel="share-modal-option-private"
/>
<PrivacyOption
icon={SvgUsers}
title="Your Organization"
description="Anyone in your organization can view this chat."
selected={selectedPrivacy === "public"}
onClick={() => setSelectedPrivacy("public")}
ariaLabel="share-modal-option-public"
/>
</Section>
{isShared && (
<div aria-label="share-modal-link-input">
<InputTypeIn
readOnly
value={shareLink}
rightSection={
<CopyIconButton
getCopyText={() => shareLink}
tooltip="Copy link"
size="sm"
aria-label="share-modal-copy-link"
/>
}
/>
</div>
)}
<PrivacyOption
icon={SvgLock}
title="Private"
description="Only you have access to this chat."
selected={selectedPrivacy === "private"}
onClick={() => setSelectedPrivacy("private")}
ariaLabel="share-modal-option-private"
/>
<PrivacyOption
icon={SvgUsers}
title="Your Organization"
description="Anyone in your organization can view this chat."
selected={selectedPrivacy === "public"}
onClick={() => setSelectedPrivacy("public")}
ariaLabel="share-modal-option-public"
/>
</Section>
{isShared && (
<InputTypeIn
aria-label="share-modal-link-input"
readOnly
value={shareLink}
rightSection={
<CopyIconButton
getCopyText={() => shareLink}
tooltip="Copy link"
size="sm"
aria-label="share-modal-copy-link"
/>
}
/>
)}
</Modal.Body>
<Modal.Footer>
{!isShared && (

View File

@@ -0,0 +1,20 @@
import { test, expect } from "@tests/e2e/fixtures/eeFeatures";
test.describe("EE Feature Redirect", () => {
test("redirects to /chat with toast when EE features are not licensed", async ({
page,
eeEnabled,
}) => {
test.skip(eeEnabled, "Redirect only happens without Enterprise license");
await page.goto("/admin/theme");
await expect(page).toHaveURL(/\/chat/, { timeout: 10_000 });
const toastContainer = page.getByTestId("toast-container");
await expect(toastContainer).toBeVisible({ timeout: 5_000 });
await expect(
toastContainer.getByText(/only accessible with a paid license/i)
).toBeVisible();
});
});

View File

@@ -1,4 +1,4 @@
import { test, expect } from "@playwright/test";
import { test, expect } from "@tests/e2e/fixtures/eeFeatures";
import { loginAs } from "@tests/e2e/utils/auth";
test.describe("Appearance Theme Settings @exclusive", () => {
@@ -12,24 +12,21 @@ test.describe("Appearance Theme Settings @exclusive", () => {
consentPrompt: "I agree to the terms",
};
test.beforeEach(async ({ page }) => {
test.beforeEach(async ({ page, eeEnabled }) => {
test.skip(
!eeEnabled,
"Enterprise license not active — skipping theme tests"
);
// Fresh session — the eeEnabled fixture already logged in to check the
// setting, so clear cookies and re-login for a clean test state.
await page.context().clearCookies();
await loginAs(page, "admin");
// Navigate first so localStorage is accessible (API-based login
// doesn't navigate, leaving the page on about:blank).
await page.goto("/admin/theme");
await page.waitForLoadState("networkidle");
// Skip the entire test when Enterprise features are not licensed.
// The /admin/theme page is gated behind ee_features_enabled and
// renders a license-required message instead of the settings form.
const eeLocked = page.getByText(
"This functionality requires an active Enterprise license."
);
if (await eeLocked.isVisible({ timeout: 1000 }).catch(() => false)) {
test.skip(true, "Enterprise license not active — skipping theme tests");
}
await expect(
page.locator('[data-label="application-name-input"]')
).toBeVisible({ timeout: 10_000 });
// Clear localStorage to ensure consent modal shows
await page.evaluate(() => {

View File

@@ -156,10 +156,7 @@ test.describe("Share Chat Session Modal", () => {
expect(patchBody).toEqual({ sharing_status: "public" });
const linkInput = dialog.locator('[aria-label="share-modal-link-input"]');
await expect(linkInput).toBeVisible({ timeout: 5000 });
const inputValue = await linkInput.locator("input").inputValue();
expect(inputValue).toContain("/app/shared/");
await expect(linkInput).toHaveValue(/\/app\/shared\//, { timeout: 5000 });
await expect(submitButton).toHaveText("Copy Link");
await expect(dialog.getByText("Chat shared")).toBeVisible();

View File

@@ -0,0 +1,43 @@
/**
* Playwright fixture that detects EE (Enterprise Edition) license state.
*
* Usage:
* ```ts
* import { test, expect } from "@tests/e2e/fixtures/eeFeatures";
*
* test("my EE-gated test", async ({ page, eeEnabled }) => {
* test.skip(!eeEnabled, "Requires active Enterprise license");
* // ... rest of test
* });
* ```
*
* The fixture:
* - Authenticates as admin
* - Fetches /api/settings to check ee_features_enabled
* - Provides a boolean to the test BEFORE any navigation happens
*
* This lets tests call test.skip() synchronously at the top, which is the
* correct Playwright pattern — never navigate then decide to skip.
*/
import { test as base, expect } from "@playwright/test";
import { loginAs } from "@tests/e2e/utils/auth";
export const test = base.extend<{
/** Whether EE features are enabled (valid enterprise license). */
eeEnabled: boolean;
}>({
eeEnabled: async ({ page }, use) => {
await loginAs(page, "admin");
const res = await page.request.get("/api/settings");
if (!res.ok()) {
// Fail open — if we can't determine, assume EE is not enabled
await use(false);
return;
}
const settings = await res.json();
await use(settings.ee_features_enabled === true);
},
});
export { expect };

View File

@@ -0,0 +1,36 @@
import { expect, test } from "@playwright/test";
import { THEMES, setThemeBeforeNavigation } from "@tests/e2e/utils/theme";
import { expectScreenshot } from "@tests/e2e/utils/visualRegression";
test.use({ storageState: "admin_auth.json" });
for (const theme of THEMES) {
test.describe(`Settings pages (${theme} mode)`, () => {
test.beforeEach(async ({ page }) => {
await setThemeBeforeNavigation(page, theme);
});
test("should screenshot each settings tab", async ({ page }) => {
await page.goto("/app/settings");
await page.waitForLoadState("networkidle");
const nav = page.getByTestId("settings-left-tab-navigation");
const tabs = nav.locator("a");
const count = await tabs.count();
expect(count).toBeGreaterThan(0);
for (let i = 0; i < count; i++) {
const tab = tabs.nth(i);
const href = await tab.getAttribute("href");
const slug = href ? href.replace("/app/settings/", "") : `tab-${i}`;
await tab.click();
await page.waitForLoadState("networkidle");
await expectScreenshot(page, {
name: `settings-${theme}-${slug}`,
});
}
});
});
}

View File

@@ -165,11 +165,6 @@ export async function expectScreenshot(
threshold,
} = options;
// Wait for any in-flight CSS animations / transitions to settle so that
// screenshots are deterministic (e.g. slide-in card animations on the
// onboarding flow).
await waitForAnimations(page);
// Merge default hide selectors with per-call selectors
const allHideSelectors = [...DEFAULT_HIDE_SELECTORS, ...hide];
@@ -178,7 +173,10 @@ export async function expectScreenshot(
if (allHideSelectors.length > 0) {
styleHandle = await page.addStyleTag({
content: allHideSelectors
.map((selector) => `${selector} { visibility: hidden !important; }`)
.map(
(selector) =>
`${selector} { visibility: hidden !important; opacity: 0 !important; pointer-events: none !important; }`
)
.join("\n"),
});
}
@@ -190,6 +188,11 @@ export async function expectScreenshot(
page.locator(selector)
);
// Wait for any in-flight CSS animations / transitions to settle so that
// screenshots are deterministic (e.g. slide-in card animations on the
// onboarding flow).
await waitForAnimations(page);
// Build the screenshot name array (Playwright expects string[])
const nameArg = name ? [name + ".png"] : undefined;
@@ -253,10 +256,6 @@ export async function expectElementScreenshot(
const page = locator.page();
// Wait for any in-flight CSS animations / transitions to settle so that
// element screenshots are deterministic (same reasoning as expectScreenshot).
await waitForAnimations(page);
// Merge default hide selectors with per-call selectors
const allHideSelectors = [...DEFAULT_HIDE_SELECTORS, ...hide];
@@ -265,7 +264,10 @@ export async function expectElementScreenshot(
if (allHideSelectors.length > 0) {
styleHandle = await page.addStyleTag({
content: allHideSelectors
.map((selector) => `${selector} { visibility: hidden !important; }`)
.map(
(selector) =>
`${selector} { visibility: hidden !important; opacity: 0 !important; pointer-events: none !important; }`
)
.join("\n"),
});
}
@@ -277,6 +279,10 @@ export async function expectElementScreenshot(
page.locator(selector)
);
// Wait for any in-flight CSS animations / transitions to settle so that
// element screenshots are deterministic (same reasoning as expectScreenshot).
await waitForAnimations(page);
// Build the screenshot name array (Playwright expects string[])
const nameArg = name ? [name + ".png"] : undefined;

View File

@@ -8,7 +8,7 @@
"name": "onyx-chat-widget",
"version": "1.0.0",
"dependencies": {
"dompurify": "^3.0.0",
"dompurify": "^3.3.2",
"lit": "^3.1.0",
"marked": "^12.0.0"
},
@@ -860,10 +860,13 @@
"license": "MIT"
},
"node_modules/dompurify": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz",
"integrity": "sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q==",
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.2.tgz",
"integrity": "sha512-6obghkliLdmKa56xdbLOpUZ43pAR6xFy1uOrxBaIDjT+yaRuuybLjGS9eVBoSR/UPU5fq3OXClEHLJNGvbxKpQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"engines": {
"node": ">=20"
},
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}

View File

@@ -17,7 +17,7 @@
"type-check": "tsc --noEmit"
},
"dependencies": {
"dompurify": "^3.0.0",
"dompurify": "^3.3.2",
"lit": "^3.1.0",
"marked": "^12.0.0"
},