Compare commits

..

26 Commits

Author SHA1 Message Date
SubashMohan
a4387f230b fix(popover): prevent viewport overflow with dynamic max-height and collision padding (#8675) 2026-02-24 10:27:36 +00:00
Evan Lohn
d91e452658 chore: version bumps for client libs (#8720) 2026-02-24 08:13:37 +00:00
Danelegend
dd274f8667 feat: code interpreter supports streaming (#8663) 2026-02-24 06:07:36 +00:00
roshan
2c82f0da16 fix(craft): delete S3 snapshot files when deleting a craft (#8718)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-24 05:58:29 +00:00
Raunak Bhagat
26101636f2 refactor: add new ContentAction component (#8695) 2026-02-24 05:13:18 +00:00
roshan
5e2c0c6cf4 fix(nrf): hide search toggle when search mode is unavailable (#8717)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-23 20:43:19 -08:00
roshan
33b64db498 fix(extensions): fix base url for chrome extension to (#8714) 2026-02-23 20:18:05 -08:00
roshan
b925cc1a56 feat(chrome-extension): add tab reading to side panel (#8571)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 01:17:57 +00:00
Danelegend
bac4b7c945 fix: preview markdown formatting (#8667) 2026-02-24 01:13:52 +00:00
Evan Lohn
6f6ef1e657 chore: coerce doc metadata (#8703) 2026-02-24 01:12:11 +00:00
Danelegend
885c69f460 feat: Improve csv preview modal (#8702) 2026-02-24 01:00:20 +00:00
Danelegend
4b837303ff feat(code-interpreter): Seed code interpreter server row (#8701) 2026-02-24 00:59:49 +00:00
Justin Tahara
d856a9befb fix(projects): Guardrails for Project User Files (#8644) 2026-02-24 00:21:57 +00:00
Justin Tahara
adade353c5 fix(api): Improving the API handling of threads (#8573) 2026-02-24 00:04:21 +00:00
Nikolas Garza
3cb6ec2f85 fix: patch prometheus metrics in daily test fixture (#8699) 2026-02-24 00:02:56 +00:00
Wenxi
691eebf00a fix: remove user info requirement for craft onboarding modal (#8697) 2026-02-23 23:52:17 +00:00
Danelegend
905b6633e6 chore: preview modal (#8665) 2026-02-23 23:40:55 +00:00
Justin Tahara
fd088196ff fix(search): Improve Speed (#8430) 2026-02-23 22:45:18 +00:00
Jamison Lahman
cafbf5b8be chore(playwright): warn user if setup takes longer than usual (#8690) 2026-02-23 22:23:58 +00:00
roshan
1235181559 fix(ui): Clean up NRF settings button styling (#8678)
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-23 21:25:43 +00:00
Justin Tahara
caa2e45632 fix(db): Multitenant Schema migration update (#8679) 2026-02-23 21:25:26 +00:00
Justin Tahara
9c62e03120 chore(ods): Automated Cherry-pick backport (#8642) 2026-02-23 21:15:09 +00:00
Nikolas Garza
0937305064 feat(scim): Okta compatibility + provider abstraction (#8568) 2026-02-23 21:09:18 +00:00
Wenxi
e4c06570e3 fix: domain rules for signup on cloud (#8671) 2026-02-23 20:27:37 +00:00
roshan
78fc7c86d7 fix: Handle unauthenticated state gracefully on NRF page (#8491)
Co-authored-by: Claude <noreply@anthropic.com>
2026-02-23 19:26:38 +00:00
Raunak Bhagat
84d3aea847 refactor: migrate Web Search page to SettingsLayouts + Content (#8662) 2026-02-23 13:38:37 +00:00
128 changed files with 4392 additions and 1188 deletions

View File

@@ -8,5 +8,5 @@
## Additional Options
- [ ] [Required] I have considered whether this PR needs to be cherry-picked to the latest beta branch.
- [ ] [Optional] Please cherry-pick this PR to the latest release version.
- [ ] [Optional] Override Linear Check

View File

@@ -0,0 +1,79 @@
name: Post-Merge Beta Cherry-Pick
on:
push:
branches:
- main
permissions:
contents: write
pull-requests: write
jobs:
cherry-pick-to-latest-release:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- name: Resolve merged PR and checkbox state
id: gate
env:
GH_TOKEN: ${{ github.token }}
run: |
# For the commit that triggered this workflow (HEAD on main), fetch all
# associated PRs and keep only the PR that was actually merged into main
# with this exact merge commit SHA.
pr_numbers="$(gh api "repos/${GITHUB_REPOSITORY}/commits/${GITHUB_SHA}/pulls" | jq -r --arg sha "${GITHUB_SHA}" '.[] | select(.merged_at != null and .base.ref == "main" and .merge_commit_sha == $sha) | .number')"
match_count="$(printf '%s\n' "$pr_numbers" | sed '/^[[:space:]]*$/d' | wc -l | tr -d ' ')"
pr_number="$(printf '%s\n' "$pr_numbers" | sed '/^[[:space:]]*$/d' | head -n 1)"
if [ "${match_count}" -gt 1 ]; then
echo "::warning::Multiple merged PRs matched commit ${GITHUB_SHA}. Using PR #${pr_number}."
fi
if [ -z "$pr_number" ]; then
echo "No merged PR associated with commit ${GITHUB_SHA}; skipping."
echo "should_cherrypick=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# Read the PR body and check whether the helper checkbox is checked.
pr_body="$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${pr_number}" --jq '.body // ""')"
echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT"
if echo "$pr_body" | grep -qiE "\\[x\\][[:space:]]*(\\[[^]]+\\][[:space:]]*)?Please cherry-pick this PR to the latest release version"; then
echo "should_cherrypick=true" >> "$GITHUB_OUTPUT"
echo "Cherry-pick checkbox checked for PR #${pr_number}."
exit 0
fi
echo "should_cherrypick=false" >> "$GITHUB_OUTPUT"
echo "Cherry-pick checkbox not checked for PR #${pr_number}. Skipping."
- name: Checkout repository
if: steps.gate.outputs.should_cherrypick == 'true'
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # ratchet:actions/checkout@v6
with:
fetch-depth: 0
persist-credentials: true
ref: main
- name: Install the latest version of uv
if: steps.gate.outputs.should_cherrypick == 'true'
uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # ratchet:astral-sh/setup-uv@v7
with:
enable-cache: false
version: "0.9.9"
- name: Configure git identity
if: steps.gate.outputs.should_cherrypick == 'true'
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
- name: Create cherry-pick PR to latest release
if: steps.gate.outputs.should_cherrypick == 'true'
env:
GH_TOKEN: ${{ github.token }}
GITHUB_TOKEN: ${{ github.token }}
run: |
uv run --no-sync --with onyx-devtools ods cherry-pick "${GITHUB_SHA}" --yes --no-verify

View File

@@ -1,28 +0,0 @@
name: Require beta cherry-pick consideration
concurrency:
group: Require-Beta-Cherrypick-Consideration-${{ github.workflow }}-${{ github.head_ref || github.event.workflow_run.head_branch || github.run_id }}
cancel-in-progress: true
on:
pull_request:
types: [opened, edited, reopened, synchronize]
permissions:
contents: read
jobs:
beta-cherrypick-check:
runs-on: ubuntu-latest
timeout-minutes: 45
steps:
- name: Check PR body for beta cherry-pick consideration
env:
PR_BODY: ${{ github.event.pull_request.body }}
run: |
if echo "$PR_BODY" | grep -qiE "\\[x\\][[:space:]]*\\[Required\\][[:space:]]*I have considered whether this PR needs to be cherry[- ]picked to the latest beta branch"; then
echo "Cherry-pick consideration box is checked. Check passed."
exit 0
fi
echo "::error::Please check the 'I have considered whether this PR needs to be cherry-picked to the latest beta branch' box in the PR description."
exit 1

View File

@@ -21,15 +21,14 @@ import sys
import threading
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import List, NamedTuple
from typing import NamedTuple
from alembic.config import Config
from alembic.script import ScriptDirectory
from sqlalchemy import text
from onyx.db.engine.sql_engine import is_valid_schema_name
from onyx.db.engine.sql_engine import SqlEngine
from onyx.db.engine.tenant_utils import get_all_tenant_ids
from onyx.db.engine.tenant_utils import get_schemas_needing_migration
from shared_configs.configs import TENANT_ID_PREFIX
@@ -105,56 +104,6 @@ def get_head_revision() -> str | None:
return script.get_current_head()
def get_schemas_needing_migration(
tenant_schemas: List[str], head_rev: str
) -> List[str]:
"""Return only schemas whose current alembic version is not at head."""
if not tenant_schemas:
return []
engine = SqlEngine.get_engine()
with engine.connect() as conn:
# Find which schemas actually have an alembic_version table
rows = conn.execute(
text(
"SELECT table_schema FROM information_schema.tables "
"WHERE table_name = 'alembic_version' "
"AND table_schema = ANY(:schemas)"
),
{"schemas": tenant_schemas},
)
schemas_with_table = set(row[0] for row in rows)
# Schemas without the table definitely need migration
needs_migration = [s for s in tenant_schemas if s not in schemas_with_table]
if not schemas_with_table:
return needs_migration
# Validate schema names before interpolating into SQL
for schema in schemas_with_table:
if not is_valid_schema_name(schema):
raise ValueError(f"Invalid schema name: {schema}")
# Single query to get every schema's current revision at once.
# Use integer tags instead of interpolating schema names into
# string literals to avoid quoting issues.
schema_list = list(schemas_with_table)
union_parts = [
f'SELECT {i} AS idx, version_num FROM "{schema}".alembic_version'
for i, schema in enumerate(schema_list)
]
rows = conn.execute(text(" UNION ALL ".join(union_parts)))
version_by_schema = {schema_list[row[0]]: row[1] for row in rows}
needs_migration.extend(
s for s in schemas_with_table if version_by_schema.get(s) != head_rev
)
return needs_migration
def run_migrations_parallel(
schemas: list[str],
max_workers: int,

View File

@@ -0,0 +1,29 @@
"""code interpreter seed
Revision ID: 07b98176f1de
Revises: 7cb492013621
Create Date: 2026-02-23 15:55:07.606784
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "07b98176f1de"
down_revision = "7cb492013621"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Seed the single instance of code_interpreter_server
# NOTE: There should only exist at most and at minimum 1 code_interpreter_server row
op.execute(
sa.text("INSERT INTO code_interpreter_server (server_enabled) VALUES (true)")
)
def downgrade() -> None:
op.execute(sa.text("DELETE FROM code_interpreter_server"))

View File

@@ -127,9 +127,14 @@ class ScimDAL(DAL):
self,
external_id: str,
user_id: UUID,
scim_username: str | None = None,
) -> ScimUserMapping:
"""Create a mapping between a SCIM externalId and an Onyx user."""
mapping = ScimUserMapping(external_id=external_id, user_id=user_id)
mapping = ScimUserMapping(
external_id=external_id,
user_id=user_id,
scim_username=scim_username,
)
self._session.add(mapping)
self._session.flush()
return mapping
@@ -248,11 +253,11 @@ class ScimDAL(DAL):
scim_filter: ScimFilter | None,
start_index: int = 1,
count: int = 100,
) -> tuple[list[tuple[User, str | None]], int]:
) -> tuple[list[tuple[User, ScimUserMapping | None]], int]:
"""Query users with optional SCIM filter and pagination.
Returns:
A tuple of (list of (user, external_id) pairs, total_count).
A tuple of (list of (user, mapping) pairs, total_count).
Raises:
ValueError: If the filter uses an unsupported attribute.
@@ -292,33 +297,104 @@ class ScimDAL(DAL):
users = list(
self._session.scalars(
query.order_by(User.id).offset(offset).limit(count) # type: ignore[arg-type]
).all()
)
.unique()
.all()
)
# Batch-fetch external IDs to avoid N+1 queries
ext_id_map = self._get_user_external_ids([u.id for u in users])
return [(u, ext_id_map.get(u.id)) for u in users], total
# Batch-fetch SCIM mappings to avoid N+1 queries
mapping_map = self._get_user_mappings_batch([u.id for u in users])
return [(u, mapping_map.get(u.id)) for u in users], total
def sync_user_external_id(self, user_id: UUID, new_external_id: str | None) -> None:
def sync_user_external_id(
self,
user_id: UUID,
new_external_id: str | None,
scim_username: str | None = None,
) -> None:
"""Create, update, or delete the external ID mapping for a user."""
mapping = self.get_user_mapping_by_user_id(user_id)
if new_external_id:
if mapping:
if mapping.external_id != new_external_id:
mapping.external_id = new_external_id
if scim_username is not None:
mapping.scim_username = scim_username
else:
self.create_user_mapping(external_id=new_external_id, user_id=user_id)
self.create_user_mapping(
external_id=new_external_id,
user_id=user_id,
scim_username=scim_username,
)
elif mapping:
self.delete_user_mapping(mapping.id)
def _get_user_external_ids(self, user_ids: list[UUID]) -> dict[UUID, str]:
"""Batch-fetch external IDs for a list of user IDs."""
def _get_user_mappings_batch(
self, user_ids: list[UUID]
) -> dict[UUID, ScimUserMapping]:
"""Batch-fetch SCIM user mappings keyed by user ID."""
if not user_ids:
return {}
mappings = self._session.scalars(
select(ScimUserMapping).where(ScimUserMapping.user_id.in_(user_ids))
).all()
return {m.user_id: m.external_id for m in mappings}
return {m.user_id: m for m in mappings}
def get_user_groups(self, user_id: UUID) -> list[tuple[int, str]]:
"""Get groups a user belongs to as ``(group_id, group_name)`` pairs.
Excludes groups marked for deletion.
"""
rels = self._session.scalars(
select(User__UserGroup).where(User__UserGroup.user_id == user_id)
).all()
group_ids = [r.user_group_id for r in rels]
if not group_ids:
return []
groups = self._session.scalars(
select(UserGroup).where(
UserGroup.id.in_(group_ids),
UserGroup.is_up_for_deletion.is_(False),
)
).all()
return [(g.id, g.name) for g in groups]
def get_users_groups_batch(
self, user_ids: list[UUID]
) -> dict[UUID, list[tuple[int, str]]]:
"""Batch-fetch group memberships for multiple users.
Returns a mapping of ``user_id → [(group_id, group_name), ...]``.
Avoids N+1 queries when building user list responses.
"""
if not user_ids:
return {}
rels = self._session.scalars(
select(User__UserGroup).where(User__UserGroup.user_id.in_(user_ids))
).all()
group_ids = list({r.user_group_id for r in rels})
if not group_ids:
return {}
groups = self._session.scalars(
select(UserGroup).where(
UserGroup.id.in_(group_ids),
UserGroup.is_up_for_deletion.is_(False),
)
).all()
groups_by_id = {g.id: g.name for g in groups}
result: dict[UUID, list[tuple[int, str]]] = {}
for r in rels:
if r.user_id and r.user_group_id in groups_by_id:
result.setdefault(r.user_id, []).append(
(r.user_group_id, groups_by_id[r.user_group_id])
)
return result
# ------------------------------------------------------------------
# Group mapping operations
@@ -483,9 +559,13 @@ class ScimDAL(DAL):
if not user_ids:
return []
users = self._session.scalars(
select(User).where(User.id.in_(user_ids)) # type: ignore[attr-defined]
).all()
users = (
self._session.scalars(
select(User).where(User.id.in_(user_ids)) # type: ignore[attr-defined]
)
.unique()
.all()
)
users_by_id = {u.id: u for u in users}
return [
@@ -504,9 +584,13 @@ class ScimDAL(DAL):
"""
if not uuids:
return []
existing_users = self._session.scalars(
select(User).where(User.id.in_(uuids)) # type: ignore[attr-defined]
).all()
existing_users = (
self._session.scalars(
select(User).where(User.id.in_(uuids)) # type: ignore[attr-defined]
)
.unique()
.all()
)
existing_ids = {u.id for u in existing_users}
return [uid for uid in uuids if uid not in existing_ids]

View File

@@ -34,7 +34,7 @@ class SendSearchQueryRequest(BaseModel):
filters: BaseFilters | None = None
num_docs_fed_to_llm_selection: int | None = None
run_query_expansion: bool = False
num_hits: int = 50
num_hits: int = 30
include_content: bool = False
stream: bool = False

View File

@@ -26,12 +26,10 @@ from sqlalchemy.orm import Session
from ee.onyx.db.scim import ScimDAL
from ee.onyx.server.scim.auth import verify_scim_token
from ee.onyx.server.scim.filtering import parse_scim_filter
from ee.onyx.server.scim.models import ScimEmail
from ee.onyx.server.scim.models import ScimError
from ee.onyx.server.scim.models import ScimGroupMember
from ee.onyx.server.scim.models import ScimGroupResource
from ee.onyx.server.scim.models import ScimListResponse
from ee.onyx.server.scim.models import ScimMeta
from ee.onyx.server.scim.models import ScimName
from ee.onyx.server.scim.models import ScimPatchRequest
from ee.onyx.server.scim.models import ScimResourceType
@@ -41,6 +39,8 @@ from ee.onyx.server.scim.models import ScimUserResource
from ee.onyx.server.scim.patch import apply_group_patch
from ee.onyx.server.scim.patch import apply_user_patch
from ee.onyx.server.scim.patch import ScimPatchError
from ee.onyx.server.scim.providers.base import get_default_provider
from ee.onyx.server.scim.providers.base import ScimProvider
from ee.onyx.server.scim.schema_definitions import GROUP_RESOURCE_TYPE
from ee.onyx.server.scim.schema_definitions import GROUP_SCHEMA_DEF
from ee.onyx.server.scim.schema_definitions import SERVICE_PROVIDER_CONFIG
@@ -53,7 +53,6 @@ from onyx.db.models import UserGroup
from onyx.db.models import UserRole
from onyx.utils.variable_functionality import fetch_ee_implementation_or_noop
# NOTE: All URL paths in this router (/ServiceProviderConfig, /ResourceTypes,
# /Schemas, /Users, /Groups) are mandated by the SCIM spec (RFC 7643/7644).
# IdPs like Okta and Azure AD hardcode these exact paths, so they cannot be
@@ -63,6 +62,18 @@ scim_router = APIRouter(prefix="/scim/v2", tags=["SCIM"])
_pw_helper = PasswordHelper()
def _get_provider(
_token: ScimToken = Depends(verify_scim_token),
) -> ScimProvider:
"""Resolve the SCIM provider for the current request.
Currently returns OktaProvider for all requests. When multi-provider
support is added (ENG-3652), this will resolve based on token metadata
or tenant configuration — no endpoint changes required.
"""
return get_default_provider()
# ---------------------------------------------------------------------------
# Service Discovery Endpoints (unauthenticated)
# ---------------------------------------------------------------------------
@@ -100,28 +111,6 @@ def _scim_error_response(status: int, detail: str) -> JSONResponse:
)
def _user_to_scim(user: User, external_id: str | None = None) -> ScimUserResource:
"""Convert an Onyx User to a SCIM User resource representation."""
name = None
if user.personal_name:
parts = user.personal_name.split(" ", 1)
name = ScimName(
givenName=parts[0],
familyName=parts[1] if len(parts) > 1 else None,
formatted=user.personal_name,
)
return ScimUserResource(
id=str(user.id),
externalId=external_id,
userName=user.email,
name=name,
emails=[ScimEmail(value=user.email, type="work", primary=True)],
active=user.is_active,
meta=ScimMeta(resourceType="User"),
)
def _check_seat_availability(dal: ScimDAL) -> str | None:
"""Return an error message if seat limit is reached, else None."""
check_fn = fetch_ee_implementation_or_noop(
@@ -155,9 +144,10 @@ def _scim_name_to_str(name: ScimName | None) -> str | None:
"""
if not name:
return None
return name.formatted or " ".join(
part for part in [name.givenName, name.familyName] if part
)
# Build from givenName/familyName first — IdPs like Okta may send a stale
# ``formatted`` value while updating the individual name components.
parts = " ".join(part for part in [name.givenName, name.familyName] if part)
return parts or name.formatted
# ---------------------------------------------------------------------------
@@ -171,6 +161,7 @@ def list_users(
startIndex: int = Query(1, ge=1),
count: int = Query(100, ge=0, le=500),
_token: ScimToken = Depends(verify_scim_token),
provider: ScimProvider = Depends(_get_provider),
db_session: Session = Depends(get_session),
) -> ScimListResponse | JSONResponse:
"""List users with optional SCIM filter and pagination."""
@@ -183,12 +174,19 @@ def list_users(
return _scim_error_response(400, str(e))
try:
users_with_ext_ids, total = dal.list_users(scim_filter, startIndex, count)
users_with_mappings, total = dal.list_users(scim_filter, startIndex, count)
except ValueError as e:
return _scim_error_response(400, str(e))
user_groups_map = dal.get_users_groups_batch([u.id for u, _ in users_with_mappings])
resources: list[ScimUserResource | ScimGroupResource] = [
_user_to_scim(user, ext_id) for user, ext_id in users_with_ext_ids
provider.build_user_resource(
user,
mapping.external_id if mapping else None,
groups=user_groups_map.get(user.id, []),
scim_username=mapping.scim_username if mapping else None,
)
for user, mapping in users_with_mappings
]
return ScimListResponse(
@@ -203,6 +201,7 @@ def list_users(
def get_user(
user_id: str,
_token: ScimToken = Depends(verify_scim_token),
provider: ScimProvider = Depends(_get_provider),
db_session: Session = Depends(get_session),
) -> ScimUserResource | JSONResponse:
"""Get a single user by ID."""
@@ -215,20 +214,26 @@ def get_user(
user = result
mapping = dal.get_user_mapping_by_user_id(user.id)
return _user_to_scim(user, mapping.external_id if mapping else None)
return provider.build_user_resource(
user,
mapping.external_id if mapping else None,
groups=dal.get_user_groups(user.id),
scim_username=mapping.scim_username if mapping else None,
)
@scim_router.post("/Users", status_code=201, response_model=None)
def create_user(
user_resource: ScimUserResource,
_token: ScimToken = Depends(verify_scim_token),
provider: ScimProvider = Depends(_get_provider),
db_session: Session = Depends(get_session),
) -> ScimUserResource | JSONResponse:
"""Create a new user from a SCIM provisioning request."""
dal = ScimDAL(db_session)
dal.update_token_last_used(_token.id)
email = user_resource.userName.strip().lower()
email = user_resource.userName.strip()
# externalId is how the IdP correlates this user on subsequent requests.
# Without it, the IdP can't find the user and will try to re-create,
@@ -264,11 +269,14 @@ def create_user(
# Create SCIM mapping (externalId is validated above, always present)
external_id = user_resource.externalId
dal.create_user_mapping(external_id=external_id, user_id=user.id)
scim_username = user_resource.userName.strip()
dal.create_user_mapping(
external_id=external_id, user_id=user.id, scim_username=scim_username
)
dal.commit()
return _user_to_scim(user, external_id)
return provider.build_user_resource(user, external_id, scim_username=scim_username)
@scim_router.put("/Users/{user_id}", response_model=None)
@@ -276,6 +284,7 @@ def replace_user(
user_id: str,
user_resource: ScimUserResource,
_token: ScimToken = Depends(verify_scim_token),
provider: ScimProvider = Depends(_get_provider),
db_session: Session = Depends(get_session),
) -> ScimUserResource | JSONResponse:
"""Replace a user entirely (RFC 7644 §3.5.1)."""
@@ -293,19 +302,27 @@ def replace_user(
if seat_error:
return _scim_error_response(403, seat_error)
personal_name = _scim_name_to_str(user_resource.name)
dal.update_user(
user,
email=user_resource.userName.strip().lower(),
email=user_resource.userName.strip(),
is_active=user_resource.active,
personal_name=_scim_name_to_str(user_resource.name),
personal_name=personal_name,
)
new_external_id = user_resource.externalId
dal.sync_user_external_id(user.id, new_external_id)
scim_username = user_resource.userName.strip()
dal.sync_user_external_id(user.id, new_external_id, scim_username=scim_username)
dal.commit()
return _user_to_scim(user, new_external_id)
return provider.build_user_resource(
user,
new_external_id,
groups=dal.get_user_groups(user.id),
scim_username=scim_username,
)
@scim_router.patch("/Users/{user_id}", response_model=None)
@@ -313,6 +330,7 @@ def patch_user(
user_id: str,
patch_request: ScimPatchRequest,
_token: ScimToken = Depends(verify_scim_token),
provider: ScimProvider = Depends(_get_provider),
db_session: Session = Depends(get_session),
) -> ScimUserResource | JSONResponse:
"""Partially update a user (RFC 7644 §3.5.2).
@@ -330,11 +348,19 @@ def patch_user(
mapping = dal.get_user_mapping_by_user_id(user.id)
external_id = mapping.external_id if mapping else None
current_scim_username = mapping.scim_username if mapping else None
current = _user_to_scim(user, external_id)
current = provider.build_user_resource(
user,
external_id,
groups=dal.get_user_groups(user.id),
scim_username=current_scim_username,
)
try:
patched = apply_user_patch(patch_request.Operations, current)
patched = apply_user_patch(
patch_request.Operations, current, provider.ignored_patch_paths
)
except ScimPatchError as e:
return _scim_error_response(e.status, e.detail)
@@ -345,22 +371,40 @@ def patch_user(
if seat_error:
return _scim_error_response(403, seat_error)
# Track the scim_username — if userName was patched, update it
new_scim_username = patched.userName.strip() if patched.userName else None
# If displayName was explicitly patched (different from the original), use
# it as personal_name directly. Otherwise, derive from name components.
personal_name: str | None
if patched.displayName and patched.displayName != current.displayName:
personal_name = patched.displayName
else:
personal_name = _scim_name_to_str(patched.name)
dal.update_user(
user,
email=(
patched.userName.strip().lower()
if patched.userName.lower() != user.email
patched.userName.strip()
if patched.userName.strip().lower() != user.email.lower()
else None
),
is_active=patched.active if patched.active != user.is_active else None,
personal_name=_scim_name_to_str(patched.name),
personal_name=personal_name,
)
dal.sync_user_external_id(user.id, patched.externalId)
dal.sync_user_external_id(
user.id, patched.externalId, scim_username=new_scim_username
)
dal.commit()
return _user_to_scim(user, patched.externalId)
return provider.build_user_resource(
user,
patched.externalId,
groups=dal.get_user_groups(user.id),
scim_username=new_scim_username,
)
@scim_router.delete("/Users/{user_id}", status_code=204, response_model=None)
@@ -398,24 +442,6 @@ def delete_user(
# ---------------------------------------------------------------------------
def _group_to_scim(
group: UserGroup,
members: list[tuple[UUID, str | None]],
external_id: str | None = None,
) -> ScimGroupResource:
"""Convert an Onyx UserGroup to a SCIM Group resource."""
scim_members = [
ScimGroupMember(value=str(uid), display=email) for uid, email in members
]
return ScimGroupResource(
id=str(group.id),
externalId=external_id,
displayName=group.name,
members=scim_members,
meta=ScimMeta(resourceType="Group"),
)
def _fetch_group_or_404(group_id: str, dal: ScimDAL) -> UserGroup | JSONResponse:
"""Parse *group_id* as int, look up the group, or return a 404 error."""
try:
@@ -474,6 +500,7 @@ def list_groups(
startIndex: int = Query(1, ge=1),
count: int = Query(100, ge=0, le=500),
_token: ScimToken = Depends(verify_scim_token),
provider: ScimProvider = Depends(_get_provider),
db_session: Session = Depends(get_session),
) -> ScimListResponse | JSONResponse:
"""List groups with optional SCIM filter and pagination."""
@@ -491,7 +518,7 @@ def list_groups(
return _scim_error_response(400, str(e))
resources: list[ScimUserResource | ScimGroupResource] = [
_group_to_scim(group, dal.get_group_members(group.id), ext_id)
provider.build_group_resource(group, dal.get_group_members(group.id), ext_id)
for group, ext_id in groups_with_ext_ids
]
@@ -507,6 +534,7 @@ def list_groups(
def get_group(
group_id: str,
_token: ScimToken = Depends(verify_scim_token),
provider: ScimProvider = Depends(_get_provider),
db_session: Session = Depends(get_session),
) -> ScimGroupResource | JSONResponse:
"""Get a single group by ID."""
@@ -521,13 +549,16 @@ def get_group(
mapping = dal.get_group_mapping_by_group_id(group.id)
members = dal.get_group_members(group.id)
return _group_to_scim(group, members, mapping.external_id if mapping else None)
return provider.build_group_resource(
group, members, mapping.external_id if mapping else None
)
@scim_router.post("/Groups", status_code=201, response_model=None)
def create_group(
group_resource: ScimGroupResource,
_token: ScimToken = Depends(verify_scim_token),
provider: ScimProvider = Depends(_get_provider),
db_session: Session = Depends(get_session),
) -> ScimGroupResource | JSONResponse:
"""Create a new group from a SCIM provisioning request."""
@@ -565,7 +596,7 @@ def create_group(
dal.commit()
members = dal.get_group_members(db_group.id)
return _group_to_scim(db_group, members, external_id)
return provider.build_group_resource(db_group, members, external_id)
@scim_router.put("/Groups/{group_id}", response_model=None)
@@ -573,6 +604,7 @@ def replace_group(
group_id: str,
group_resource: ScimGroupResource,
_token: ScimToken = Depends(verify_scim_token),
provider: ScimProvider = Depends(_get_provider),
db_session: Session = Depends(get_session),
) -> ScimGroupResource | JSONResponse:
"""Replace a group entirely (RFC 7644 §3.5.1)."""
@@ -595,7 +627,7 @@ def replace_group(
dal.commit()
members = dal.get_group_members(group.id)
return _group_to_scim(group, members, group_resource.externalId)
return provider.build_group_resource(group, members, group_resource.externalId)
@scim_router.patch("/Groups/{group_id}", response_model=None)
@@ -603,6 +635,7 @@ def patch_group(
group_id: str,
patch_request: ScimPatchRequest,
_token: ScimToken = Depends(verify_scim_token),
provider: ScimProvider = Depends(_get_provider),
db_session: Session = Depends(get_session),
) -> ScimGroupResource | JSONResponse:
"""Partially update a group (RFC 7644 §3.5.2).
@@ -621,11 +654,11 @@ def patch_group(
external_id = mapping.external_id if mapping else None
current_members = dal.get_group_members(group.id)
current = _group_to_scim(group, current_members, external_id)
current = provider.build_group_resource(group, current_members, external_id)
try:
patched, added_ids, removed_ids = apply_group_patch(
patch_request.Operations, current
patch_request.Operations, current, provider.ignored_patch_paths
)
except ScimPatchError as e:
return _scim_error_response(e.status, e.detail)
@@ -652,7 +685,7 @@ def patch_group(
dal.commit()
members = dal.get_group_members(group.id)
return _group_to_scim(group, members, patched.externalId)
return provider.build_group_resource(group, members, patched.externalId)
@scim_router.delete("/Groups/{group_id}", status_code=204, response_model=None)

View File

@@ -63,6 +63,13 @@ class ScimMeta(BaseModel):
location: str | None = None
class ScimUserGroupRef(BaseModel):
"""Group reference within a User resource (RFC 7643 §4.1.2, read-only)."""
value: str
display: str | None = None
class ScimUserResource(BaseModel):
"""SCIM User resource representation (RFC 7643 §4.1).
@@ -76,8 +83,10 @@ class ScimUserResource(BaseModel):
externalId: str | None = None # IdP's identifier for this user
userName: str # Typically the user's email address
name: ScimName | None = None
displayName: str | None = None
emails: list[ScimEmail] = Field(default_factory=list)
active: bool = True
groups: list[ScimUserGroupRef] = Field(default_factory=list)
meta: ScimMeta | None = None
@@ -121,12 +130,40 @@ class ScimPatchOperationType(str, Enum):
REMOVE = "remove"
class ScimPatchResourceValue(BaseModel):
"""Partial resource dict for path-less PATCH replace operations.
When an IdP sends a PATCH without a ``path``, the ``value`` is a dict
of resource attributes to set. IdPs may include read-only fields
(``id``, ``schemas``, ``meta``) alongside actual changes — these are
stripped by the provider's ``ignored_patch_paths`` before processing.
``extra="allow"`` lets unknown attributes pass through so the patch
handler can decide what to do with them (ignore or reject).
"""
model_config = ConfigDict(extra="allow")
active: bool | None = None
userName: str | None = None
displayName: str | None = None
externalId: str | None = None
name: ScimName | None = None
members: list[ScimGroupMember] | None = None
id: str | None = None
schemas: list[str] | None = None
meta: ScimMeta | None = None
ScimPatchValue = str | bool | list[ScimGroupMember] | ScimPatchResourceValue | None
class ScimPatchOperation(BaseModel):
"""Single PATCH operation (RFC 7644 §3.5.2)."""
op: ScimPatchOperationType
path: str | None = None
value: str | list[dict[str, str]] | dict[str, str | bool] | bool | None = None
value: ScimPatchValue = None
class ScimPatchRequest(BaseModel):

View File

@@ -16,9 +16,12 @@ from __future__ import annotations
import re
from ee.onyx.server.scim.models import ScimGroupMember
from ee.onyx.server.scim.models import ScimGroupResource
from ee.onyx.server.scim.models import ScimPatchOperation
from ee.onyx.server.scim.models import ScimPatchOperationType
from ee.onyx.server.scim.models import ScimPatchResourceValue
from ee.onyx.server.scim.models import ScimPatchValue
from ee.onyx.server.scim.models import ScimUserResource
@@ -41,9 +44,15 @@ _MEMBER_FILTER_RE = re.compile(
def apply_user_patch(
operations: list[ScimPatchOperation],
current: ScimUserResource,
ignored_paths: frozenset[str] = frozenset(),
) -> ScimUserResource:
"""Apply SCIM PATCH operations to a user resource.
Args:
operations: The PATCH operations to apply.
current: The current user resource state.
ignored_paths: SCIM attribute paths to silently skip (from provider).
Returns a new ``ScimUserResource`` with the modifications applied.
The original object is not mutated.
@@ -55,9 +64,9 @@ def apply_user_patch(
for op in operations:
if op.op == ScimPatchOperationType.REPLACE:
_apply_user_replace(op, data, name_data)
_apply_user_replace(op, data, name_data, ignored_paths)
elif op.op == ScimPatchOperationType.ADD:
_apply_user_replace(op, data, name_data)
_apply_user_replace(op, data, name_data, ignored_paths)
else:
raise ScimPatchError(
f"Unsupported operation '{op.op.value}' on User resource"
@@ -71,30 +80,34 @@ def _apply_user_replace(
op: ScimPatchOperation,
data: dict,
name_data: dict,
ignored_paths: frozenset[str],
) -> None:
"""Apply a replace/add operation to user data."""
path = (op.path or "").lower()
if not path:
# No path — value is a dict of top-level attributes to set
if isinstance(op.value, dict):
for key, val in op.value.items():
_set_user_field(key.lower(), val, data, name_data)
# No path — value is a resource dict of top-level attributes to set
if isinstance(op.value, ScimPatchResourceValue):
for key, val in op.value.model_dump(exclude_unset=True).items():
_set_user_field(key.lower(), val, data, name_data, ignored_paths)
else:
raise ScimPatchError("Replace without path requires a dict value")
return
_set_user_field(path, op.value, data, name_data)
_set_user_field(path, op.value, data, name_data, ignored_paths)
def _set_user_field(
path: str,
value: str | bool | dict | list | None,
value: ScimPatchValue,
data: dict,
name_data: dict,
ignored_paths: frozenset[str],
) -> None:
"""Set a single field on user data by SCIM path."""
if path == "active":
if path in ignored_paths:
return
elif path == "active":
data["active"] = value
elif path == "username":
data["userName"] = value
@@ -107,7 +120,7 @@ def _set_user_field(
elif path == "name.formatted":
name_data["formatted"] = value
elif path == "displayname":
# Some IdPs send displayName on users; map to formatted name
data["displayName"] = value
name_data["formatted"] = value
else:
raise ScimPatchError(f"Unsupported path '{path}' for User PATCH")
@@ -116,9 +129,15 @@ def _set_user_field(
def apply_group_patch(
operations: list[ScimPatchOperation],
current: ScimGroupResource,
ignored_paths: frozenset[str] = frozenset(),
) -> tuple[ScimGroupResource, list[str], list[str]]:
"""Apply SCIM PATCH operations to a group resource.
Args:
operations: The PATCH operations to apply.
current: The current group resource state.
ignored_paths: SCIM attribute paths to silently skip (from provider).
Returns:
A tuple of (modified group, added member IDs, removed member IDs).
The caller uses the member ID lists to update the database.
@@ -133,7 +152,9 @@ def apply_group_patch(
for op in operations:
if op.op == ScimPatchOperationType.REPLACE:
_apply_group_replace(op, data, current_members, added_ids, removed_ids)
_apply_group_replace(
op, data, current_members, added_ids, removed_ids, ignored_paths
)
elif op.op == ScimPatchOperationType.ADD:
_apply_group_add(op, current_members, added_ids)
elif op.op == ScimPatchOperationType.REMOVE:
@@ -154,38 +175,48 @@ def _apply_group_replace(
current_members: list[dict],
added_ids: list[str],
removed_ids: list[str],
ignored_paths: frozenset[str],
) -> None:
"""Apply a replace operation to group data."""
path = (op.path or "").lower()
if not path:
if isinstance(op.value, dict):
for key, val in op.value.items():
if isinstance(op.value, ScimPatchResourceValue):
dumped = op.value.model_dump(exclude_unset=True)
for key, val in dumped.items():
if key.lower() == "members":
_replace_members(val, current_members, added_ids, removed_ids)
else:
_set_group_field(key.lower(), val, data)
_set_group_field(key.lower(), val, data, ignored_paths)
else:
raise ScimPatchError("Replace without path requires a dict value")
return
if path == "members":
_replace_members(op.value, current_members, added_ids, removed_ids)
_replace_members(
_members_to_dicts(op.value), current_members, added_ids, removed_ids
)
return
_set_group_field(path, op.value, data)
_set_group_field(path, op.value, data, ignored_paths)
def _members_to_dicts(
value: str | bool | list[ScimGroupMember] | ScimPatchResourceValue | None,
) -> list[dict]:
"""Convert a member list value to a list of dicts for internal processing."""
if not isinstance(value, list):
raise ScimPatchError("Replace members requires a list value")
return [m.model_dump(exclude_none=True) for m in value]
def _replace_members(
value: str | list | dict | bool | None,
value: list[dict],
current_members: list[dict],
added_ids: list[str],
removed_ids: list[str],
) -> None:
"""Replace the entire group member list."""
if not isinstance(value, list):
raise ScimPatchError("Replace members requires a list value")
old_ids = {m["value"] for m in current_members}
new_ids = {m.get("value", "") for m in value}
@@ -197,11 +228,14 @@ def _replace_members(
def _set_group_field(
path: str,
value: str | bool | dict | list | None,
value: ScimPatchValue,
data: dict,
ignored_paths: frozenset[str],
) -> None:
"""Set a single field on group data by SCIM path."""
if path == "displayname":
if path in ignored_paths:
return
elif path == "displayname":
data["displayName"] = value
elif path == "externalid":
data["externalId"] = value
@@ -223,8 +257,10 @@ def _apply_group_add(
if not isinstance(op.value, list):
raise ScimPatchError("Add members requires a list value")
member_dicts = [m.model_dump(exclude_none=True) for m in op.value]
existing_ids = {m["value"] for m in members}
for member_data in op.value:
for member_data in member_dicts:
member_id = member_data.get("value", "")
if member_id and member_id not in existing_ids:
members.append(member_data)

View File

@@ -0,0 +1,123 @@
"""Base SCIM provider abstraction."""
from __future__ import annotations
from abc import ABC
from abc import abstractmethod
from uuid import UUID
from ee.onyx.server.scim.models import ScimEmail
from ee.onyx.server.scim.models import ScimGroupMember
from ee.onyx.server.scim.models import ScimGroupResource
from ee.onyx.server.scim.models import ScimMeta
from ee.onyx.server.scim.models import ScimName
from ee.onyx.server.scim.models import ScimUserGroupRef
from ee.onyx.server.scim.models import ScimUserResource
from onyx.db.models import User
from onyx.db.models import UserGroup
class ScimProvider(ABC):
"""Base class for provider-specific SCIM behavior.
Subclass this to handle IdP-specific quirks. The base class provides
RFC 7643-compliant response builders that populate all standard fields.
"""
@property
@abstractmethod
def name(self) -> str:
"""Short identifier for this provider (e.g. ``"okta"``)."""
...
@property
@abstractmethod
def ignored_patch_paths(self) -> frozenset[str]:
"""SCIM attribute paths to silently skip in PATCH value-object dicts.
IdPs may include read-only or meta fields alongside actual changes
(e.g. Okta sends ``{"id": "...", "active": false}``). Paths listed
here are silently dropped instead of raising an error.
"""
...
def build_user_resource(
self,
user: User,
external_id: str | None = None,
groups: list[tuple[int, str]] | None = None,
scim_username: str | None = None,
) -> ScimUserResource:
"""Build a SCIM User response from an Onyx User.
Args:
user: The Onyx user model.
external_id: The IdP's external identifier for this user.
groups: List of ``(group_id, group_name)`` tuples for the
``groups`` read-only attribute. Pass ``None`` or ``[]``
for newly-created users.
scim_username: The original-case userName from the IdP. Falls
back to ``user.email`` (lowercase) when not available.
"""
group_refs = [
ScimUserGroupRef(value=str(gid), display=gname)
for gid, gname in (groups or [])
]
# Use original-case userName if stored, otherwise fall back to the
# lowercased email from the User model.
username = scim_username or user.email
return ScimUserResource(
id=str(user.id),
externalId=external_id,
userName=username,
name=self._build_scim_name(user),
displayName=user.personal_name,
emails=[ScimEmail(value=username, type="work", primary=True)],
active=user.is_active,
groups=group_refs,
meta=ScimMeta(resourceType="User"),
)
def build_group_resource(
self,
group: UserGroup,
members: list[tuple[UUID, str | None]],
external_id: str | None = None,
) -> ScimGroupResource:
"""Build a SCIM Group response from an Onyx UserGroup."""
scim_members = [
ScimGroupMember(value=str(uid), display=email) for uid, email in members
]
return ScimGroupResource(
id=str(group.id),
externalId=external_id,
displayName=group.name,
members=scim_members,
meta=ScimMeta(resourceType="Group"),
)
@staticmethod
def _build_scim_name(user: User) -> ScimName | None:
"""Extract SCIM name components from a user's personal name."""
if not user.personal_name:
return None
parts = user.personal_name.split(" ", 1)
return ScimName(
givenName=parts[0],
familyName=parts[1] if len(parts) > 1 else None,
formatted=user.personal_name,
)
def get_default_provider() -> ScimProvider:
"""Return the default SCIM provider.
Currently returns ``OktaProvider`` since Okta is the primary supported
IdP. When provider detection is added (via token metadata or tenant
config), this can be replaced with dynamic resolution.
"""
from ee.onyx.server.scim.providers.okta import OktaProvider
return OktaProvider()

View File

@@ -0,0 +1,25 @@
"""Okta SCIM provider."""
from __future__ import annotations
from ee.onyx.server.scim.providers.base import ScimProvider
class OktaProvider(ScimProvider):
"""Okta SCIM provider.
Okta behavioral notes:
- Uses ``PATCH {"active": false}`` for deprovisioning (not DELETE)
- Sends path-less PATCH with value dicts containing extra fields
(``id``, ``schemas``)
- Expects ``displayName`` and ``groups`` in user responses
- Only uses ``eq`` operator for ``userName`` filter
"""
@property
def name(self) -> str:
return "okta"
@property
def ignored_patch_paths(self) -> frozenset[str]:
return frozenset({"id", "schemas", "meta"})

View File

@@ -277,13 +277,32 @@ def verify_email_domain(email: str) -> None:
detail="Email is not valid",
)
domain = email.split("@")[-1].lower()
local_part, domain = email.split("@")
domain = domain.lower()
if AUTH_TYPE == AuthType.CLOUD:
# Normalize googlemail.com to gmail.com (they deliver to the same inbox)
if domain == "googlemail.com":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={"reason": "Please use @gmail.com instead of @googlemail.com."},
)
if "+" in local_part and domain != "onyx.app":
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"reason": "Email addresses with '+' are not allowed. Please use your base email address."
},
)
# Check if email uses a disposable/temporary domain
if is_disposable_email(email):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Disposable email addresses are not allowed. Please use a permanent email address.",
detail={
"reason": "Disposable email addresses are not allowed. Please use a permanent email address."
},
)
# Check domain whitelist if configured

View File

@@ -5,8 +5,10 @@ from uuid import UUID
import httpx
import sqlalchemy as sa
from celery import Celery
from celery import shared_task
from celery import Task
from redis import Redis
from redis.lock import Lock as RedisLock
from retry import retry
from sqlalchemy import select
@@ -24,12 +26,14 @@ from onyx.configs.constants import CELERY_GENERIC_BEAT_LOCK_TIMEOUT
from onyx.configs.constants import CELERY_USER_FILE_PROCESSING_LOCK_TIMEOUT
from onyx.configs.constants import CELERY_USER_FILE_PROCESSING_TASK_EXPIRES
from onyx.configs.constants import CELERY_USER_FILE_PROJECT_SYNC_LOCK_TIMEOUT
from onyx.configs.constants import CELERY_USER_FILE_PROJECT_SYNC_TASK_EXPIRES
from onyx.configs.constants import DocumentSource
from onyx.configs.constants import OnyxCeleryPriority
from onyx.configs.constants import OnyxCeleryQueues
from onyx.configs.constants import OnyxCeleryTask
from onyx.configs.constants import OnyxRedisLocks
from onyx.configs.constants import USER_FILE_PROCESSING_MAX_QUEUE_DEPTH
from onyx.configs.constants import USER_FILE_PROJECT_SYNC_MAX_QUEUE_DEPTH
from onyx.connectors.file.connector import LocalFileConnector
from onyx.connectors.models import Document
from onyx.connectors.models import HierarchyNode
@@ -75,10 +79,58 @@ def _user_file_project_sync_lock_key(user_file_id: str | UUID) -> str:
return f"{OnyxRedisLocks.USER_FILE_PROJECT_SYNC_LOCK_PREFIX}:{user_file_id}"
def _user_file_project_sync_queued_key(user_file_id: str | UUID) -> str:
return f"{OnyxRedisLocks.USER_FILE_PROJECT_SYNC_QUEUED_PREFIX}:{user_file_id}"
def _user_file_delete_lock_key(user_file_id: str | UUID) -> str:
return f"{OnyxRedisLocks.USER_FILE_DELETE_LOCK_PREFIX}:{user_file_id}"
def get_user_file_project_sync_queue_depth(celery_app: Celery) -> int:
redis_celery: Redis = celery_app.broker_connection().channel().client # type: ignore
return celery_get_queue_length(
OnyxCeleryQueues.USER_FILE_PROJECT_SYNC, redis_celery
)
def enqueue_user_file_project_sync_task(
*,
celery_app: Celery,
redis_client: Redis,
user_file_id: str | UUID,
tenant_id: str,
priority: OnyxCeleryPriority = OnyxCeleryPriority.HIGH,
) -> bool:
"""Enqueue a project-sync task if no matching queued task already exists."""
queued_key = _user_file_project_sync_queued_key(user_file_id)
# NX+EX gives us atomic dedupe and a self-healing TTL.
queued_guard_set = redis_client.set(
queued_key,
1,
nx=True,
ex=CELERY_USER_FILE_PROJECT_SYNC_TASK_EXPIRES,
)
if not queued_guard_set:
return False
try:
celery_app.send_task(
OnyxCeleryTask.PROCESS_SINGLE_USER_FILE_PROJECT_SYNC,
kwargs={"user_file_id": str(user_file_id), "tenant_id": tenant_id},
queue=OnyxCeleryQueues.USER_FILE_PROJECT_SYNC,
priority=priority,
expires=CELERY_USER_FILE_PROJECT_SYNC_TASK_EXPIRES,
)
except Exception:
# Roll back the queued guard if task publish fails.
redis_client.delete(queued_key)
raise
return True
@retry(tries=3, delay=1, backoff=2, jitter=(0.0, 1.0))
def _visit_chunks(
*,
@@ -632,8 +684,8 @@ def process_single_user_file_delete(
ignore_result=True,
)
def check_for_user_file_project_sync(self: Task, *, tenant_id: str) -> None:
"""Scan for user files with PROJECT_SYNC status and enqueue per-file tasks."""
task_logger.info("check_for_user_file_project_sync - Starting")
"""Scan for user files needing project sync and enqueue per-file tasks."""
task_logger.info("Starting")
redis_client = get_redis_client(tenant_id=tenant_id)
lock: RedisLock = redis_client.lock(
@@ -645,7 +697,16 @@ def check_for_user_file_project_sync(self: Task, *, tenant_id: str) -> None:
return None
enqueued = 0
skipped_guard = 0
try:
queue_depth = get_user_file_project_sync_queue_depth(self.app)
if queue_depth > USER_FILE_PROJECT_SYNC_MAX_QUEUE_DEPTH:
task_logger.warning(
f"Queue depth {queue_depth} exceeds "
f"{USER_FILE_PROJECT_SYNC_MAX_QUEUE_DEPTH}, skipping enqueue for tenant={tenant_id}"
)
return None
with get_session_with_current_tenant() as db_session:
user_file_ids = (
db_session.execute(
@@ -661,19 +722,23 @@ def check_for_user_file_project_sync(self: Task, *, tenant_id: str) -> None:
)
for user_file_id in user_file_ids:
self.app.send_task(
OnyxCeleryTask.PROCESS_SINGLE_USER_FILE_PROJECT_SYNC,
kwargs={"user_file_id": str(user_file_id), "tenant_id": tenant_id},
queue=OnyxCeleryQueues.USER_FILE_PROJECT_SYNC,
if not enqueue_user_file_project_sync_task(
celery_app=self.app,
redis_client=redis_client,
user_file_id=user_file_id,
tenant_id=tenant_id,
priority=OnyxCeleryPriority.HIGH,
)
):
skipped_guard += 1
continue
enqueued += 1
finally:
if lock.owned():
lock.release()
task_logger.info(
f"check_for_user_file_project_sync - Enqueued {enqueued} tasks for tenant={tenant_id}"
f"Enqueued {enqueued} "
f"Skipped guard {skipped_guard} tasks for tenant={tenant_id}"
)
return None
@@ -692,6 +757,8 @@ def process_single_user_file_project_sync(
)
redis_client = get_redis_client(tenant_id=tenant_id)
redis_client.delete(_user_file_project_sync_queued_key(user_file_id))
file_lock: RedisLock = redis_client.lock(
_user_file_project_sync_lock_key(user_file_id),
timeout=CELERY_USER_FILE_PROJECT_SYNC_LOCK_TIMEOUT,

View File

@@ -30,6 +30,7 @@ from onyx.configs.constants import DocumentSource
from onyx.configs.constants import MessageType
from onyx.context.search.models import SearchDoc
from onyx.context.search.models import SearchDocsResponse
from onyx.db.engine.sql_engine import get_session_with_current_tenant
from onyx.db.memory import add_memory
from onyx.db.memory import update_memory_at_index
from onyx.db.memory import UserMemoryContext
@@ -656,7 +657,12 @@ def run_llm_loop(
fallback_extraction_attempted: bool = False
citation_mapping: dict[int, str] = {} # Maps citation_num -> document_id/URL
default_base_system_prompt: str = get_default_base_system_prompt(db_session)
# Fetch this in a short-lived session so the long-running stream loop does
# not pin a connection just to keep read state alive.
with get_session_with_current_tenant() as prompt_db_session:
default_base_system_prompt: str = get_default_base_system_prompt(
prompt_db_session
)
system_prompt = None
custom_agent_prompt_msg = None

View File

@@ -856,6 +856,11 @@ def handle_stream_message_objects(
reserved_tokens=reserved_token_count,
)
# Release any read transaction before entering the long-running LLM stream.
# Without this, the request-scoped session can keep a connection checked out
# for the full stream duration.
db_session.commit()
# The stream generator can resume on a different worker thread after early yields.
# Set this right before launching the LLM loop so run_in_background copies the right context.
if new_msg_req.mock_llm_response is not None:

View File

@@ -167,6 +167,14 @@ CELERY_USER_FILE_PROCESSING_TASK_EXPIRES = 60 # 1 minute (in seconds)
# beat generator stops adding more. Prevents unbounded queue growth when workers
# fall behind.
USER_FILE_PROCESSING_MAX_QUEUE_DEPTH = 500
# How long a queued user-file-project-sync task remains valid.
# Should be short enough to discard stale queue entries under load while still
# allowing workers enough time to pick up new tasks.
CELERY_USER_FILE_PROJECT_SYNC_TASK_EXPIRES = 60 # 1 minute (in seconds)
# Max queue depth before user-file-project-sync producers stop enqueuing.
# This applies backpressure when workers are falling behind.
USER_FILE_PROJECT_SYNC_MAX_QUEUE_DEPTH = 500
CELERY_USER_FILE_PROJECT_SYNC_LOCK_TIMEOUT = 5 * 60 # 5 minutes (in seconds)
@@ -459,6 +467,7 @@ class OnyxRedisLocks:
USER_FILE_QUEUED_PREFIX = "da_lock:user_file_queued"
USER_FILE_PROJECT_SYNC_BEAT_LOCK = "da_lock:check_user_file_project_sync_beat"
USER_FILE_PROJECT_SYNC_LOCK_PREFIX = "da_lock:user_file_project_sync"
USER_FILE_PROJECT_SYNC_QUEUED_PREFIX = "da_lock:user_file_project_sync_queued"
USER_FILE_DELETE_BEAT_LOCK = "da_lock:check_user_file_delete_beat"
USER_FILE_DELETE_LOCK_PREFIX = "da_lock:user_file_delete"

View File

@@ -6,6 +6,7 @@ from typing import cast
from pydantic import BaseModel
from pydantic import Field
from pydantic import field_validator
from pydantic import model_validator
from onyx.access.models import ExternalAccess
@@ -167,6 +168,14 @@ class DocumentBase(BaseModel):
# list of strings.
metadata: dict[str, str | list[str]]
@field_validator("metadata", mode="before")
@classmethod
def _coerce_metadata_values(cls, v: dict[str, Any]) -> dict[str, str | list[str]]:
return {
key: [str(item) for item in val] if isinstance(val, list) else str(val)
for key, val in v.items()
}
# UTC time
doc_updated_at: datetime | None = None
chunk_count: int | None = None

View File

@@ -1592,6 +1592,7 @@ class SharepointConnector(
if certificate_data is None:
raise RuntimeError("Failed to load certificate")
logger.info(f"Creating MSAL app with authority url {authority_url}")
self.msal_app = msal.ConfidentialClientApplication(
authority=authority_url,
client_id=sp_client_id,

View File

@@ -59,12 +59,11 @@ def _build_index_filters(
base_filters = user_provided_filters or BaseFilters()
if (
user_provided_filters
and user_provided_filters.document_set is None
and persona_document_sets is not None
):
base_filters.document_set = persona_document_sets
document_set_filter = (
base_filters.document_set
if base_filters.document_set is not None
else persona_document_sets
)
time_filter = base_filters.time_cutoff or persona_time_cutoff
source_filter = base_filters.source_type
@@ -120,7 +119,7 @@ def _build_index_filters(
user_file_ids=user_file_ids,
project_id=project_id,
source_type=source_filter,
document_set=persona_document_sets,
document_set=document_set_filter,
time_cutoff=time_filter,
tags=base_filters.tags,
access_control_list=user_acl_filters,

View File

@@ -1,11 +1,102 @@
from sqlalchemy import text
from onyx.db.engine.sql_engine import get_session_with_shared_schema
from onyx.db.engine.sql_engine import SqlEngine
from shared_configs.configs import MULTI_TENANT
from shared_configs.configs import POSTGRES_DEFAULT_SCHEMA
from shared_configs.configs import TENANT_ID_PREFIX
def get_schemas_needing_migration(
tenant_schemas: list[str], head_rev: str
) -> list[str]:
"""Return only schemas whose current alembic version is not at head.
Uses a server-side PL/pgSQL loop to collect each schema's alembic version
into a temp table one at a time. This avoids building a massive UNION ALL
query (which locks the DB and times out at 17k+ schemas) and instead
acquires locks sequentially, one schema per iteration.
"""
if not tenant_schemas:
return []
engine = SqlEngine.get_engine()
with engine.connect() as conn:
# Populate a temp input table with exactly the schemas we care about.
# The DO block reads from this table so it only iterates the requested
# schemas instead of every tenant_% schema in the database.
conn.execute(text("DROP TABLE IF EXISTS _alembic_version_snapshot"))
conn.execute(text("DROP TABLE IF EXISTS _tenant_schemas_input"))
conn.execute(text("CREATE TEMP TABLE _tenant_schemas_input (schema_name text)"))
conn.execute(
text(
"INSERT INTO _tenant_schemas_input (schema_name) "
"SELECT unnest(CAST(:schemas AS text[]))"
),
{"schemas": tenant_schemas},
)
conn.execute(
text(
"CREATE TEMP TABLE _alembic_version_snapshot "
"(schema_name text, version_num text)"
)
)
conn.execute(
text(
"""
DO $$
DECLARE
s text;
schemas text[];
BEGIN
SELECT array_agg(schema_name) INTO schemas
FROM _tenant_schemas_input;
IF schemas IS NULL THEN
RAISE NOTICE 'No tenant schemas found.';
RETURN;
END IF;
FOREACH s IN ARRAY schemas LOOP
BEGIN
EXECUTE format(
'INSERT INTO _alembic_version_snapshot
SELECT %L, version_num FROM %I.alembic_version',
s, s
);
EXCEPTION
-- undefined_table: schema exists but has no alembic_version
-- table yet (new tenant, not yet migrated).
-- invalid_schema_name: tenant is registered but its
-- PostgreSQL schema does not exist yet (e.g. provisioning
-- incomplete). Both cases mean no version is available and
-- the schema will be included in the migration list.
WHEN undefined_table THEN NULL;
WHEN invalid_schema_name THEN NULL;
END;
END LOOP;
END;
$$
"""
)
)
rows = conn.execute(
text("SELECT schema_name, version_num FROM _alembic_version_snapshot")
)
version_by_schema = {row[0]: row[1] for row in rows}
conn.execute(text("DROP TABLE IF EXISTS _alembic_version_snapshot"))
conn.execute(text("DROP TABLE IF EXISTS _tenant_schemas_input"))
# Schemas missing from the snapshot have no alembic_version table yet and
# also need migration. version_by_schema.get(s) returns None for those,
# and None != head_rev, so they are included automatically.
return [s for s in tenant_schemas if version_by_schema.get(s) != head_rev]
def get_all_tenant_ids() -> list[str]:
"""Returning [None] means the only tenant is the 'public' or self hosted tenant."""

View File

@@ -554,10 +554,9 @@ class VespaDocumentIndex(DocumentIndex):
num_to_retrieve: int,
) -> list[InferenceChunk]:
vespa_where_clauses = build_vespa_filters(filters)
# Needs to be at least as much as the rerank-count value set in the
# Vespa schema config. Otherwise we would be getting fewer results than
# expected for reranking.
target_hits = max(10 * num_to_retrieve, RERANK_COUNT)
# Avoid over-fetching a very large candidate set for global-phase reranking.
# Keep enough headroom for quality while capping cost on larger indices.
target_hits = min(max(4 * num_to_retrieve, 100), RERANK_COUNT)
yql = (
YQL_BASE.format(index_name=self._index_name)

View File

@@ -68,6 +68,7 @@ from onyx.server.features.build.db.sandbox import create_sandbox__no_commit
from onyx.server.features.build.db.sandbox import get_running_sandbox_count_by_tenant
from onyx.server.features.build.db.sandbox import get_sandbox_by_session_id
from onyx.server.features.build.db.sandbox import get_sandbox_by_user_id
from onyx.server.features.build.db.sandbox import get_snapshots_for_session
from onyx.server.features.build.db.sandbox import update_sandbox_heartbeat
from onyx.server.features.build.db.sandbox import update_sandbox_status__no_commit
from onyx.server.features.build.sandbox import get_sandbox_manager
@@ -1035,6 +1036,23 @@ class SessionManager:
# workspace cleanup fails (e.g., if pod is already terminated)
logger.warning(f"Failed to cleanup session workspace {session_id}: {e}")
# Delete snapshot files from S3 before removing DB records
snapshots = get_snapshots_for_session(self._db_session, session_id)
if snapshots:
from onyx.file_store.file_store import get_default_file_store
from onyx.server.features.build.sandbox.manager.snapshot_manager import (
SnapshotManager,
)
snapshot_manager = SnapshotManager(get_default_file_store())
for snapshot in snapshots:
try:
snapshot_manager.delete_snapshot(snapshot.storage_path)
except Exception as e:
logger.warning(
f"Failed to delete snapshot file {snapshot.storage_path}: {e}"
)
# Delete session (uses flush, caller commits)
return delete_build_session__no_commit(session_id, user_id, self._db_session)

View File

@@ -12,11 +12,18 @@ from pydantic import BaseModel
from sqlalchemy.orm import Session
from onyx.auth.users import current_user
from onyx.background.celery.tasks.user_file_processing.tasks import (
enqueue_user_file_project_sync_task,
)
from onyx.background.celery.tasks.user_file_processing.tasks import (
get_user_file_project_sync_queue_depth,
)
from onyx.background.celery.versioned_apps.client import app as client_app
from onyx.configs.constants import OnyxCeleryPriority
from onyx.configs.constants import OnyxCeleryQueues
from onyx.configs.constants import OnyxCeleryTask
from onyx.configs.constants import PUBLIC_API_TAGS
from onyx.configs.constants import USER_FILE_PROJECT_SYNC_MAX_QUEUE_DEPTH
from onyx.db.engine.sql_engine import get_session
from onyx.db.enums import UserFileStatus
from onyx.db.models import ChatSession
@@ -27,6 +34,7 @@ from onyx.db.models import UserProject
from onyx.db.persona import get_personas_by_ids
from onyx.db.projects import get_project_token_count
from onyx.db.projects import upload_files_to_user_files_with_indexing
from onyx.redis.redis_pool import get_redis_client
from onyx.server.features.projects.models import CategorizedFilesSnapshot
from onyx.server.features.projects.models import ChatSessionRequest
from onyx.server.features.projects.models import TokenCountResponse
@@ -47,6 +55,33 @@ class UserFileDeleteResult(BaseModel):
assistant_names: list[str] = []
def _trigger_user_file_project_sync(user_file_id: UUID, tenant_id: str) -> None:
queue_depth = get_user_file_project_sync_queue_depth(client_app)
if queue_depth > USER_FILE_PROJECT_SYNC_MAX_QUEUE_DEPTH:
logger.warning(
f"Skipping immediate project sync for user_file_id={user_file_id} due to "
f"queue depth {queue_depth}>{USER_FILE_PROJECT_SYNC_MAX_QUEUE_DEPTH}. "
"It will be picked up by beat later."
)
return
redis_client = get_redis_client(tenant_id=tenant_id)
enqueued = enqueue_user_file_project_sync_task(
celery_app=client_app,
redis_client=redis_client,
user_file_id=user_file_id,
tenant_id=tenant_id,
priority=OnyxCeleryPriority.HIGHEST,
)
if not enqueued:
logger.info(
f"Skipped duplicate project sync enqueue for user_file_id={user_file_id}"
)
return
logger.info(f"Triggered project sync for user_file_id={user_file_id}")
@router.get("", tags=PUBLIC_API_TAGS)
def get_projects(
user: User = Depends(current_user),
@@ -189,15 +224,7 @@ def unlink_user_file_from_project(
db_session.commit()
tenant_id = get_current_tenant_id()
task = client_app.send_task(
OnyxCeleryTask.PROCESS_SINGLE_USER_FILE_PROJECT_SYNC,
kwargs={"user_file_id": user_file.id, "tenant_id": tenant_id},
queue=OnyxCeleryQueues.USER_FILE_PROJECT_SYNC,
priority=OnyxCeleryPriority.HIGHEST,
)
logger.info(
f"Triggered project sync for user_file_id={user_file.id} with task_id={task.id}"
)
_trigger_user_file_project_sync(user_file.id, tenant_id)
return Response(status_code=204)
@@ -241,15 +268,7 @@ def link_user_file_to_project(
db_session.commit()
tenant_id = get_current_tenant_id()
task = client_app.send_task(
OnyxCeleryTask.PROCESS_SINGLE_USER_FILE_PROJECT_SYNC,
kwargs={"user_file_id": user_file.id, "tenant_id": tenant_id},
queue=OnyxCeleryQueues.USER_FILE_PROJECT_SYNC,
priority=OnyxCeleryPriority.HIGHEST,
)
logger.info(
f"Triggered project sync for user_file_id={user_file.id} with task_id={task.id}"
)
_trigger_user_file_project_sync(user_file.id, tenant_id)
return UserFileSnapshot.from_model(user_file)

View File

@@ -587,6 +587,7 @@ def handle_send_chat_message(
request.headers
),
mcp_headers=chat_message_req.mcp_headers,
additional_context=chat_message_req.additional_context,
external_state_container=state_container,
)
result = gather_stream_full(packets, state_container)
@@ -609,6 +610,7 @@ def handle_send_chat_message(
request.headers
),
mcp_headers=chat_message_req.mcp_headers,
additional_context=chat_message_req.additional_context,
external_state_container=state_container,
):
yield get_json_line(obj.model_dump())

View File

@@ -125,6 +125,11 @@ class SendMessageRequest(BaseModel):
# - No CitationInfo packets are emitted during streaming
include_citations: bool = True
# Additional context injected into the LLM call but NOT stored in the DB
# (not shown in chat history). Used e.g. by the Chrome extension to pass
# the current tab URL when "Read this tab" is enabled.
additional_context: str | None = None
@model_validator(mode="after")
def check_chat_session_id_or_info(self) -> "SendMessageRequest":
# If neither is provided, default to creating a new chat session using the

View File

@@ -1,5 +1,8 @@
import json
from collections.abc import Generator
from typing import Literal
from typing import TypedDict
from typing import Union
import requests
from pydantic import BaseModel
@@ -36,6 +39,39 @@ class ExecuteResponse(BaseModel):
files: list[WorkspaceFile]
class StreamOutputEvent(BaseModel):
"""SSE 'output' event: a chunk of stdout or stderr"""
stream: Literal["stdout", "stderr"]
data: str
class StreamResultEvent(BaseModel):
"""SSE 'result' event: final execution result"""
exit_code: int | None
timed_out: bool
duration_ms: int
files: list[WorkspaceFile]
class StreamErrorEvent(BaseModel):
"""SSE 'error' event: execution-level error"""
message: str
StreamEvent = Union[StreamOutputEvent, StreamResultEvent, StreamErrorEvent]
_SSE_EVENT_MAP: dict[
str, type[StreamOutputEvent | StreamResultEvent | StreamErrorEvent]
] = {
"output": StreamOutputEvent,
"result": StreamResultEvent,
"error": StreamErrorEvent,
}
class CodeInterpreterClient:
"""Client for Code Interpreter service"""
@@ -45,6 +81,23 @@ class CodeInterpreterClient:
self.base_url = base_url.rstrip("/")
self.session = requests.Session()
def _build_payload(
self,
code: str,
stdin: str | None,
timeout_ms: int,
files: list[FileInput] | None,
) -> dict:
payload: dict = {
"code": code,
"timeout_ms": timeout_ms,
}
if stdin is not None:
payload["stdin"] = stdin
if files:
payload["files"] = files
return payload
def execute(
self,
code: str,
@@ -52,25 +105,110 @@ class CodeInterpreterClient:
timeout_ms: int = 30000,
files: list[FileInput] | None = None,
) -> ExecuteResponse:
"""Execute Python code"""
"""Execute Python code (batch)"""
url = f"{self.base_url}/v1/execute"
payload = {
"code": code,
"timeout_ms": timeout_ms,
}
if stdin is not None:
payload["stdin"] = stdin
if files:
payload["files"] = files
payload = self._build_payload(code, stdin, timeout_ms, files)
response = self.session.post(url, json=payload, timeout=timeout_ms / 1000 + 10)
response.raise_for_status()
return ExecuteResponse(**response.json())
def execute_streaming(
self,
code: str,
stdin: str | None = None,
timeout_ms: int = 30000,
files: list[FileInput] | None = None,
) -> Generator[StreamEvent, None, None]:
"""Execute Python code with streaming SSE output.
Yields StreamEvent objects (StreamOutputEvent, StreamResultEvent,
StreamErrorEvent) as execution progresses. Falls back to batch
execution if the streaming endpoint is not available (older
code-interpreter versions).
"""
url = f"{self.base_url}/v1/execute/stream"
payload = self._build_payload(code, stdin, timeout_ms, files)
response = self.session.post(
url,
json=payload,
stream=True,
timeout=timeout_ms / 1000 + 10,
)
if response.status_code == 404:
logger.info(
"Streaming endpoint not available, " "falling back to batch execution"
)
response.close()
yield from self._batch_as_stream(code, stdin, timeout_ms, files)
return
response.raise_for_status()
yield from self._parse_sse(response)
def _parse_sse(
self, response: requests.Response
) -> Generator[StreamEvent, None, None]:
"""Parse SSE streaming response into StreamEvent objects.
Expected format per event:
event: <type>
data: <json>
<blank line>
"""
event_type: str | None = None
data_lines: list[str] = []
for line in response.iter_lines(decode_unicode=True):
if line is None:
continue
if line == "":
# Blank line marks end of an SSE event
if event_type is not None and data_lines:
data = "\n".join(data_lines)
model_cls = _SSE_EVENT_MAP.get(event_type)
if model_cls is not None:
yield model_cls(**json.loads(data))
else:
logger.warning(f"Unknown SSE event type: {event_type}")
event_type = None
data_lines = []
elif line.startswith("event:"):
event_type = line[len("event:") :].strip()
elif line.startswith("data:"):
data_lines.append(line[len("data:") :].strip())
if event_type is not None or data_lines:
logger.warning(
f"SSE stream ended with incomplete event: "
f"event_type={event_type}, data_lines={data_lines}"
)
def _batch_as_stream(
self,
code: str,
stdin: str | None,
timeout_ms: int,
files: list[FileInput] | None,
) -> Generator[StreamEvent, None, None]:
"""Execute via batch endpoint and yield results as stream events."""
result = self.execute(code, stdin, timeout_ms, files)
if result.stdout:
yield StreamOutputEvent(stream="stdout", data=result.stdout)
if result.stderr:
yield StreamOutputEvent(stream="stderr", data=result.stderr)
yield StreamResultEvent(
exit_code=result.exit_code,
timed_out=result.timed_out,
duration_ms=result.duration_ms,
files=result.files,
)
def upload_file(self, file_content: bytes, filename: str) -> str:
"""Upload file to Code Interpreter and return file_id"""
url = f"{self.base_url}/v1/files"

View File

@@ -28,6 +28,15 @@ from onyx.tools.tool_implementations.python.code_interpreter_client import (
CodeInterpreterClient,
)
from onyx.tools.tool_implementations.python.code_interpreter_client import FileInput
from onyx.tools.tool_implementations.python.code_interpreter_client import (
StreamErrorEvent,
)
from onyx.tools.tool_implementations.python.code_interpreter_client import (
StreamOutputEvent,
)
from onyx.tools.tool_implementations.python.code_interpreter_client import (
StreamResultEvent,
)
from onyx.utils.logger import setup_logger
@@ -181,19 +190,50 @@ class PythonTool(Tool[PythonToolOverrideKwargs]):
try:
logger.debug(f"Executing code: {code}")
# Execute code with timeout
response = client.execute(
# Execute code with streaming (falls back to batch if unavailable)
stdout_parts: list[str] = []
stderr_parts: list[str] = []
result_event: StreamResultEvent | None = None
for event in client.execute_streaming(
code=code,
timeout_ms=CODE_INTERPRETER_DEFAULT_TIMEOUT_MS,
files=files_to_stage or None,
)
):
if isinstance(event, StreamOutputEvent):
if event.stream == "stdout":
stdout_parts.append(event.data)
else:
stderr_parts.append(event.data)
# Emit incremental delta to frontend
self.emitter.emit(
Packet(
placement=placement,
obj=PythonToolDelta(
stdout=event.data if event.stream == "stdout" else "",
stderr=event.data if event.stream == "stderr" else "",
),
)
)
elif isinstance(event, StreamResultEvent):
result_event = event
elif isinstance(event, StreamErrorEvent):
raise RuntimeError(f"Code interpreter error: {event.message}")
if result_event is None:
raise RuntimeError(
"Code interpreter stream ended without a result event"
)
full_stdout = "".join(stdout_parts)
full_stderr = "".join(stderr_parts)
# Truncate output for LLM consumption
truncated_stdout = _truncate_output(
response.stdout, CODE_INTERPRETER_MAX_OUTPUT_LENGTH, "stdout"
full_stdout, CODE_INTERPRETER_MAX_OUTPUT_LENGTH, "stdout"
)
truncated_stderr = _truncate_output(
response.stderr, CODE_INTERPRETER_MAX_OUTPUT_LENGTH, "stderr"
full_stderr, CODE_INTERPRETER_MAX_OUTPUT_LENGTH, "stderr"
)
# Handle generated files
@@ -202,7 +242,7 @@ class PythonTool(Tool[PythonToolOverrideKwargs]):
file_ids_to_cleanup: list[str] = []
file_store = get_default_file_store()
for workspace_file in response.files:
for workspace_file in result_event.files:
if workspace_file.kind != "file" or not workspace_file.file_id:
continue
@@ -258,26 +298,23 @@ class PythonTool(Tool[PythonToolOverrideKwargs]):
f"Failed to delete Code Interpreter staged file {file_mapping['file_id']}: {e}"
)
# Emit delta with stdout/stderr and generated files
self.emitter.emit(
Packet(
placement=placement,
obj=PythonToolDelta(
stdout=truncated_stdout,
stderr=truncated_stderr,
file_ids=generated_file_ids,
),
# Emit file_ids once files are processed
if generated_file_ids:
self.emitter.emit(
Packet(
placement=placement,
obj=PythonToolDelta(file_ids=generated_file_ids),
)
)
)
# Build result
result = LlmPythonExecutionResult(
stdout=truncated_stdout,
stderr=truncated_stderr,
exit_code=response.exit_code,
timed_out=response.timed_out,
exit_code=result_event.exit_code,
timed_out=result_event.timed_out,
generated_files=generated_files,
error=None if response.exit_code == 0 else truncated_stderr,
error=None if result_event.exit_code == 0 else truncated_stderr,
)
# Serialize result for LLM

View File

@@ -6,6 +6,8 @@ aioboto3==15.1.0
# via onyx
aiobotocore==2.24.0
# via aioboto3
aiofile==3.9.0
# via py-key-value-aio
aiofiles==25.1.0
# via
# aioboto3
@@ -40,8 +42,10 @@ anyio==4.11.0
# httpx
# mcp
# openai
# py-key-value-aio
# sse-starlette
# starlette
# watchfiles
argon2-cffi==23.1.0
# via pwdlib
argon2-cffi-bindings==25.1.0
@@ -74,9 +78,7 @@ backports-tarfile==1.2.0 ; python_full_version < '3.12'
bcrypt==4.3.0
# via pwdlib
beartype==0.22.6
# via
# py-key-value-aio
# py-key-value-shared
# via py-key-value-aio
beautifulsoup4==4.12.3
# via
# atlassian-python-api
@@ -110,6 +112,8 @@ cachetools==6.2.2
# via
# google-auth
# py-key-value-aio
caio==0.9.25
# via aiofile
celery==5.5.1
# via onyx
certifi==2025.11.12
@@ -170,7 +174,6 @@ cloudpickle==3.1.2
# via
# dask
# distributed
# pydocket
cobble==0.1.4
# via mammoth
cohere==5.6.1
@@ -218,8 +221,6 @@ deprecated==1.3.1
# pygithub
discord-py==2.4.0
# via onyx
diskcache==5.6.3
# via py-key-value-aio
distributed==2026.1.1
# via onyx
distro==1.9.0
@@ -256,8 +257,6 @@ exceptiongroup==1.3.0
# via
# braintrust
# fastmcp
fakeredis==2.33.0
# via pydocket
fastapi==0.128.0
# via
# fastapi-limiter
@@ -273,7 +272,7 @@ fastapi-users-db-sqlalchemy==7.0.0
# via onyx
fastavro==1.12.1
# via cohere
fastmcp==2.14.2
fastmcp==3.0.2
# via onyx
fastuuid==0.14.0
# via litellm
@@ -478,7 +477,9 @@ jsonpatch==1.33
jsonpointer==3.0.0
# via jsonpatch
jsonref==1.1.0
# via onyx
# via
# fastmcp
# onyx
jsonschema==4.25.1
# via
# litellm
@@ -513,8 +514,6 @@ locket==1.0.0
# via
# distributed
# partd
lupa==2.6
# via fakeredis
lxml==5.3.0
# via
# htmldate
@@ -556,7 +555,7 @@ marshmallow==3.26.2
# via dataclasses-json
matrix-client==0.3.2
# via zulip
mcp==1.25.0
mcp==1.26.0
# via
# claude-agent-sdk
# fastmcp
@@ -613,7 +612,7 @@ oauthlib==3.2.2
# kubernetes
# onyx
# requests-oauthlib
office365-rest-python-client==2.5.9
office365-rest-python-client==2.6.2
# via onyx
olefile==0.47
# via
@@ -642,22 +641,16 @@ opensearch-py==3.0.0
opentelemetry-api==1.39.1
# via
# ddtrace
# fastmcp
# langfuse
# openinference-instrumentation
# opentelemetry-exporter-otlp-proto-http
# opentelemetry-exporter-prometheus
# opentelemetry-instrumentation
# opentelemetry-sdk
# opentelemetry-semantic-conventions
# pydocket
opentelemetry-exporter-otlp-proto-common==1.39.1
# via opentelemetry-exporter-otlp-proto-http
opentelemetry-exporter-otlp-proto-http==1.39.1
# via langfuse
opentelemetry-exporter-prometheus==0.60b1
# via pydocket
opentelemetry-instrumentation==0.60b1
# via pydocket
opentelemetry-proto==1.39.1
# via
# onyx
@@ -668,17 +661,15 @@ opentelemetry-sdk==1.39.1
# langfuse
# openinference-instrumentation
# opentelemetry-exporter-otlp-proto-http
# opentelemetry-exporter-prometheus
opentelemetry-semantic-conventions==0.60b1
# via
# opentelemetry-instrumentation
# opentelemetry-sdk
# via opentelemetry-sdk
orjson==3.11.4 ; platform_python_implementation != 'PyPy'
# via langsmith
packaging==24.2
# via
# dask
# distributed
# fastmcp
# google-cloud-aiplatform
# google-cloud-bigquery
# huggingface-hub
@@ -689,7 +680,6 @@ packaging==24.2
# langsmith
# marshmallow
# onnxruntime
# opentelemetry-instrumentation
# pytest
# pywikibot
pandas==2.3.3
@@ -702,8 +692,6 @@ passlib==1.7.4
# via onyx
pathable==0.4.4
# via jsonschema-path
pathvalidate==3.3.1
# via py-key-value-aio
pdfminer-six==20251107
# via markitdown
pillow==12.1.1
@@ -723,9 +711,7 @@ ply==3.11
prometheus-client==0.23.1
# via
# onyx
# opentelemetry-exporter-prometheus
# prometheus-fastapi-instrumentator
# pydocket
prometheus-fastapi-instrumentator==7.1.0
# via onyx
prompt-toolkit==3.0.52
@@ -764,12 +750,8 @@ pwdlib==0.3.0
# via fastapi-users
py==1.11.0
# via retry
py-key-value-aio==0.3.0
# via
# fastmcp
# pydocket
py-key-value-shared==0.3.0
# via py-key-value-aio
py-key-value-aio==0.4.4
# via fastmcp
pyairtable==3.0.1
# via onyx
pyasn1==0.6.2
@@ -806,8 +788,6 @@ pydantic-core==2.33.2
# via pydantic
pydantic-settings==2.12.0
# via mcp
pydocket==0.16.3
# via fastmcp
pyee==13.0.0
# via playwright
pygithub==2.5.0
@@ -879,8 +859,6 @@ python-http-client==3.3.7
# via sendgrid
python-iso639==2025.11.16
# via unstructured
python-json-logger==4.0.0
# via pydocket
python-magic==0.4.27
# via unstructured
python-multipart==0.0.22
@@ -918,6 +896,7 @@ pyyaml==6.0.3
# via
# dask
# distributed
# fastmcp
# huggingface-hub
# jsonschema-path
# kubernetes
@@ -928,11 +907,8 @@ rapidfuzz==3.13.0
# unstructured
redis==5.0.8
# via
# fakeredis
# fastapi-limiter
# onyx
# py-key-value-aio
# pydocket
referencing==0.36.2
# via
# jsonschema
@@ -1007,7 +983,6 @@ rich==14.2.0
# via
# cyclopts
# fastmcp
# pydocket
# rich-rst
# typer
rich-rst==1.3.2
@@ -1056,9 +1031,7 @@ sniffio==1.3.1
# anyio
# openai
sortedcontainers==2.4.0
# via
# distributed
# fakeredis
# via distributed
soupsieve==2.8
# via beautifulsoup4
sqlalchemy==2.0.15
@@ -1124,9 +1097,7 @@ tqdm==4.67.1
trafilatura==1.12.2
# via onyx
typer==0.20.0
# via
# mcp
# pydocket
# via mcp
types-awscrt==0.28.4
# via botocore-stubs
types-openpyxl==3.0.4.7
@@ -1162,11 +1133,10 @@ typing-extensions==4.15.0
# opentelemetry-exporter-otlp-proto-http
# opentelemetry-sdk
# opentelemetry-semantic-conventions
# py-key-value-shared
# py-key-value-aio
# pyairtable
# pydantic
# pydantic-core
# pydocket
# pyee
# pygithub
# python-docx
@@ -1234,6 +1204,8 @@ vine==5.1.0
# kombu
voyageai==0.2.3
# via onyx
watchfiles==1.1.1
# via fastmcp
wcwidth==0.2.14
# via prompt-toolkit
webencodings==0.5.1
@@ -1254,7 +1226,6 @@ wrapt==1.17.3
# deprecated
# langfuse
# openinference-instrumentation
# opentelemetry-instrumentation
# unstructured
xlrd==2.0.2
# via markitdown

View File

@@ -288,7 +288,7 @@ matplotlib-inline==0.2.1
# via
# ipykernel
# ipython
mcp==1.25.0
mcp==1.26.0
# via claude-agent-sdk
multidict==6.7.0
# via

View File

@@ -211,7 +211,7 @@ litellm==1.81.6
# via onyx
markupsafe==3.0.3
# via jinja2
mcp==1.25.0
mcp==1.26.0
# via claude-agent-sdk
monotonic==1.6
# via posthog

View File

@@ -246,7 +246,7 @@ litellm==1.81.6
# via onyx
markupsafe==3.0.3
# via jinja2
mcp==1.25.0
mcp==1.26.0
# via claude-agent-sdk
mpmath==1.3.0
# via sympy

View File

@@ -9,6 +9,7 @@ from collections.abc import AsyncGenerator
from collections.abc import Generator
from contextlib import asynccontextmanager
from unittest.mock import MagicMock
from unittest.mock import patch
import pytest
from dotenv import load_dotenv
@@ -46,11 +47,15 @@ def mock_current_admin_user() -> MagicMock:
@pytest.fixture(scope="function")
def client() -> Generator[TestClient, None, None]:
# Initialize TestClient with the FastAPI app using a no-op test lifespan
# Initialize TestClient with the FastAPI app using a no-op test lifespan.
# Patch out prometheus metrics setup to avoid "Duplicated timeseries in
# CollectorRegistry" errors when multiple tests each create a new app
# (prometheus registers metrics globally and rejects duplicate names).
get_app = fetch_versioned_implementation(
module="onyx.main", attribute="get_application"
)
app: FastAPI = get_app(lifespan_override=test_lifespan)
with patch("onyx.main.setup_prometheus_metrics"):
app: FastAPI = get_app(lifespan_override=test_lifespan)
# Override the database session dependency with a mock
# (these tests don't actually need DB access)

View File

@@ -990,6 +990,27 @@ class _MockCIHandler(BaseHTTPRequestHandler):
self._respond_json(
200, {"file_id": f"mock-ci-file-{self.server._file_counter}"}
)
elif self.path == "/v1/execute/stream":
if self.server.streaming_enabled:
self._respond_sse(
[
(
"output",
{"stream": "stdout", "data": "mock output\n"},
),
(
"result",
{
"exit_code": 0,
"timed_out": False,
"duration_ms": 50,
"files": [],
},
),
]
)
else:
self._respond_json(404, {"error": "not found"})
elif self.path == "/v1/execute":
self._respond_json(
200,
@@ -1027,6 +1048,17 @@ class _MockCIHandler(BaseHTTPRequestHandler):
self.end_headers()
self.wfile.write(payload)
def _respond_sse(self, events: list[tuple[str, dict[str, Any]]]) -> None:
frames = []
for event_type, data in events:
frames.append(f"event: {event_type}\ndata: {json.dumps(data)}\n\n")
payload = "".join(frames).encode()
self.send_response(200)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Content-Length", str(len(payload)))
self.end_headers()
self.wfile.write(payload)
def log_message(self, format: str, *args: Any) -> None: # noqa: A002
pass
@@ -1038,6 +1070,7 @@ class MockCodeInterpreterServer(HTTPServer):
super().__init__(("localhost", 0), _MockCIHandler)
self.captured_requests: list[CapturedRequest] = []
self._file_counter = 0
self.streaming_enabled: bool = True
@property
def url(self) -> str:
@@ -1168,17 +1201,19 @@ def test_code_interpreter_receives_chat_files(
finally:
ci_mod.CodeInterpreterClient.__init__.__defaults__ = original_defaults
# Verify: file uploaded, code executed, staged file cleaned up
# Verify: file uploaded, code executed via streaming, staged file cleaned up
assert len(mock_ci_server.get_requests(method="POST", path="/v1/files")) == 1
assert len(mock_ci_server.get_requests(method="POST", path="/v1/execute")) == 1
assert (
len(mock_ci_server.get_requests(method="POST", path="/v1/execute/stream")) == 1
)
delete_requests = mock_ci_server.get_requests(method="DELETE")
assert len(delete_requests) == 1
assert delete_requests[0].path.startswith("/v1/files/")
execute_body = mock_ci_server.get_requests(method="POST", path="/v1/execute")[
0
].json_body()
execute_body = mock_ci_server.get_requests(
method="POST", path="/v1/execute/stream"
)[0].json_body()
assert execute_body["code"] == code
assert len(execute_body["files"]) == 1
assert execute_body["files"][0]["path"] == "data.csv"
@@ -1284,7 +1319,9 @@ def test_code_interpreter_replay_packets_include_code_and_output(
db_session=db_session,
)
assert len(mock_ci_server.get_requests(method="POST", path="/v1/execute")) == 1
assert (
len(mock_ci_server.get_requests(method="POST", path="/v1/execute/stream")) == 1
)
# The response contains `packets` — a list of packet-lists, one per
# assistant message. We should have exactly one assistant message.
@@ -1313,3 +1350,76 @@ def test_code_interpreter_replay_packets_include_code_and_output(
delta_obj = delta_packets[0].obj
assert isinstance(delta_obj, PythonToolDelta)
assert "mock output" in delta_obj.stdout
def test_code_interpreter_streaming_fallback_to_batch(
db_session: Session,
mock_ci_server: MockCodeInterpreterServer,
_attach_python_tool_to_default_persona: None,
initialize_file_store: None, # noqa: ARG001
) -> None:
"""When the streaming endpoint is not available (older code-interpreter),
execute_streaming should fall back to the batch /v1/execute endpoint."""
mock_ci_server.captured_requests.clear()
mock_ci_server._file_counter = 0
mock_ci_server.streaming_enabled = False
mock_url = mock_ci_server.url
user = create_test_user(db_session, "ci_fallback_test")
chat_session = create_chat_session(db_session=db_session, user=user)
code = 'print("fallback test")'
msg_req = SendMessageRequest(
message="Print fallback test",
chat_session_id=chat_session.id,
stream=True,
)
original_defaults = ci_mod.CodeInterpreterClient.__init__.__defaults__
with (
use_mock_llm() as mock_llm,
patch(
"onyx.tools.tool_implementations.python.python_tool.CODE_INTERPRETER_BASE_URL",
mock_url,
),
patch(
"onyx.tools.tool_implementations.python.code_interpreter_client.CODE_INTERPRETER_BASE_URL",
mock_url,
),
):
mock_llm.add_response(
LLMToolCallResponse(
tool_name="python",
tool_call_id="call_fallback",
tool_call_argument_tokens=[json.dumps({"code": code})],
)
)
mock_llm.forward_till_end()
ci_mod.CodeInterpreterClient.__init__.__defaults__ = (mock_url,)
try:
packets = list(
handle_stream_message_objects(
new_msg_req=msg_req, user=user, db_session=db_session
)
)
finally:
ci_mod.CodeInterpreterClient.__init__.__defaults__ = original_defaults
mock_ci_server.streaming_enabled = True
# Streaming was attempted first (returned 404), then fell back to batch
assert (
len(mock_ci_server.get_requests(method="POST", path="/v1/execute/stream")) == 1
)
assert len(mock_ci_server.get_requests(method="POST", path="/v1/execute")) == 1
# Verify output still made it through
delta_packets = [
p
for p in packets
if isinstance(p, Packet) and isinstance(p.obj, PythonToolDelta)
]
assert len(delta_packets) >= 1
first_delta = delta_packets[0].obj
assert isinstance(first_delta, PythonToolDelta)
assert "mock output" in first_delta.stdout

View File

@@ -5,22 +5,17 @@ from fastapi import FastAPI
from fastapi.responses import PlainTextResponse
from fastmcp import FastMCP
from fastmcp.server.auth import StaticTokenVerifier
from fastmcp.server.server import FunctionTool
def make_many_tools(mcp: FastMCP) -> list[FunctionTool]:
def make_tool(i: int) -> FunctionTool:
def make_many_tools(mcp: FastMCP) -> None:
def make_tool(i: int) -> None:
@mcp.tool(name=f"tool_{i}", description=f"Get secret value {i}")
def tool_name(name: str) -> str: # noqa: ARG001
"""Get secret value."""
return f"Secret value {200 - i}!"
return tool_name
tools = []
for i in range(100):
tools.append(make_tool(i))
return tools
make_tool(i)
if __name__ == "__main__":

View File

@@ -28,7 +28,6 @@ from fastmcp import FastMCP
from fastmcp.server.auth import AccessToken
from fastmcp.server.auth import TokenVerifier
from fastmcp.server.dependencies import get_access_token
from fastmcp.server.server import FunctionTool
# Google's tokeninfo endpoint for validating access tokens
GOOGLE_TOKENINFO_URL = "https://oauth2.googleapis.com/tokeninfo"
@@ -148,24 +147,19 @@ class GoogleOAuthTokenVerifier(TokenVerifier):
await self._http_client.aclose()
def make_tools(mcp: FastMCP) -> list[FunctionTool]:
def make_tools(mcp: FastMCP) -> None:
"""Create test tools for the MCP server."""
tools: list[FunctionTool] = []
@mcp.tool(name="echo", description="Echo back the input message")
def echo(message: str) -> str:
"""Echo the message back to the caller."""
return f"You said: {message}"
tools.append(echo)
@mcp.tool(name="get_secret", description="Get a secret value (requires auth)")
def get_secret(secret_name: str) -> str:
"""Get a secret value. This proves the token was validated."""
return f"Secret value for '{secret_name}': super-secret-value-12345"
tools.append(get_secret)
@mcp.tool(name="whoami", description="Get information about the authenticated user")
async def whoami() -> dict[str, Any]:
"""Get information about the authenticated user from their Google token."""
@@ -182,9 +176,6 @@ def make_tools(mcp: FastMCP) -> list[FunctionTool]:
"access_type": tok.claims.get("access_type"),
}
tools.append(whoami)
# Add some numbered tools for testing tool discovery
for i in range(5):
@mcp.tool(name=f"oauth_tool_{i}", description=f"Test tool number {i}")
@@ -192,10 +183,6 @@ def make_tools(mcp: FastMCP) -> list[FunctionTool]:
"""A numbered test tool."""
return f"Tool {_i} says hello to {name}!"
tools.append(numbered_tool)
return tools
if __name__ == "__main__":
port = int(sys.argv[1] if len(sys.argv) > 1 else "8006")

View File

@@ -2,7 +2,6 @@ import os
import sys
from fastmcp import FastMCP
from fastmcp.server.server import FunctionTool
mcp = FastMCP("My HTTP MCP")
@@ -13,19 +12,15 @@ def hello(name: str) -> str:
return f"Hello, {name}!"
def make_many_tools() -> list[FunctionTool]:
def make_tool(i: int) -> FunctionTool:
def make_many_tools() -> None:
def make_tool(i: int) -> None:
@mcp.tool(name=f"tool_{i}", description=f"Get secret value {i}")
def tool_name(name: str) -> str: # noqa: ARG001
"""Get secret value."""
return f"Secret value {100 - i}!"
return tool_name
tools = []
for i in range(100):
tools.append(make_tool(i))
return tools
make_tool(i)
if __name__ == "__main__":

View File

@@ -15,7 +15,6 @@ from fastapi.responses import Response
from fastmcp import FastMCP
from fastmcp.server.auth.providers.jwt import JWTVerifier
from fastmcp.server.dependencies import get_access_token
from fastmcp.server.server import FunctionTool
from starlette.middleware.base import BaseHTTPMiddleware
# uncomment for debug logs
@@ -37,18 +36,15 @@ Enable authorization code and store the client id and secret.
"""
def make_many_tools(mcp: FastMCP) -> list[FunctionTool]:
def make_tool(i: int) -> FunctionTool:
def make_many_tools(mcp: FastMCP) -> None:
def make_tool(i: int) -> None:
@mcp.tool(name=f"tool_{i}", description=f"Get secret value {i}")
def tool_name(name: str) -> str: # noqa: ARG001
"""Get secret value."""
return f"Secret value {500 - i}!"
return tool_name
tools = []
for i in range(100):
tools.append(make_tool(i))
make_tool(i)
@mcp.tool
async def whoami() -> dict[str, Any]:
@@ -59,9 +55,6 @@ def make_many_tools(mcp: FastMCP) -> list[FunctionTool]:
"claims": tok.claims if tok else {},
}
tools.append(whoami)
return tools
# ---------- FASTAPI APP ----------

View File

@@ -10,7 +10,6 @@ from fastmcp import FastMCP
from fastmcp.server.auth.auth import AccessToken
from fastmcp.server.auth.auth import TokenVerifier
from fastmcp.server.dependencies import get_access_token
from fastmcp.server.server import FunctionTool
# pip install fastmcp bcrypt
@@ -93,19 +92,15 @@ class ApiKeyVerifier(TokenVerifier):
# ---- server -----------------------------------------------------------------
def make_many_tools(mcp: FastMCP) -> list[FunctionTool]:
def make_tool(i: int) -> FunctionTool:
def make_many_tools(mcp: FastMCP) -> None:
def make_tool(i: int) -> None:
@mcp.tool(name=f"tool_{i}", description=f"Get secret value {i}")
def tool_name(name: str) -> str: # noqa: ARG001
"""Get secret value."""
return f"Secret value {400 - i}!"
return tool_name
tools = []
for i in range(100):
tools.append(make_tool(i))
return tools
make_tool(i)
if __name__ == "__main__":

View File

@@ -106,13 +106,13 @@ class TestGuildDataIsolation:
# Create admin user for tenant 1
admin_user1: DATestUser = UserManager.create(
email=f"discord_admin1+{unique}@example.com",
email=f"discord_admin1_{unique}@example.com",
)
assert UserManager.is_role(admin_user1, UserRole.ADMIN)
# Create admin user for tenant 2
admin_user2: DATestUser = UserManager.create(
email=f"discord_admin2+{unique}@example.com",
email=f"discord_admin2_{unique}@example.com",
)
assert UserManager.is_role(admin_user2, UserRole.ADMIN)
@@ -170,10 +170,10 @@ class TestGuildDataIsolation:
# Create admin users for two tenants
admin_user1: DATestUser = UserManager.create(
email=f"discord_list1+{unique}@example.com",
email=f"discord_list1_{unique}@example.com",
)
admin_user2: DATestUser = UserManager.create(
email=f"discord_list2+{unique}@example.com",
email=f"discord_list2_{unique}@example.com",
)
# Create 1 guild in tenant 1
@@ -350,10 +350,10 @@ class TestGuildAccessIsolation:
# Create admin users for two tenants
admin_user1: DATestUser = UserManager.create(
email=f"discord_access1+{unique}@example.com",
email=f"discord_access1_{unique}@example.com",
)
admin_user2: DATestUser = UserManager.create(
email=f"discord_access2+{unique}@example.com",
email=f"discord_access2_{unique}@example.com",
)
# Create a guild in tenant 1

View File

@@ -21,7 +21,7 @@ def test_admin_can_invite_users(reset_multitenant: None) -> None: # noqa: ARG00
# Admin user invites the previously registered and non-registered user
UserManager.invite_user(invited_user.email, admin_user)
UserManager.invite_user(f"{INVITED_BASIC_USER}+{unique}@example.com", admin_user)
UserManager.invite_user(f"{INVITED_BASIC_USER}_{unique}@example.com", admin_user)
# Verify users are in the invited users list
invited_users = UserManager.get_invited_users(admin_user)
@@ -40,7 +40,7 @@ def test_non_registered_user_gets_basic_role(
assert UserManager.is_role(admin_user, UserRole.ADMIN)
# Admin user invites a non-registered user
invited_email = f"{INVITED_BASIC_USER}+{unique}@example.com"
invited_email = f"{INVITED_BASIC_USER}_{unique}@example.com"
UserManager.invite_user(invited_email, admin_user)
# Non-registered user registers
@@ -58,7 +58,7 @@ def test_user_can_accept_invitation(reset_multitenant: None) -> None: # noqa: A
assert UserManager.is_role(admin_user, UserRole.ADMIN)
# Create a user to be invited
invited_user_email = f"invited_user+{unique}@example.com"
invited_user_email = f"invited_user_{unique}@example.com"
# User registers with the same email as the invitation
invited_user: DATestUser = UserManager.create(

View File

@@ -20,13 +20,13 @@ def setup_test_tenants(reset_multitenant: None) -> dict[str, Any]: # noqa: ARG0
unique = uuid4().hex
# Creating an admin user for Tenant 1
admin_user1: DATestUser = UserManager.create(
email=f"admin+{unique}@example.com",
email=f"admin_{unique}@example.com",
)
assert UserManager.is_role(admin_user1, UserRole.ADMIN)
# Create Tenant 2 and its Admin User
admin_user2: DATestUser = UserManager.create(
email=f"admin2+{unique}@example.com",
email=f"admin2_{unique}@example.com",
)
assert UserManager.is_role(admin_user2, UserRole.ADMIN)

View File

@@ -0,0 +1,167 @@
"""
Integration tests for onyx.db.engine.tenant_utils.get_schemas_needing_migration.
These tests require a live database and exercise the function directly,
independent of the alembic migration runner script.
Usage:
pytest tests/integration/multitenant_tests/test_get_schemas_needing_migration.py -v
"""
from __future__ import annotations
import subprocess
import uuid
from collections.abc import Generator
import pytest
from sqlalchemy import text
from sqlalchemy.engine import Engine
from onyx.db.engine.sql_engine import SqlEngine
from onyx.db.engine.tenant_utils import get_schemas_needing_migration
_BACKEND_DIR = __file__[: __file__.index("/tests/")]
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def engine() -> Engine:
return SqlEngine.get_engine()
@pytest.fixture
def current_head_rev() -> str:
result = subprocess.run(
["alembic", "heads", "--resolve-dependencies"],
cwd=_BACKEND_DIR,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
)
assert (
result.returncode == 0
), f"alembic heads failed (exit {result.returncode}):\n{result.stdout}"
rev = result.stdout.strip().split()[0]
assert len(rev) > 0
return rev
@pytest.fixture
def tenant_schema_at_head(
engine: Engine, current_head_rev: str
) -> Generator[str, None, None]:
"""Tenant schema with alembic_version already at head — should be excluded."""
schema = f"tenant_test_{uuid.uuid4().hex[:12]}"
with engine.connect() as conn:
conn.execute(text(f'CREATE SCHEMA "{schema}"'))
conn.execute(
text(
f'CREATE TABLE "{schema}".alembic_version '
f"(version_num VARCHAR(32) NOT NULL)"
)
)
conn.execute(
text(f'INSERT INTO "{schema}".alembic_version (version_num) VALUES (:rev)'),
{"rev": current_head_rev},
)
conn.commit()
yield schema
with engine.connect() as conn:
conn.execute(text(f'DROP SCHEMA IF EXISTS "{schema}" CASCADE'))
conn.commit()
@pytest.fixture
def tenant_schema_empty(engine: Engine) -> Generator[str, None, None]:
"""Tenant schema with no tables — should be included (needs migration)."""
schema = f"tenant_test_{uuid.uuid4().hex[:12]}"
with engine.connect() as conn:
conn.execute(text(f'CREATE SCHEMA "{schema}"'))
conn.commit()
yield schema
with engine.connect() as conn:
conn.execute(text(f'DROP SCHEMA IF EXISTS "{schema}" CASCADE'))
conn.commit()
@pytest.fixture
def tenant_schema_stale_rev(engine: Engine) -> Generator[str, None, None]:
"""Tenant schema with a non-head revision — should be included (needs migration)."""
schema = f"tenant_test_{uuid.uuid4().hex[:12]}"
with engine.connect() as conn:
conn.execute(text(f'CREATE SCHEMA "{schema}"'))
conn.execute(
text(
f'CREATE TABLE "{schema}".alembic_version '
f"(version_num VARCHAR(32) NOT NULL)"
)
)
conn.execute(
text(
f'INSERT INTO "{schema}".alembic_version (version_num) '
f"VALUES ('stalerev000000000000')"
)
)
conn.commit()
yield schema
with engine.connect() as conn:
conn.execute(text(f'DROP SCHEMA IF EXISTS "{schema}" CASCADE'))
conn.commit()
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
def test_classifies_all_cases(
current_head_rev: str,
tenant_schema_at_head: str,
tenant_schema_empty: str,
tenant_schema_stale_rev: str,
) -> None:
"""Correctly classifies all three schema states:
- at head → excluded
- no table → included (needs migration)
- stale rev → included (needs migration)
"""
all_schemas = [tenant_schema_at_head, tenant_schema_empty, tenant_schema_stale_rev]
result = get_schemas_needing_migration(all_schemas, current_head_rev)
assert tenant_schema_at_head not in result
assert tenant_schema_empty in result
assert tenant_schema_stale_rev in result
def test_idempotent(
current_head_rev: str,
tenant_schema_at_head: str,
tenant_schema_empty: str,
) -> None:
"""Calling the function twice returns the same result.
Verifies that the DROP TABLE IF EXISTS guards correctly clean up temp
tables so a second call succeeds even if the first left state behind.
"""
schemas = [tenant_schema_at_head, tenant_schema_empty]
first = get_schemas_needing_migration(schemas, current_head_rev)
second = get_schemas_needing_migration(schemas, current_head_rev)
assert first == second
def test_empty_input(current_head_rev: str) -> None:
"""An empty input list returns immediately without touching the DB."""
assert get_schemas_needing_migration([], current_head_rev) == []

View File

@@ -3,6 +3,7 @@ from fastapi import HTTPException
import onyx.auth.users as users
from onyx.auth.users import verify_email_domain
from onyx.configs.constants import AuthType
def test_verify_email_domain_allows_case_insensitive_match(
@@ -35,3 +36,37 @@ def test_verify_email_domain_invalid_email_format(
verify_email_domain("userexample.com") # missing '@'
assert exc.value.status_code == 400
assert "Email is not valid" in exc.value.detail
def test_verify_email_domain_rejects_plus_addressing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(users, "VALID_EMAIL_DOMAINS", [], raising=False)
monkeypatch.setattr(users, "AUTH_TYPE", AuthType.CLOUD, raising=False)
with pytest.raises(HTTPException) as exc:
verify_email_domain("user+tag@gmail.com")
assert exc.value.status_code == 400
assert "'+'" in str(exc.value.detail)
def test_verify_email_domain_allows_plus_for_onyx_app(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(users, "VALID_EMAIL_DOMAINS", [], raising=False)
monkeypatch.setattr(users, "AUTH_TYPE", AuthType.CLOUD, raising=False)
# Should not raise for onyx.app domain
verify_email_domain("user+tag@onyx.app")
def test_verify_email_domain_rejects_googlemail(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(users, "VALID_EMAIL_DOMAINS", [], raising=False)
monkeypatch.setattr(users, "AUTH_TYPE", AuthType.CLOUD, raising=False)
with pytest.raises(HTTPException) as exc:
verify_email_domain("user@googlemail.com")
assert exc.value.status_code == 400
assert "gmail.com" in str(exc.value.detail)

View File

@@ -0,0 +1,168 @@
from unittest.mock import MagicMock
from unittest.mock import patch
from uuid import uuid4
import pytest
from onyx.background.celery.tasks.user_file_processing.tasks import (
_user_file_project_sync_queued_key,
)
from onyx.background.celery.tasks.user_file_processing.tasks import (
check_for_user_file_project_sync,
)
from onyx.background.celery.tasks.user_file_processing.tasks import (
enqueue_user_file_project_sync_task,
)
from onyx.background.celery.tasks.user_file_processing.tasks import (
process_single_user_file_project_sync,
)
from onyx.configs.constants import CELERY_USER_FILE_PROJECT_SYNC_TASK_EXPIRES
from onyx.configs.constants import OnyxCeleryPriority
from onyx.configs.constants import OnyxCeleryQueues
from onyx.configs.constants import OnyxCeleryTask
from onyx.configs.constants import USER_FILE_PROJECT_SYNC_MAX_QUEUE_DEPTH
def _build_redis_mock_with_lock() -> tuple[MagicMock, MagicMock]:
redis_client = MagicMock()
lock = MagicMock()
lock.acquire.return_value = True
lock.owned.return_value = True
redis_client.lock.return_value = lock
return redis_client, lock
@patch(
"onyx.background.celery.tasks.user_file_processing.tasks."
"get_user_file_project_sync_queue_depth"
)
@patch("onyx.background.celery.tasks.user_file_processing.tasks.get_redis_client")
def test_check_for_user_file_project_sync_applies_queue_backpressure(
mock_get_redis_client: MagicMock,
mock_get_queue_depth: MagicMock,
) -> None:
redis_client, lock = _build_redis_mock_with_lock()
mock_get_redis_client.return_value = redis_client
mock_get_queue_depth.return_value = USER_FILE_PROJECT_SYNC_MAX_QUEUE_DEPTH + 1
task_app = MagicMock()
with patch.object(check_for_user_file_project_sync, "app", task_app):
check_for_user_file_project_sync.run(tenant_id="test-tenant")
task_app.send_task.assert_not_called()
lock.release.assert_called_once()
@patch(
"onyx.background.celery.tasks.user_file_processing.tasks."
"enqueue_user_file_project_sync_task"
)
@patch(
"onyx.background.celery.tasks.user_file_processing.tasks."
"get_user_file_project_sync_queue_depth"
)
@patch(
"onyx.background.celery.tasks.user_file_processing.tasks."
"get_session_with_current_tenant"
)
@patch("onyx.background.celery.tasks.user_file_processing.tasks.get_redis_client")
def test_check_for_user_file_project_sync_skips_duplicates(
mock_get_redis_client: MagicMock,
mock_get_session: MagicMock,
mock_get_queue_depth: MagicMock,
mock_enqueue: MagicMock,
) -> None:
redis_client, lock = _build_redis_mock_with_lock()
mock_get_redis_client.return_value = redis_client
mock_get_queue_depth.return_value = 0
user_file_id_one = uuid4()
user_file_id_two = uuid4()
session = MagicMock()
session.execute.return_value.scalars.return_value.all.return_value = [
user_file_id_one,
user_file_id_two,
]
mock_get_session.return_value.__enter__.return_value = session
mock_enqueue.side_effect = [True, False]
task_app = MagicMock()
with patch.object(check_for_user_file_project_sync, "app", task_app):
check_for_user_file_project_sync.run(tenant_id="test-tenant")
assert mock_enqueue.call_count == 2
lock.release.assert_called_once()
def test_enqueue_user_file_project_sync_task_sets_guard_and_expiry() -> None:
redis_client = MagicMock()
redis_client.set.return_value = True
celery_app = MagicMock()
user_file_id = str(uuid4())
enqueued = enqueue_user_file_project_sync_task(
celery_app=celery_app,
redis_client=redis_client,
user_file_id=user_file_id,
tenant_id="test-tenant",
priority=OnyxCeleryPriority.HIGHEST,
)
assert enqueued is True
redis_client.set.assert_called_once_with(
_user_file_project_sync_queued_key(user_file_id),
1,
nx=True,
ex=CELERY_USER_FILE_PROJECT_SYNC_TASK_EXPIRES,
)
celery_app.send_task.assert_called_once_with(
OnyxCeleryTask.PROCESS_SINGLE_USER_FILE_PROJECT_SYNC,
kwargs={"user_file_id": user_file_id, "tenant_id": "test-tenant"},
queue=OnyxCeleryQueues.USER_FILE_PROJECT_SYNC,
priority=OnyxCeleryPriority.HIGHEST,
expires=CELERY_USER_FILE_PROJECT_SYNC_TASK_EXPIRES,
)
def test_enqueue_user_file_project_sync_task_rolls_back_guard_on_publish_failure() -> (
None
):
redis_client = MagicMock()
redis_client.set.return_value = True
celery_app = MagicMock()
celery_app.send_task.side_effect = RuntimeError("publish failed")
user_file_id = str(uuid4())
with pytest.raises(RuntimeError):
enqueue_user_file_project_sync_task(
celery_app=celery_app,
redis_client=redis_client,
user_file_id=user_file_id,
tenant_id="test-tenant",
)
redis_client.delete.assert_called_once_with(
_user_file_project_sync_queued_key(user_file_id)
)
@patch("onyx.background.celery.tasks.user_file_processing.tasks.get_redis_client")
def test_process_single_user_file_project_sync_clears_queued_guard_on_pickup(
mock_get_redis_client: MagicMock,
) -> None:
redis_client = MagicMock()
lock = MagicMock()
lock.acquire.return_value = False
redis_client.lock.return_value = lock
mock_get_redis_client.return_value = redis_client
user_file_id = str(uuid4())
process_single_user_file_project_sync.run(
user_file_id=user_file_id,
tenant_id="test-tenant",
)
redis_client.delete.assert_called_once_with(
_user_file_project_sync_queued_key(user_file_id)
)

View File

@@ -0,0 +1,95 @@
from onyx.configs.constants import DocumentSource
from onyx.connectors.models import Document
from onyx.connectors.models import DocumentBase
from onyx.connectors.models import TextSection
def _minimal_doc_kwargs(metadata: dict) -> dict:
return {
"id": "test-doc",
"sections": [TextSection(text="hello", link="http://example.com")],
"source": DocumentSource.NOT_APPLICABLE,
"semantic_identifier": "Test Doc",
"metadata": metadata,
}
def test_int_values_coerced_to_str() -> None:
doc = Document(**_minimal_doc_kwargs({"count": 42}))
assert doc.metadata == {"count": "42"}
def test_float_values_coerced_to_str() -> None:
doc = Document(**_minimal_doc_kwargs({"score": 3.14}))
assert doc.metadata == {"score": "3.14"}
def test_bool_values_coerced_to_str() -> None:
doc = Document(**_minimal_doc_kwargs({"active": True}))
assert doc.metadata == {"active": "True"}
def test_list_of_ints_coerced_to_list_of_str() -> None:
doc = Document(**_minimal_doc_kwargs({"ids": [1, 2, 3]}))
assert doc.metadata == {"ids": ["1", "2", "3"]}
def test_list_of_mixed_types_coerced_to_list_of_str() -> None:
doc = Document(**_minimal_doc_kwargs({"tags": ["a", 1, True, 2.5]}))
assert doc.metadata == {"tags": ["a", "1", "True", "2.5"]}
def test_list_of_dicts_coerced_to_list_of_str() -> None:
raw = {"nested": [{"key": "val"}, {"key2": "val2"}]}
doc = Document(**_minimal_doc_kwargs(raw))
assert doc.metadata == {"nested": ["{'key': 'val'}", "{'key2': 'val2'}"]}
def test_dict_value_coerced_to_str() -> None:
raw = {"info": {"inner_key": "inner_val"}}
doc = Document(**_minimal_doc_kwargs(raw))
assert doc.metadata == {"info": "{'inner_key': 'inner_val'}"}
def test_none_value_coerced_to_str() -> None:
doc = Document(**_minimal_doc_kwargs({"empty": None}))
assert doc.metadata == {"empty": "None"}
def test_already_valid_str_values_unchanged() -> None:
doc = Document(**_minimal_doc_kwargs({"key": "value"}))
assert doc.metadata == {"key": "value"}
def test_already_valid_list_of_str_unchanged() -> None:
doc = Document(**_minimal_doc_kwargs({"tags": ["a", "b", "c"]}))
assert doc.metadata == {"tags": ["a", "b", "c"]}
def test_empty_metadata_unchanged() -> None:
doc = Document(**_minimal_doc_kwargs({}))
assert doc.metadata == {}
def test_mixed_metadata_values() -> None:
raw = {
"str_val": "hello",
"int_val": 99,
"list_val": [1, "two", 3.0],
"dict_val": {"nested": True},
}
doc = Document(**_minimal_doc_kwargs(raw))
assert doc.metadata == {
"str_val": "hello",
"int_val": "99",
"list_val": ["1", "two", "3.0"],
"dict_val": "{'nested': True}",
}
def test_coercion_works_on_base_class() -> None:
kwargs = _minimal_doc_kwargs({"count": 42})
kwargs.pop("source")
kwargs.pop("id")
doc = DocumentBase(**kwargs)
assert doc.metadata == {"count": "42"}

View File

@@ -97,6 +97,7 @@ class TestScimDALUserMappings:
assert model_attrs(added_obj) == {
"external_id": "ext-1",
"user_id": user_id,
"scim_username": None,
}
def test_delete_user_mapping(

View File

@@ -15,7 +15,10 @@ from sqlalchemy.orm import Session
from ee.onyx.server.scim.models import ScimGroupResource
from ee.onyx.server.scim.models import ScimName
from ee.onyx.server.scim.models import ScimUserResource
from ee.onyx.server.scim.providers.base import ScimProvider
from ee.onyx.server.scim.providers.okta import OktaProvider
from onyx.db.models import ScimToken
from onyx.db.models import ScimUserMapping
from onyx.db.models import User
from onyx.db.models import UserGroup
from onyx.db.models import UserRole
@@ -35,6 +38,12 @@ def mock_token() -> MagicMock:
return token
@pytest.fixture
def provider() -> ScimProvider:
"""An OktaProvider instance for endpoint tests."""
return OktaProvider()
@pytest.fixture
def mock_dal() -> Generator[MagicMock, None, None]:
"""Patch ScimDAL construction in api module and yield the mock instance."""
@@ -53,6 +62,9 @@ def mock_dal() -> Generator[MagicMock, None, None]:
dal.get_group_mapping_by_external_id.return_value = None
dal.get_group_members.return_value = []
dal.list_groups.return_value = ([], 0)
# User-group relationship defaults
dal.get_user_groups.return_value = []
dal.get_users_groups_batch.return_value = {}
yield dal
@@ -96,6 +108,16 @@ def make_db_group(**kwargs: Any) -> MagicMock:
return group
def make_user_mapping(**kwargs: Any) -> MagicMock:
"""Build a mock ScimUserMapping ORM object with configurable attributes."""
mapping = MagicMock(spec=ScimUserMapping)
mapping.id = kwargs.get("id", 1)
mapping.external_id = kwargs.get("external_id", "ext-default")
mapping.user_id = kwargs.get("user_id", uuid4())
mapping.scim_username = kwargs.get("scim_username", None)
return mapping
def assert_scim_error(result: object, expected_status: int) -> None:
"""Assert *result* is a JSONResponse with the given status code."""
assert isinstance(result, JSONResponse)

View File

@@ -21,6 +21,7 @@ from ee.onyx.server.scim.models import ScimPatchOperation
from ee.onyx.server.scim.models import ScimPatchOperationType
from ee.onyx.server.scim.models import ScimPatchRequest
from ee.onyx.server.scim.patch import ScimPatchError
from ee.onyx.server.scim.providers.base import ScimProvider
from tests.unit.onyx.server.scim.conftest import assert_scim_error
from tests.unit.onyx.server.scim.conftest import make_db_group
from tests.unit.onyx.server.scim.conftest import make_scim_group
@@ -34,6 +35,7 @@ class TestListGroups:
mock_db_session: MagicMock,
mock_token: MagicMock,
mock_dal: MagicMock,
provider: ScimProvider,
) -> None:
mock_dal.list_groups.return_value = ([], 0)
@@ -42,6 +44,7 @@ class TestListGroups:
startIndex=1,
count=100,
_token=mock_token,
provider=provider,
db_session=mock_db_session,
)
@@ -54,6 +57,7 @@ class TestListGroups:
mock_db_session: MagicMock,
mock_token: MagicMock,
mock_dal: MagicMock,
provider: ScimProvider,
) -> None:
mock_dal.list_groups.side_effect = ValueError(
"Unsupported filter attribute: userName"
@@ -64,6 +68,7 @@ class TestListGroups:
startIndex=1,
count=100,
_token=mock_token,
provider=provider,
db_session=mock_db_session,
)
@@ -74,6 +79,7 @@ class TestListGroups:
mock_db_session: MagicMock,
mock_token: MagicMock,
mock_dal: MagicMock,
provider: ScimProvider,
) -> None:
group = make_db_group(id=5, name="Engineering")
uid = uuid4()
@@ -85,6 +91,7 @@ class TestListGroups:
startIndex=1,
count=100,
_token=mock_token,
provider=provider,
db_session=mock_db_session,
)
@@ -106,6 +113,7 @@ class TestGetGroup:
mock_db_session: MagicMock,
mock_token: MagicMock,
mock_dal: MagicMock,
provider: ScimProvider,
) -> None:
group = make_db_group(id=5, name="Engineering")
mock_dal.get_group.return_value = group
@@ -114,6 +122,7 @@ class TestGetGroup:
result = get_group(
group_id="5",
_token=mock_token,
provider=provider,
db_session=mock_db_session,
)
@@ -126,10 +135,12 @@ class TestGetGroup:
mock_db_session: MagicMock,
mock_token: MagicMock,
mock_dal: MagicMock, # noqa: ARG002
provider: ScimProvider,
) -> None:
result = get_group(
group_id="not-a-number",
_token=mock_token,
provider=provider,
db_session=mock_db_session,
)
@@ -140,12 +151,14 @@ class TestGetGroup:
mock_db_session: MagicMock,
mock_token: MagicMock,
mock_dal: MagicMock,
provider: ScimProvider,
) -> None:
mock_dal.get_group.return_value = None
result = get_group(
group_id="999",
_token=mock_token,
provider=provider,
db_session=mock_db_session,
)
@@ -162,6 +175,7 @@ class TestCreateGroup:
mock_db_session: MagicMock,
mock_token: MagicMock,
mock_dal: MagicMock,
provider: ScimProvider,
) -> None:
mock_dal.get_group_by_name.return_value = None
mock_validate.return_value = ([], None)
@@ -172,6 +186,7 @@ class TestCreateGroup:
result = create_group(
group_resource=resource,
_token=mock_token,
provider=provider,
db_session=mock_db_session,
)
@@ -185,6 +200,7 @@ class TestCreateGroup:
mock_db_session: MagicMock,
mock_token: MagicMock,
mock_dal: MagicMock,
provider: ScimProvider,
) -> None:
mock_dal.get_group_by_name.return_value = make_db_group()
resource = make_scim_group()
@@ -192,6 +208,7 @@ class TestCreateGroup:
result = create_group(
group_resource=resource,
_token=mock_token,
provider=provider,
db_session=mock_db_session,
)
@@ -204,6 +221,7 @@ class TestCreateGroup:
mock_db_session: MagicMock,
mock_token: MagicMock,
mock_dal: MagicMock,
provider: ScimProvider,
) -> None:
mock_dal.get_group_by_name.return_value = None
mock_validate.return_value = ([], "Invalid member ID: bad-uuid")
@@ -213,6 +231,7 @@ class TestCreateGroup:
result = create_group(
group_resource=resource,
_token=mock_token,
provider=provider,
db_session=mock_db_session,
)
@@ -225,6 +244,7 @@ class TestCreateGroup:
mock_db_session: MagicMock,
mock_token: MagicMock,
mock_dal: MagicMock,
provider: ScimProvider,
) -> None:
mock_dal.get_group_by_name.return_value = None
uid = uuid4()
@@ -235,6 +255,7 @@ class TestCreateGroup:
result = create_group(
group_resource=resource,
_token=mock_token,
provider=provider,
db_session=mock_db_session,
)
@@ -247,6 +268,7 @@ class TestCreateGroup:
mock_db_session: MagicMock,
mock_token: MagicMock,
mock_dal: MagicMock,
provider: ScimProvider,
) -> None:
mock_dal.get_group_by_name.return_value = None
mock_validate.return_value = ([], None)
@@ -257,6 +279,7 @@ class TestCreateGroup:
result = create_group(
group_resource=resource,
_token=mock_token,
provider=provider,
db_session=mock_db_session,
)
@@ -274,6 +297,7 @@ class TestReplaceGroup:
mock_db_session: MagicMock,
mock_token: MagicMock,
mock_dal: MagicMock,
provider: ScimProvider,
) -> None:
group = make_db_group(id=5, name="Old Name")
mock_dal.get_group.return_value = group
@@ -286,6 +310,7 @@ class TestReplaceGroup:
group_id="5",
group_resource=resource,
_token=mock_token,
provider=provider,
db_session=mock_db_session,
)
@@ -299,6 +324,7 @@ class TestReplaceGroup:
mock_db_session: MagicMock,
mock_token: MagicMock,
mock_dal: MagicMock,
provider: ScimProvider,
) -> None:
mock_dal.get_group.return_value = None
@@ -306,6 +332,7 @@ class TestReplaceGroup:
group_id="999",
group_resource=make_scim_group(),
_token=mock_token,
provider=provider,
db_session=mock_db_session,
)
@@ -318,6 +345,7 @@ class TestReplaceGroup:
mock_db_session: MagicMock,
mock_token: MagicMock,
mock_dal: MagicMock,
provider: ScimProvider,
) -> None:
group = make_db_group(id=5)
mock_dal.get_group.return_value = group
@@ -329,6 +357,7 @@ class TestReplaceGroup:
group_id="5",
group_resource=resource,
_token=mock_token,
provider=provider,
db_session=mock_db_session,
)
@@ -341,6 +370,7 @@ class TestReplaceGroup:
mock_db_session: MagicMock,
mock_token: MagicMock,
mock_dal: MagicMock,
provider: ScimProvider,
) -> None:
group = make_db_group(id=5)
mock_dal.get_group.return_value = group
@@ -353,6 +383,7 @@ class TestReplaceGroup:
group_id="5",
group_resource=resource,
_token=mock_token,
provider=provider,
db_session=mock_db_session,
)
@@ -369,6 +400,7 @@ class TestPatchGroup:
mock_db_session: MagicMock,
mock_token: MagicMock,
mock_dal: MagicMock,
provider: ScimProvider,
) -> None:
group = make_db_group(id=5, name="Old Name")
mock_dal.get_group.return_value = group
@@ -391,6 +423,7 @@ class TestPatchGroup:
group_id="5",
patch_request=patch_req,
_token=mock_token,
provider=provider,
db_session=mock_db_session,
)
@@ -402,6 +435,7 @@ class TestPatchGroup:
mock_db_session: MagicMock,
mock_token: MagicMock,
mock_dal: MagicMock,
provider: ScimProvider,
) -> None:
mock_dal.get_group.return_value = None
@@ -419,6 +453,7 @@ class TestPatchGroup:
group_id="999",
patch_request=patch_req,
_token=mock_token,
provider=provider,
db_session=mock_db_session,
)
@@ -431,6 +466,7 @@ class TestPatchGroup:
mock_db_session: MagicMock,
mock_token: MagicMock,
mock_dal: MagicMock,
provider: ScimProvider,
) -> None:
group = make_db_group(id=5)
mock_dal.get_group.return_value = group
@@ -452,6 +488,7 @@ class TestPatchGroup:
group_id="5",
patch_request=patch_req,
_token=mock_token,
provider=provider,
db_session=mock_db_session,
)
@@ -464,6 +501,7 @@ class TestPatchGroup:
mock_db_session: MagicMock,
mock_token: MagicMock,
mock_dal: MagicMock,
provider: ScimProvider,
) -> None:
group = make_db_group(id=5)
mock_dal.get_group.return_value = group
@@ -483,7 +521,7 @@ class TestPatchGroup:
ScimPatchOperation(
op=ScimPatchOperationType.ADD,
path="members",
value=[{"value": uid}],
value=[ScimGroupMember(value=uid)],
)
]
)
@@ -492,6 +530,7 @@ class TestPatchGroup:
group_id="5",
patch_request=patch_req,
_token=mock_token,
provider=provider,
db_session=mock_db_session,
)
@@ -506,6 +545,7 @@ class TestPatchGroup:
mock_db_session: MagicMock,
mock_token: MagicMock,
mock_dal: MagicMock,
provider: ScimProvider,
) -> None:
group = make_db_group(id=5)
mock_dal.get_group.return_value = group
@@ -525,7 +565,7 @@ class TestPatchGroup:
ScimPatchOperation(
op=ScimPatchOperationType.ADD,
path="members",
value=[{"value": str(uid)}],
value=[ScimGroupMember(value=str(uid))],
)
]
)
@@ -534,6 +574,7 @@ class TestPatchGroup:
group_id="5",
patch_request=patch_req,
_token=mock_token,
provider=provider,
db_session=mock_db_session,
)
@@ -546,6 +587,7 @@ class TestPatchGroup:
mock_db_session: MagicMock,
mock_token: MagicMock,
mock_dal: MagicMock,
provider: ScimProvider,
) -> None:
group = make_db_group(id=5)
mock_dal.get_group.return_value = group
@@ -568,6 +610,7 @@ class TestPatchGroup:
group_id="5",
patch_request=patch_req,
_token=mock_token,
provider=provider,
db_session=mock_db_session,
)

View File

@@ -2,13 +2,19 @@ import pytest
from ee.onyx.server.scim.models import ScimGroupMember
from ee.onyx.server.scim.models import ScimGroupResource
from ee.onyx.server.scim.models import ScimMeta
from ee.onyx.server.scim.models import ScimName
from ee.onyx.server.scim.models import ScimPatchOperation
from ee.onyx.server.scim.models import ScimPatchOperationType
from ee.onyx.server.scim.models import ScimPatchResourceValue
from ee.onyx.server.scim.models import ScimPatchValue
from ee.onyx.server.scim.models import ScimUserResource
from ee.onyx.server.scim.patch import apply_group_patch
from ee.onyx.server.scim.patch import apply_user_patch
from ee.onyx.server.scim.patch import ScimPatchError
from ee.onyx.server.scim.providers.okta import OktaProvider
_OKTA_IGNORED = OktaProvider().ignored_patch_paths
def _make_user(**kwargs: object) -> ScimUserResource:
@@ -29,14 +35,14 @@ def _make_group(**kwargs: object) -> ScimGroupResource:
def _replace_op(
path: str | None = None,
value: str | bool | dict | list | None = None,
value: ScimPatchValue = None,
) -> ScimPatchOperation:
return ScimPatchOperation(op=ScimPatchOperationType.REPLACE, path=path, value=value)
def _add_op(
path: str | None = None,
value: str | bool | dict | list | None = None,
value: ScimPatchValue = None,
) -> ScimPatchOperation:
return ScimPatchOperation(op=ScimPatchOperationType.ADD, path=path, value=value)
@@ -80,7 +86,12 @@ class TestApplyUserPatch:
def test_replace_without_path_uses_dict(self) -> None:
user = _make_user()
result = apply_user_patch(
[_replace_op(None, {"active": False, "userName": "new@example.com"})],
[
_replace_op(
None,
ScimPatchResourceValue(active=False, userName="new@example.com"),
)
],
user,
)
assert result.active is False
@@ -119,6 +130,86 @@ class TestApplyUserPatch:
with pytest.raises(ScimPatchError, match="Unsupported operation"):
apply_user_patch([_remove_op("active")], user)
def test_replace_without_path_ignores_id(self) -> None:
"""Okta sends 'id' alongside actual changes — it should be silently ignored."""
user = _make_user()
result = apply_user_patch(
[_replace_op(None, ScimPatchResourceValue(active=False, id="some-uuid"))],
user,
ignored_paths=_OKTA_IGNORED,
)
assert result.active is False
def test_replace_without_path_ignores_schemas(self) -> None:
"""The 'schemas' key in a value dict should be silently ignored."""
user = _make_user()
result = apply_user_patch(
[
_replace_op(
None,
ScimPatchResourceValue(
active=False,
schemas=["urn:ietf:params:scim:schemas:core:2.0:User"],
),
)
],
user,
ignored_paths=_OKTA_IGNORED,
)
assert result.active is False
def test_okta_deactivation_payload(self) -> None:
"""Exact Okta deactivation payload: path-less replace with id + active."""
user = _make_user()
result = apply_user_patch(
[
_replace_op(
None,
ScimPatchResourceValue(id="abc-123", active=False),
)
],
user,
ignored_paths=_OKTA_IGNORED,
)
assert result.active is False
assert result.userName == "test@example.com"
def test_replace_displayname(self) -> None:
user = _make_user()
result = apply_user_patch(
[_replace_op("displayName", "New Display Name")], user
)
assert result.displayName == "New Display Name"
assert result.name is not None
assert result.name.formatted == "New Display Name"
def test_replace_without_path_complex_value_dict(self) -> None:
"""Okta sends id/schemas/meta alongside actual changes — complex types
(lists, nested dicts) must not cause Pydantic validation errors."""
user = _make_user()
result = apply_user_patch(
[
_replace_op(
None,
ScimPatchResourceValue(
active=False,
id="some-uuid",
schemas=["urn:ietf:params:scim:schemas:core:2.0:User"],
meta=ScimMeta(resourceType="User"),
),
)
],
user,
ignored_paths=_OKTA_IGNORED,
)
assert result.active is False
assert result.userName == "test@example.com"
def test_add_operation_works_like_replace(self) -> None:
user = _make_user()
result = apply_user_patch([_add_op("externalId", "ext-456")], user)
assert result.externalId == "ext-456"
class TestApplyGroupPatch:
"""Tests for SCIM group PATCH operations."""
@@ -135,7 +226,12 @@ class TestApplyGroupPatch:
def test_add_members(self) -> None:
group = _make_group()
result, added, removed = apply_group_patch(
[_add_op("members", [{"value": "user-1"}, {"value": "user-2"}])],
[
_add_op(
"members",
[ScimGroupMember(value="user-1"), ScimGroupMember(value="user-2")],
)
],
group,
)
assert len(result.members) == 2
@@ -145,7 +241,7 @@ class TestApplyGroupPatch:
def test_add_members_without_path(self) -> None:
group = _make_group()
result, added, _ = apply_group_patch(
[_add_op(None, [{"value": "user-1"}])],
[_add_op(None, [ScimGroupMember(value="user-1")])],
group,
)
assert len(result.members) == 1
@@ -154,7 +250,12 @@ class TestApplyGroupPatch:
def test_add_duplicate_member_skipped(self) -> None:
group = _make_group(members=[ScimGroupMember(value="user-1")])
result, added, _ = apply_group_patch(
[_add_op("members", [{"value": "user-1"}, {"value": "user-2"}])],
[
_add_op(
"members",
[ScimGroupMember(value="user-1"), ScimGroupMember(value="user-2")],
)
],
group,
)
assert len(result.members) == 2
@@ -190,7 +291,7 @@ class TestApplyGroupPatch:
result, added, removed = apply_group_patch(
[
_replace_op("displayName", "Renamed"),
_add_op("members", [{"value": "user-2"}]),
_add_op("members", [ScimGroupMember(value="user-2")]),
_remove_op('members[value eq "user-1"]'),
],
group,
@@ -221,7 +322,12 @@ class TestApplyGroupPatch:
]
)
result, added, removed = apply_group_patch(
[_replace_op("members", [{"value": "user-2"}, {"value": "user-3"}])],
[
_replace_op(
"members",
[ScimGroupMember(value="user-2"), ScimGroupMember(value="user-3")],
)
],
group,
)
assert len(result.members) == 2
@@ -256,3 +362,55 @@ class TestApplyGroupPatch:
group = _make_group()
apply_group_patch([_replace_op("displayName", "Changed")], group)
assert group.displayName == "Engineering"
def test_replace_without_path_ignores_id(self) -> None:
"""Group replace with 'id' in value dict should be silently ignored."""
group = _make_group()
result, _, _ = apply_group_patch(
[
_replace_op(
None, ScimPatchResourceValue(displayName="Updated", id="some-id")
)
],
group,
ignored_paths=_OKTA_IGNORED,
)
assert result.displayName == "Updated"
def test_replace_without_path_ignores_schemas(self) -> None:
group = _make_group()
result, _, _ = apply_group_patch(
[
_replace_op(
None,
ScimPatchResourceValue(
displayName="Updated",
schemas=["urn:ietf:params:scim:schemas:core:2.0:Group"],
),
)
],
group,
ignored_paths=_OKTA_IGNORED,
)
assert result.displayName == "Updated"
def test_replace_without_path_complex_value_dict(self) -> None:
"""Group PATCH with complex types in value dict (lists, nested dicts)
must not cause Pydantic validation errors."""
group = _make_group()
result, _, _ = apply_group_patch(
[
_replace_op(
None,
ScimPatchResourceValue(
displayName="Updated",
id="123",
schemas=["urn:ietf:params:scim:schemas:core:2.0:Group"],
meta=ScimMeta(resourceType="Group"),
),
)
],
group,
ignored_paths=_OKTA_IGNORED,
)
assert result.displayName == "Updated"

View File

@@ -0,0 +1,167 @@
from unittest.mock import MagicMock
from uuid import UUID
from uuid import uuid4
from ee.onyx.server.scim.models import ScimEmail
from ee.onyx.server.scim.models import ScimGroupMember
from ee.onyx.server.scim.models import ScimGroupResource
from ee.onyx.server.scim.models import ScimMeta
from ee.onyx.server.scim.models import ScimName
from ee.onyx.server.scim.models import ScimUserGroupRef
from ee.onyx.server.scim.models import ScimUserResource
from ee.onyx.server.scim.providers.base import get_default_provider
from ee.onyx.server.scim.providers.okta import OktaProvider
def _make_mock_user(
user_id: UUID | None = None,
email: str = "test@example.com",
personal_name: str | None = "Test User",
is_active: bool = True,
) -> MagicMock:
user = MagicMock()
user.id = user_id or uuid4()
user.email = email
user.personal_name = personal_name
user.is_active = is_active
return user
def _make_mock_group(group_id: int = 42, name: str = "Engineering") -> MagicMock:
group = MagicMock()
group.id = group_id
group.name = name
return group
class TestOktaProvider:
def test_name(self) -> None:
assert OktaProvider().name == "okta"
def test_ignored_patch_paths(self) -> None:
assert OktaProvider().ignored_patch_paths == frozenset(
{"id", "schemas", "meta"}
)
def test_build_user_resource_basic(self) -> None:
provider = OktaProvider()
user = _make_mock_user()
result = provider.build_user_resource(user, "ext-123")
assert result == ScimUserResource(
id=str(user.id),
externalId="ext-123",
userName="test@example.com",
name=ScimName(givenName="Test", familyName="User", formatted="Test User"),
displayName="Test User",
emails=[ScimEmail(value="test@example.com", type="work", primary=True)],
active=True,
groups=[],
meta=ScimMeta(resourceType="User"),
)
def test_build_user_resource_with_groups(self) -> None:
provider = OktaProvider()
user = _make_mock_user()
groups = [(1, "Engineering"), (2, "Design")]
result = provider.build_user_resource(user, "ext-123", groups=groups)
assert result.groups == [
ScimUserGroupRef(value="1", display="Engineering"),
ScimUserGroupRef(value="2", display="Design"),
]
def test_build_user_resource_empty_groups(self) -> None:
provider = OktaProvider()
user = _make_mock_user()
result = provider.build_user_resource(user, "ext-123", groups=[])
assert result.groups == []
def test_build_user_resource_no_groups(self) -> None:
provider = OktaProvider()
user = _make_mock_user()
result = provider.build_user_resource(user, "ext-123")
assert result.groups == []
def test_build_user_resource_name_parsing(self) -> None:
provider = OktaProvider()
user = _make_mock_user(personal_name="Jane Doe")
result = provider.build_user_resource(user, None)
assert result.name == ScimName(
givenName="Jane", familyName="Doe", formatted="Jane Doe"
)
def test_build_user_resource_single_name(self) -> None:
provider = OktaProvider()
user = _make_mock_user(personal_name="Madonna")
result = provider.build_user_resource(user, None)
assert result.name == ScimName(
givenName="Madonna", familyName=None, formatted="Madonna"
)
def test_build_user_resource_no_name(self) -> None:
provider = OktaProvider()
user = _make_mock_user(personal_name=None)
result = provider.build_user_resource(user, None)
assert result.name is None
assert result.displayName is None
def test_build_user_resource_scim_username_preserves_case(self) -> None:
"""When scim_username is set, userName and emails use original case."""
provider = OktaProvider()
user = _make_mock_user(email="alice@example.com")
result = provider.build_user_resource(
user, "ext-1", scim_username="Alice@Example.com"
)
assert result.userName == "Alice@Example.com"
assert result.emails[0].value == "Alice@Example.com"
def test_build_user_resource_scim_username_none_falls_back(self) -> None:
"""When scim_username is None, userName falls back to user.email."""
provider = OktaProvider()
user = _make_mock_user(email="alice@example.com")
result = provider.build_user_resource(user, "ext-1", scim_username=None)
assert result.userName == "alice@example.com"
assert result.emails[0].value == "alice@example.com"
def test_build_group_resource(self) -> None:
provider = OktaProvider()
group = _make_mock_group()
uid1, uid2 = uuid4(), uuid4()
members: list[tuple[UUID, str | None]] = [
(uid1, "alice@example.com"),
(uid2, "bob@example.com"),
]
result = provider.build_group_resource(group, members, "ext-g-1")
assert result == ScimGroupResource(
id="42",
externalId="ext-g-1",
displayName="Engineering",
members=[
ScimGroupMember(value=str(uid1), display="alice@example.com"),
ScimGroupMember(value=str(uid2), display="bob@example.com"),
],
meta=ScimMeta(resourceType="Group"),
)
def test_build_group_resource_empty_members(self) -> None:
provider = OktaProvider()
group = _make_mock_group()
result = provider.build_group_resource(group, [])
assert result.members == []
class TestGetDefaultProvider:
def test_returns_okta(self) -> None:
provider = get_default_provider()
assert isinstance(provider, OktaProvider)

View File

@@ -9,6 +9,7 @@ from uuid import uuid4
from fastapi import Response
from sqlalchemy.exc import IntegrityError
from ee.onyx.server.scim.api import _scim_name_to_str
from ee.onyx.server.scim.api import create_user
from ee.onyx.server.scim.api import delete_user
from ee.onyx.server.scim.api import get_user
@@ -22,9 +23,11 @@ from ee.onyx.server.scim.models import ScimPatchOperationType
from ee.onyx.server.scim.models import ScimPatchRequest
from ee.onyx.server.scim.models import ScimUserResource
from ee.onyx.server.scim.patch import ScimPatchError
from ee.onyx.server.scim.providers.base import ScimProvider
from tests.unit.onyx.server.scim.conftest import assert_scim_error
from tests.unit.onyx.server.scim.conftest import make_db_user
from tests.unit.onyx.server.scim.conftest import make_scim_user
from tests.unit.onyx.server.scim.conftest import make_user_mapping
class TestListUsers:
@@ -35,6 +38,7 @@ class TestListUsers:
mock_db_session: MagicMock,
mock_token: MagicMock,
mock_dal: MagicMock,
provider: ScimProvider,
) -> None:
mock_dal.list_users.return_value = ([], 0)
@@ -43,6 +47,7 @@ class TestListUsers:
startIndex=1,
count=100,
_token=mock_token,
provider=provider,
db_session=mock_db_session,
)
@@ -55,15 +60,20 @@ class TestListUsers:
mock_db_session: MagicMock,
mock_token: MagicMock,
mock_dal: MagicMock,
provider: ScimProvider,
) -> None:
user = make_db_user(email="alice@example.com", personal_name="Alice Smith")
mock_dal.list_users.return_value = ([(user, "ext-abc")], 1)
mapping = make_user_mapping(
external_id="ext-abc", user_id=user.id, scim_username="Alice@example.com"
)
mock_dal.list_users.return_value = ([(user, mapping)], 1)
result = list_users(
filter=None,
startIndex=1,
count=100,
_token=mock_token,
provider=provider,
db_session=mock_db_session,
)
@@ -72,7 +82,7 @@ class TestListUsers:
assert len(result.Resources) == 1
resource = result.Resources[0]
assert isinstance(resource, ScimUserResource)
assert resource.userName == "alice@example.com"
assert resource.userName == "Alice@example.com"
assert resource.externalId == "ext-abc"
def test_unsupported_filter_attribute_returns_400(
@@ -80,6 +90,7 @@ class TestListUsers:
mock_db_session: MagicMock,
mock_token: MagicMock,
mock_dal: MagicMock,
provider: ScimProvider,
) -> None:
mock_dal.list_users.side_effect = ValueError(
"Unsupported filter attribute: emails"
@@ -90,6 +101,7 @@ class TestListUsers:
startIndex=1,
count=100,
_token=mock_token,
provider=provider,
db_session=mock_db_session,
)
@@ -100,12 +112,14 @@ class TestListUsers:
mock_db_session: MagicMock,
mock_token: MagicMock,
mock_dal: MagicMock, # noqa: ARG002
provider: ScimProvider,
) -> None:
result = list_users(
filter="not a valid filter",
startIndex=1,
count=100,
_token=mock_token,
provider=provider,
db_session=mock_db_session,
)
@@ -120,6 +134,7 @@ class TestGetUser:
mock_db_session: MagicMock,
mock_token: MagicMock,
mock_dal: MagicMock,
provider: ScimProvider,
) -> None:
user = make_db_user(email="alice@example.com")
mock_dal.get_user.return_value = user
@@ -127,6 +142,7 @@ class TestGetUser:
result = get_user(
user_id=str(user.id),
_token=mock_token,
provider=provider,
db_session=mock_db_session,
)
@@ -139,10 +155,12 @@ class TestGetUser:
mock_db_session: MagicMock,
mock_token: MagicMock,
mock_dal: MagicMock, # noqa: ARG002
provider: ScimProvider,
) -> None:
result = get_user(
user_id="not-a-uuid",
_token=mock_token,
provider=provider,
db_session=mock_db_session,
)
@@ -153,12 +171,14 @@ class TestGetUser:
mock_db_session: MagicMock,
mock_token: MagicMock,
mock_dal: MagicMock,
provider: ScimProvider,
) -> None:
mock_dal.get_user.return_value = None
result = get_user(
user_id=str(uuid4()),
_token=mock_token,
provider=provider,
db_session=mock_db_session,
)
@@ -175,6 +195,7 @@ class TestCreateUser:
mock_db_session: MagicMock,
mock_token: MagicMock,
mock_dal: MagicMock,
provider: ScimProvider,
) -> None:
mock_dal.get_user_by_email.return_value = None
resource = make_scim_user(userName="new@example.com")
@@ -182,6 +203,7 @@ class TestCreateUser:
result = create_user(
user_resource=resource,
_token=mock_token,
provider=provider,
db_session=mock_db_session,
)
@@ -195,12 +217,14 @@ class TestCreateUser:
mock_db_session: MagicMock,
mock_token: MagicMock,
mock_dal: MagicMock, # noqa: ARG002
provider: ScimProvider,
) -> None:
resource = make_scim_user(externalId=None)
result = create_user(
user_resource=resource,
_token=mock_token,
provider=provider,
db_session=mock_db_session,
)
@@ -213,6 +237,7 @@ class TestCreateUser:
mock_db_session: MagicMock,
mock_token: MagicMock,
mock_dal: MagicMock,
provider: ScimProvider,
) -> None:
mock_dal.get_user_by_email.return_value = make_db_user()
resource = make_scim_user()
@@ -220,6 +245,7 @@ class TestCreateUser:
result = create_user(
user_resource=resource,
_token=mock_token,
provider=provider,
db_session=mock_db_session,
)
@@ -232,6 +258,7 @@ class TestCreateUser:
mock_db_session: MagicMock,
mock_token: MagicMock,
mock_dal: MagicMock,
provider: ScimProvider,
) -> None:
mock_dal.get_user_by_email.return_value = None
mock_dal.add_user.side_effect = IntegrityError("dup", {}, Exception())
@@ -240,6 +267,7 @@ class TestCreateUser:
result = create_user(
user_resource=resource,
_token=mock_token,
provider=provider,
db_session=mock_db_session,
)
@@ -253,6 +281,7 @@ class TestCreateUser:
mock_db_session: MagicMock,
mock_token: MagicMock,
mock_dal: MagicMock, # noqa: ARG002
provider: ScimProvider,
) -> None:
mock_seats.return_value = "Seat limit reached"
resource = make_scim_user()
@@ -260,6 +289,7 @@ class TestCreateUser:
result = create_user(
user_resource=resource,
_token=mock_token,
provider=provider,
db_session=mock_db_session,
)
@@ -272,6 +302,7 @@ class TestCreateUser:
mock_db_session: MagicMock,
mock_token: MagicMock,
mock_dal: MagicMock,
provider: ScimProvider,
) -> None:
mock_dal.get_user_by_email.return_value = None
resource = make_scim_user(externalId="ext-123")
@@ -279,6 +310,7 @@ class TestCreateUser:
result = create_user(
user_resource=resource,
_token=mock_token,
provider=provider,
db_session=mock_db_session,
)
@@ -295,6 +327,7 @@ class TestReplaceUser:
mock_db_session: MagicMock,
mock_token: MagicMock,
mock_dal: MagicMock,
provider: ScimProvider,
) -> None:
user = make_db_user(email="old@example.com")
mock_dal.get_user.return_value = user
@@ -307,6 +340,7 @@ class TestReplaceUser:
user_id=str(user.id),
user_resource=resource,
_token=mock_token,
provider=provider,
db_session=mock_db_session,
)
@@ -319,6 +353,7 @@ class TestReplaceUser:
mock_db_session: MagicMock,
mock_token: MagicMock,
mock_dal: MagicMock,
provider: ScimProvider,
) -> None:
mock_dal.get_user.return_value = None
@@ -326,6 +361,7 @@ class TestReplaceUser:
user_id=str(uuid4()),
user_resource=make_scim_user(),
_token=mock_token,
provider=provider,
db_session=mock_db_session,
)
@@ -338,6 +374,7 @@ class TestReplaceUser:
mock_db_session: MagicMock,
mock_token: MagicMock,
mock_dal: MagicMock,
provider: ScimProvider,
) -> None:
user = make_db_user(is_active=False)
mock_dal.get_user.return_value = user
@@ -348,6 +385,7 @@ class TestReplaceUser:
user_id=str(user.id),
user_resource=resource,
_token=mock_token,
provider=provider,
db_session=mock_db_session,
)
@@ -359,6 +397,7 @@ class TestReplaceUser:
mock_db_session: MagicMock,
mock_token: MagicMock,
mock_dal: MagicMock,
provider: ScimProvider,
) -> None:
user = make_db_user()
mock_dal.get_user.return_value = user
@@ -369,11 +408,14 @@ class TestReplaceUser:
user_id=str(user.id),
user_resource=resource,
_token=mock_token,
provider=provider,
db_session=mock_db_session,
)
assert isinstance(result, ScimUserResource)
mock_dal.sync_user_external_id.assert_called_once_with(user.id, None)
mock_dal.sync_user_external_id.assert_called_once_with(
user.id, None, scim_username="test@example.com"
)
class TestPatchUser:
@@ -384,6 +426,7 @@ class TestPatchUser:
mock_db_session: MagicMock,
mock_token: MagicMock,
mock_dal: MagicMock,
provider: ScimProvider,
) -> None:
user = make_db_user(is_active=True)
mock_dal.get_user.return_value = user
@@ -401,6 +444,7 @@ class TestPatchUser:
user_id=str(user.id),
patch_request=patch_req,
_token=mock_token,
provider=provider,
db_session=mock_db_session,
)
@@ -412,6 +456,7 @@ class TestPatchUser:
mock_db_session: MagicMock,
mock_token: MagicMock,
mock_dal: MagicMock,
provider: ScimProvider,
) -> None:
mock_dal.get_user.return_value = None
patch_req = ScimPatchRequest(
@@ -428,11 +473,45 @@ class TestPatchUser:
user_id=str(uuid4()),
patch_request=patch_req,
_token=mock_token,
provider=provider,
db_session=mock_db_session,
)
assert_scim_error(result, 404)
def test_patch_displayname_persists(
self,
mock_db_session: MagicMock,
mock_token: MagicMock,
mock_dal: MagicMock,
provider: ScimProvider,
) -> None:
"""PATCH displayName should update personal_name in the DB."""
user = make_db_user(personal_name="Old Name")
mock_dal.get_user.return_value = user
patch_req = ScimPatchRequest(
Operations=[
ScimPatchOperation(
op=ScimPatchOperationType.REPLACE,
path="displayName",
value="New Display Name",
)
]
)
result = patch_user(
user_id=str(user.id),
patch_request=patch_req,
_token=mock_token,
provider=provider,
db_session=mock_db_session,
)
assert isinstance(result, ScimUserResource)
# Verify the update_user call received the new display name
call_kwargs = mock_dal.update_user.call_args
assert call_kwargs[1]["personal_name"] == "New Display Name"
@patch("ee.onyx.server.scim.api.apply_user_patch")
def test_patch_error_returns_error_response(
self,
@@ -440,6 +519,7 @@ class TestPatchUser:
mock_db_session: MagicMock,
mock_token: MagicMock,
mock_dal: MagicMock,
provider: ScimProvider,
) -> None:
user = make_db_user()
mock_dal.get_user.return_value = user
@@ -457,6 +537,7 @@ class TestPatchUser:
user_id=str(user.id),
patch_request=patch_req,
_token=mock_token,
provider=provider,
db_session=mock_db_session,
)
@@ -519,3 +600,87 @@ class TestDeleteUser:
)
assert_scim_error(result, 404)
class TestScimNameToStr:
"""Tests for _scim_name_to_str helper."""
def test_prefers_given_family_over_formatted(self) -> None:
"""Okta may send stale formatted while updating givenName/familyName."""
name = ScimName(givenName="Jane", familyName="Smith", formatted="Old Name")
assert _scim_name_to_str(name) == "Jane Smith"
def test_given_name_only(self) -> None:
name = ScimName(givenName="Jane")
assert _scim_name_to_str(name) == "Jane"
def test_family_name_only(self) -> None:
name = ScimName(familyName="Smith")
assert _scim_name_to_str(name) == "Smith"
def test_falls_back_to_formatted(self) -> None:
name = ScimName(formatted="Display Name")
assert _scim_name_to_str(name) == "Display Name"
def test_none_returns_none(self) -> None:
assert _scim_name_to_str(None) is None
def test_empty_name_returns_none(self) -> None:
name = ScimName()
assert _scim_name_to_str(name) is None
class TestEmailCasePreservation:
"""Tests verifying email case is preserved through SCIM endpoints."""
@patch("ee.onyx.server.scim.api._check_seat_availability", return_value=None)
def test_create_preserves_username_case(
self,
mock_seats: MagicMock, # noqa: ARG002
mock_db_session: MagicMock,
mock_token: MagicMock,
mock_dal: MagicMock,
provider: ScimProvider,
) -> None:
"""POST /Users with mixed-case userName returns the original case."""
mock_dal.get_user_by_email.return_value = None
resource = make_scim_user(userName="Alice@Example.COM")
result = create_user(
user_resource=resource,
_token=mock_token,
provider=provider,
db_session=mock_db_session,
)
assert isinstance(result, ScimUserResource)
assert result.userName == "Alice@Example.COM"
assert result.emails[0].value == "Alice@Example.COM"
def test_get_preserves_username_case(
self,
mock_db_session: MagicMock,
mock_token: MagicMock,
mock_dal: MagicMock,
provider: ScimProvider,
) -> None:
"""GET /Users/{id} returns the original-case userName from mapping."""
user = make_db_user(email="alice@example.com")
mock_dal.get_user.return_value = user
mapping = make_user_mapping(
external_id="ext-1",
user_id=user.id,
scim_username="Alice@Example.COM",
)
mock_dal.get_user_mapping_by_user_id.return_value = mapping
result = get_user(
user_id=str(user.id),
_token=mock_token,
provider=provider,
db_session=mock_db_session,
)
assert isinstance(result, ScimUserResource)
assert result.userName == "Alice@Example.COM"
assert result.emails[0].value == "Alice@Example.COM"

View File

@@ -0,0 +1,173 @@
"""Unit tests for CodeInterpreterClient streaming-to-batch fallback.
When the streaming endpoint (/v1/execute/stream) returns 404 — e.g. because the
code-interpreter service is an older version that doesn't support streaming — the
client should transparently fall back to the batch endpoint (/v1/execute) and
convert the batch response into the same stream-event interface.
"""
from __future__ import annotations
from unittest.mock import MagicMock
from unittest.mock import patch
from onyx.tools.tool_implementations.python.code_interpreter_client import (
CodeInterpreterClient,
)
from onyx.tools.tool_implementations.python.code_interpreter_client import FileInput
from onyx.tools.tool_implementations.python.code_interpreter_client import (
StreamOutputEvent,
)
from onyx.tools.tool_implementations.python.code_interpreter_client import (
StreamResultEvent,
)
def _make_batch_response(
stdout: str = "",
stderr: str = "",
exit_code: int = 0,
timed_out: bool = False,
duration_ms: int = 50,
) -> MagicMock:
"""Build a mock ``requests.Response`` for the batch /v1/execute endpoint."""
resp = MagicMock()
resp.status_code = 200
resp.raise_for_status = MagicMock()
resp.json.return_value = {
"stdout": stdout,
"stderr": stderr,
"exit_code": exit_code,
"timed_out": timed_out,
"duration_ms": duration_ms,
"files": [],
}
return resp
def _make_404_response() -> MagicMock:
"""Build a mock ``requests.Response`` that returns 404 (streaming not found)."""
resp = MagicMock()
resp.status_code = 404
return resp
def test_execute_streaming_fallback_to_batch_on_404() -> None:
"""When /v1/execute/stream returns 404, the client should fall back to
/v1/execute and yield equivalent StreamEvent objects."""
client = CodeInterpreterClient(base_url="http://fake:9000")
stream_resp = _make_404_response()
batch_resp = _make_batch_response(
stdout="hello world\n",
stderr="a warning\n",
)
urls_called: list[str] = []
def mock_post(url: str, **_kwargs: object) -> MagicMock:
urls_called.append(url)
if url.endswith("/v1/execute/stream"):
return stream_resp
if url.endswith("/v1/execute"):
return batch_resp
raise AssertionError(f"Unexpected URL: {url}")
with patch.object(client.session, "post", side_effect=mock_post):
events = list(client.execute_streaming(code="print('hello world')"))
# Streaming endpoint was attempted first, then batch
assert len(urls_called) == 2
assert urls_called[0].endswith("/v1/execute/stream")
assert urls_called[1].endswith("/v1/execute")
# The 404 response must be closed before making the batch call
stream_resp.close.assert_called_once()
# _batch_as_stream yields: stdout event, stderr event, result event
assert len(events) == 3
assert isinstance(events[0], StreamOutputEvent)
assert events[0].stream == "stdout"
assert events[0].data == "hello world\n"
assert isinstance(events[1], StreamOutputEvent)
assert events[1].stream == "stderr"
assert events[1].data == "a warning\n"
assert isinstance(events[2], StreamResultEvent)
assert events[2].exit_code == 0
assert not events[2].timed_out
assert events[2].duration_ms == 50
assert events[2].files == []
def test_execute_streaming_fallback_stdout_only() -> None:
"""Fallback with only stdout (no stderr) should yield two events:
one StreamOutputEvent for stdout and one StreamResultEvent."""
client = CodeInterpreterClient(base_url="http://fake:9000")
stream_resp = _make_404_response()
batch_resp = _make_batch_response(stdout="result: 42\n")
def mock_post(url: str, **_kwargs: object) -> MagicMock:
if url.endswith("/v1/execute/stream"):
return stream_resp
if url.endswith("/v1/execute"):
return batch_resp
raise AssertionError(f"Unexpected URL: {url}")
with patch.object(client.session, "post", side_effect=mock_post):
events = list(client.execute_streaming(code="print(42)"))
# No stderr → only stdout + result
assert len(events) == 2
assert isinstance(events[0], StreamOutputEvent)
assert events[0].stream == "stdout"
assert events[0].data == "result: 42\n"
assert isinstance(events[1], StreamResultEvent)
assert events[1].exit_code == 0
def test_execute_streaming_fallback_preserves_files_param() -> None:
"""When falling back, the files parameter must be forwarded to the
batch endpoint so staged files are still available for execution."""
client = CodeInterpreterClient(base_url="http://fake:9000")
stream_resp = _make_404_response()
batch_resp = _make_batch_response(stdout="ok\n")
captured_payloads: list[dict] = []
def mock_post(url: str, **kwargs: object) -> MagicMock:
if "json" in kwargs:
captured_payloads.append(kwargs["json"]) # type: ignore[arg-type]
if url.endswith("/v1/execute/stream"):
return stream_resp
if url.endswith("/v1/execute"):
return batch_resp
raise AssertionError(f"Unexpected URL: {url}")
files_input: list[FileInput] = [{"path": "data.csv", "file_id": "file-abc123"}]
with patch.object(client.session, "post", side_effect=mock_post):
events = list(
client.execute_streaming(
code="import pandas",
files=files_input,
)
)
# Both the streaming attempt and the batch fallback should include files
assert len(captured_payloads) == 2
for payload in captured_payloads:
assert payload["files"] == files_input
assert payload["code"] == "import pandas"
# Should still yield valid events
assert any(isinstance(e, StreamResultEvent) for e in events)

View File

@@ -45,6 +45,6 @@ dependencies:
repository: https://charts.min.io/
condition: minio.enabled
- name: code-interpreter
version: 0.2.2
version: 0.2.1
repository: https://onyx-dot-app.github.io/python-sandbox/
condition: codeInterpreter.enabled

View File

@@ -957,7 +957,7 @@ minio:
# Code Interpreter - Python code execution service (beta feature)
codeInterpreter:
enabled: true
enabled: false # Disabled by default (beta feature)
replicaCount: 1

View File

@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "Onyx",
"version": "1.0",
"version": "1.1",
"description": "Onyx lets you research, create, and automate with LLMs powered by your team's unique knowledge",
"permissions": [
"sidePanel",

View File

@@ -43,8 +43,12 @@ async function openSidePanel(tabId) {
}
}
function encodeUserPrompt(text) {
return encodeURIComponent(text).replace(/\(/g, "%28").replace(/\)/g, "%29");
}
async function sendToOnyx(info, tab) {
const selectedText = encodeURIComponent(info.selectionText);
const selectedText = encodeUserPrompt(info.selectionText);
const currentUrl = encodeURIComponent(tab.url);
try {
@@ -153,6 +157,23 @@ chrome.commands.onCommand.addListener(async (command) => {
}
});
async function sendActiveTabUrlToPanel() {
try {
const [tab] = await chrome.tabs.query({
active: true,
lastFocusedWindow: true,
});
if (tab?.url) {
chrome.runtime.sendMessage({
action: ACTIONS.TAB_URL_UPDATED,
url: tab.url,
});
}
} catch (error) {
console.error("[Onyx SW] Error sending tab URL:", error);
}
}
chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
if (request.action === ACTIONS.GET_CURRENT_ONYX_DOMAIN) {
chrome.storage.local.get(
@@ -188,7 +209,7 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
chrome.storage.local.get(
{ [CHROME_SPECIFIC_STORAGE_KEYS.ONYX_DOMAIN]: DEFAULT_ONYX_DOMAIN },
(result) => {
const encodedText = encodeURIComponent(selectedText);
const encodedText = encodeUserPrompt(selectedText);
const onyxDomain = result[CHROME_SPECIFIC_STORAGE_KEYS.ONYX_DOMAIN];
const url = `${onyxDomain}${SIDE_PANEL_PATH}?user-prompt=${encodedText}`;
@@ -222,6 +243,15 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => {
}
return true;
}
if (request.action === ACTIONS.TAB_READING_ENABLED) {
chrome.storage.session.set({ tabReadingEnabled: true });
sendActiveTabUrlToPanel();
return false;
}
if (request.action === ACTIONS.TAB_READING_DISABLED) {
chrome.storage.session.set({ tabReadingEnabled: false });
return false;
}
});
chrome.storage.onChanged.addListener((changes, namespace) => {
@@ -273,4 +303,40 @@ chrome.omnibox.onInputChanged.addListener((text, suggest) => {
}
});
chrome.tabs.onActivated.addListener(async (activeInfo) => {
const result = await chrome.storage.session.get({ tabReadingEnabled: false });
if (!result.tabReadingEnabled) return;
try {
const tab = await chrome.tabs.get(activeInfo.tabId);
if (tab.url) {
chrome.runtime.sendMessage({
action: ACTIONS.TAB_URL_UPDATED,
url: tab.url,
});
}
} catch (error) {
console.error("[Onyx SW] Error on tab activated:", error);
}
});
chrome.tabs.onUpdated.addListener(async (tabId, changeInfo, tab) => {
if (!changeInfo.url) return;
const result = await chrome.storage.session.get({ tabReadingEnabled: false });
if (!result.tabReadingEnabled) return;
try {
const [activeTab] = await chrome.tabs.query({
active: true,
lastFocusedWindow: true,
});
if (activeTab?.id === tabId) {
chrome.runtime.sendMessage({
action: ACTIONS.TAB_URL_UPDATED,
url: changeInfo.url,
});
}
} catch (error) {
console.error("[Onyx SW] Error on tab updated:", error);
}
});
setupSidePanel();

View File

@@ -132,9 +132,7 @@ import { getOnyxDomain } from "../utils/storage.js";
return;
}
setIframeSrc(
items[CHROME_SPECIFIC_STORAGE_KEYS.ONYX_DOMAIN] + "/chat/nrf",
);
setIframeSrc(items[CHROME_SPECIFIC_STORAGE_KEYS.ONYX_DOMAIN] + "/nrf");
},
);
}

View File

@@ -15,6 +15,15 @@ import {
let iframeLoadTimeout;
let authRequired = false;
// Returns the origin of the Onyx app loaded in the iframe.
// We derive the origin from iframe.src so postMessage payloads
// (including tab URLs) are only delivered to the expected page.
// Throws if iframe.src is not a valid URL — this is intentional:
// postMessage must never fall back to the unsafe wildcard "*".
function getIframeOrigin() {
return new URL(iframe.src).origin;
}
async function checkPendingInput() {
try {
const result = await chrome.storage.session.get("pendingInput");
@@ -57,7 +66,7 @@ import {
type: WEB_MESSAGE.PAGE_CHANGE,
url: pageUrl,
},
"*",
getIframeOrigin(),
);
currentUrl = pageUrl;
}
@@ -76,15 +85,34 @@ import {
}
function handleMessage(event) {
// Only trust messages from the Onyx app iframe.
// Check both source identity and origin so that a cross-origin page
// navigated to inside the iframe cannot send privileged extension
// messages (e.g. TAB_READING_ENABLED) after iframe.src changes.
// getIframeOrigin() throws if iframe.src is not yet a valid URL —
// catching it here fails closed (message is rejected, not processed).
if (event.source !== iframe.contentWindow) return;
try {
if (event.origin !== getIframeOrigin()) return;
} catch {
return;
}
if (event.data.type === CHROME_MESSAGE.ONYX_APP_LOADED) {
clearTimeout(iframeLoadTimeout);
iframeLoaded = true;
showIframe();
if (iframe.contentWindow) {
iframe.contentWindow.postMessage({ type: "PANEL_READY" }, "*");
iframe.contentWindow.postMessage(
{ type: "PANEL_READY" },
getIframeOrigin(),
);
}
} else if (event.data.type === CHROME_MESSAGE.AUTH_REQUIRED) {
authRequired = true;
} else if (event.data.type === CHROME_MESSAGE.TAB_READING_ENABLED) {
chrome.runtime.sendMessage({ action: ACTIONS.TAB_READING_ENABLED });
} else if (event.data.type === CHROME_MESSAGE.TAB_READING_DISABLED) {
chrome.runtime.sendMessage({ action: ACTIONS.TAB_READING_DISABLED });
}
}
@@ -117,6 +145,13 @@ import {
setIframeSrc(request.url, request.pageUrl);
} else if (request.action === ACTIONS.UPDATE_PAGE_URL) {
sendWebsiteToIframe(request.pageUrl);
} else if (request.action === ACTIONS.TAB_URL_UPDATED) {
if (iframe.contentWindow) {
iframe.contentWindow.postMessage(
{ type: CHROME_MESSAGE.TAB_URL_UPDATED, url: request.url },
getIframeOrigin(),
);
}
}
});

View File

@@ -5,7 +5,7 @@ export const THEMES = {
export const DEFAULT_ONYX_DOMAIN = "http://localhost:3000";
export const SIDE_PANEL_PATH = "/chat/nrf/side-panel";
export const SIDE_PANEL_PATH = "/nrf/side-panel";
export const ACTIONS = {
GET_SELECTED_TEXT: "getSelectedText",
@@ -17,6 +17,9 @@ export const ACTIONS = {
OPEN_SIDE_PANEL_WITH_INPUT: "openSidePanelWithInput",
OPEN_ONYX_WITH_INPUT: "openOnyxWithInput",
CLOSE_SIDE_PANEL: "closeSidePanel",
TAB_URL_UPDATED: "tabUrlUpdated",
TAB_READING_ENABLED: "tabReadingEnabled",
TAB_READING_DISABLED: "tabReadingDisabled",
};
export const CHROME_SPECIFIC_STORAGE_KEYS = {
@@ -36,6 +39,9 @@ export const CHROME_MESSAGE = {
LOAD_NEW_CHAT_PAGE: "LOAD_NEW_CHAT_PAGE",
LOAD_NEW_PAGE: "LOAD_NEW_PAGE",
AUTH_REQUIRED: "AUTH_REQUIRED",
TAB_READING_ENABLED: "TAB_READING_ENABLED",
TAB_READING_DISABLED: "TAB_READING_DISABLED",
TAB_URL_UPDATED: "TAB_URL_UPDATED",
};
export const WEB_MESSAGE = {

View File

@@ -49,7 +49,7 @@ backend = [
"fastapi-users==15.0.4",
"fastapi-users-db-sqlalchemy==7.0.0",
"fastapi-limiter==0.1.6",
"fastmcp==2.14.2",
"fastmcp==3.0.2",
"filelock==3.20.3",
"google-api-python-client==2.86.0",
"google-auth-httplib2==0.1.0",
@@ -71,10 +71,10 @@ backend = [
"lxml==5.3.0",
"Mako==1.2.4",
"markitdown[pdf, docx, pptx, xlsx, xls]==0.1.2",
"mcp[cli]==1.25.0",
"mcp[cli]==1.26.0",
"msal==1.34.0",
"msoffcrypto-tool==5.4.2",
"Office365-REST-Python-Client==2.5.9",
"Office365-REST-Python-Client==2.6.2",
"oauthlib==3.2.2",
# NOTE: This is frozen to avoid https://foss.heptapod.net/openpyxl/openpyxl/-/issues/2147
"openpyxl==3.0.10",

View File

@@ -490,7 +490,6 @@ func createCherryPickPR(headBranch, baseBranch, title string, commitSHAs, commit
// Add standard checklist
body += "\n\n"
body += "- [x] [Required] I have considered whether this PR needs to be cherry-picked to the latest beta branch.\n"
body += "- [x] [Optional] Override Linear Check\n"
cmd := exec.Command("gh", "pr", "create",

View File

@@ -118,7 +118,7 @@ func runCI(cmd *cobra.Command, args []string, opts *RunCIOptions) {
// Create the CI branch
ciBranch := fmt.Sprintf("run-ci/%s", prNumber)
prTitle := fmt.Sprintf("chore: [Running GitHub actions for #%s]", prNumber)
prBody := fmt.Sprintf("This PR runs GitHub Actions CI for #%s.\n\n- [x] I have considered whether this PR needs to be cherry-picked to the latest beta branch.\n- [x] Override Linear Check\n\n**This PR should be closed (not merged) after CI completes.**", prNumber)
prBody := fmt.Sprintf("This PR runs GitHub Actions CI for #%s.\n\n- [x] Override Linear Check\n\n**This PR should be closed (not merged) after CI completes.**", prNumber)
// Fetch the fork's branch
if forkRepo == "" {

338
uv.lock generated
View File

@@ -86,6 +86,18 @@ boto3 = [
{ name = "boto3" },
]
[[package]]
name = "aiofile"
version = "3.9.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "caio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/67/e2/d7cb819de8df6b5c1968a2756c3cb4122d4fa2b8fc768b53b7c9e5edb646/aiofile-3.9.0.tar.gz", hash = "sha256:e5ad718bb148b265b6df1b3752c4d1d83024b93da9bd599df74b9d9ffcf7919b", size = 17943, upload-time = "2024-10-08T10:39:35.846Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/50/25/da1f0b4dd970e52bf5a36c204c107e11a0c6d3ed195eba0bfbc664c312b2/aiofile-3.9.0-py3-none-any.whl", hash = "sha256:ce2f6c1571538cbdfa0143b04e16b208ecb0e9cb4148e528af8a640ed51cc8aa", size = 19539, upload-time = "2024-10-08T10:39:32.955Z" },
]
[[package]]
name = "aiofiles"
version = "25.1.0"
@@ -736,6 +748,23 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e6/46/eb6eca305c77a4489affe1c5d8f4cae82f285d9addd8de4ec084a7184221/cachetools-6.2.2-py3-none-any.whl", hash = "sha256:6c09c98183bf58560c97b2abfcedcbaf6a896a490f534b031b661d3723b45ace", size = 11503, upload-time = "2025-11-13T17:42:50.232Z" },
]
[[package]]
name = "caio"
version = "0.9.25"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/92/88/b8527e1b00c1811db339a1df8bd1ae49d146fcea9d6a5c40e3a80aaeb38d/caio-0.9.25.tar.gz", hash = "sha256:16498e7f81d1d0f5a4c0ad3f2540e65fe25691376e0a5bd367f558067113ed10", size = 26781, upload-time = "2025-12-26T15:21:36.501Z" }
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/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/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/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/86/93/1f76c8d1bafe3b0614e06b2195784a3765bbf7b0a067661af9e2dd47fc33/caio-0.9.25-py3-none-any.whl", hash = "sha256:06c0bb02d6b929119b1cfbe1ca403c768b2013a369e2db46bfa2a5761cf82e40", size = 19087, upload-time = "2025-12-26T15:22:00.221Z" },
]
[[package]]
name = "celery"
version = "5.5.1"
@@ -1448,15 +1477,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/23/10/3c44e9331a5ec3bae8b2919d51f611a5b94e179563b1b89eb6423a8f43eb/discord.py-2.4.0-py3-none-any.whl", hash = "sha256:b8af6711c70f7e62160bfbecb55be699b5cb69d007426759ab8ab06b1bd77d1d", size = 1125988, upload-time = "2024-06-22T01:20:19.764Z" },
]
[[package]]
name = "diskcache"
version = "5.6.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/3f/21/1c1ffc1a039ddcc459db43cc108658f32c57d271d7289a2794e401d0fdb6/diskcache-5.6.3.tar.gz", hash = "sha256:2c3a3fa2743d8535d832ec61c2054a1641f41775aa7c556758a109941e33e4fc", size = 67916, upload-time = "2023-08-31T06:12:00.316Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3f/27/4570e78fc0bf5ea0ca45eb1de3818a23787af9b390c0b0a0033a1b8236f9/diskcache-5.6.3-py3-none-any.whl", hash = "sha256:5e31b2d5fbad117cc363ebaf6b689474db18a1f6438bc82358b024abd4c2ca19", size = 45550, upload-time = "2023-08-31T06:11:58.822Z" },
]
[[package]]
name = "distlib"
version = "0.4.0"
@@ -1666,24 +1686,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/46/ec/91a434c8a53d40c3598966621dea9c50512bec6ce8e76fa1751015e74cef/faker-40.1.2-py3-none-any.whl", hash = "sha256:93503165c165d330260e4379fd6dc07c94da90c611ed3191a0174d2ab9966a42", size = 1985633, upload-time = "2026-01-13T20:51:47.982Z" },
]
[[package]]
name = "fakeredis"
version = "2.33.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "redis" },
{ name = "sortedcontainers" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5f/f9/57464119936414d60697fcbd32f38909bb5688b616ae13de6e98384433e0/fakeredis-2.33.0.tar.gz", hash = "sha256:d7bc9a69d21df108a6451bbffee23b3eba432c21a654afc7ff2d295428ec5770", size = 175187, upload-time = "2025-12-16T19:45:52.269Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6e/78/a850fed8aeef96d4a99043c90b818b2ed5419cd5b24a4049fd7cfb9f1471/fakeredis-2.33.0-py3-none-any.whl", hash = "sha256:de535f3f9ccde1c56672ab2fdd6a8efbc4f2619fc2f1acc87b8737177d71c965", size = 119605, upload-time = "2025-12-16T19:45:51.08Z" },
]
[package.optional-dependencies]
lua = [
{ name = "lupa" },
]
[[package]]
name = "fastapi"
version = "0.128.0"
@@ -1785,29 +1787,33 @@ wheels = [
[[package]]
name = "fastmcp"
version = "2.14.2"
version = "3.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "authlib" },
{ name = "cyclopts" },
{ name = "exceptiongroup" },
{ name = "httpx" },
{ name = "jsonref" },
{ name = "jsonschema-path" },
{ name = "mcp" },
{ name = "openapi-pydantic" },
{ name = "opentelemetry-api" },
{ name = "packaging" },
{ name = "platformdirs" },
{ name = "py-key-value-aio", extra = ["disk", "keyring", "memory"] },
{ name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] },
{ name = "pydantic", extra = ["email"] },
{ name = "pydocket" },
{ name = "pyperclip" },
{ name = "python-dotenv" },
{ name = "pyyaml" },
{ name = "rich" },
{ name = "uvicorn" },
{ name = "watchfiles" },
{ name = "websockets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d1/1e/e3528227688c248283f6d86869b1e900563ffc223eff00f4f923d2750365/fastmcp-2.14.2.tar.gz", hash = "sha256:bd23d1b808b6f446444f10114dac468b11bfb9153ed78628f5619763d0cf573e", size = 8272966, upload-time = "2025-12-31T15:26:13.433Z" }
sdist = { url = "https://files.pythonhosted.org/packages/11/6b/1a7ec89727797fb07ec0928e9070fa2f45e7b35718e1fe01633a34c35e45/fastmcp-3.0.2.tar.gz", hash = "sha256:6bd73b4a3bab773ee6932df5249dcbcd78ed18365ed0aeeb97bb42702a7198d7", size = 17239351, upload-time = "2026-02-22T16:32:28.843Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0d/67/8456d39484fcb7afd0defed21918e773ed59a98b39e5b633328527c88367/fastmcp-2.14.2-py3-none-any.whl", hash = "sha256:e33cd622e1ebd5110af6a981804525b6cd41072e3c7d68268ed69ef3be651aca", size = 413279, upload-time = "2025-12-31T15:26:11.178Z" },
{ url = "https://files.pythonhosted.org/packages/0a/5a/f410a9015cfde71adf646dab4ef2feae49f92f34f6050fcfb265eb126b30/fastmcp-3.0.2-py3-none-any.whl", hash = "sha256:f513d80d4b30b54749fe8950116b1aab843f3c293f5cb971fc8665cb48dbb028", size = 606268, upload-time = "2026-02-22T16:32:30.992Z" },
]
[[package]]
@@ -3353,69 +3359,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/db/bc/83e112abc66cd466c6b83f99118035867cecd41802f8d044638aa78a106e/locket-1.0.0-py2.py3-none-any.whl", hash = "sha256:b6c819a722f7b6bd955b80781788e4a66a55628b858d347536b7e81325a3a5e3", size = 4398, upload-time = "2022-04-20T22:04:42.23Z" },
]
[[package]]
name = "lupa"
version = "2.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b8/1c/191c3e6ec6502e3dbe25a53e27f69a5daeac3e56de1f73c0138224171ead/lupa-2.6.tar.gz", hash = "sha256:9a770a6e89576be3447668d7ced312cd6fd41d3c13c2462c9dc2c2ab570e45d9", size = 7240282, upload-time = "2025-10-24T07:20:29.738Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ca/29/1f66907c1ebf1881735afa695e646762c674f00738ebf66d795d59fc0665/lupa-2.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6d988c0f9331b9f2a5a55186701a25444ab10a1432a1021ee58011499ecbbdd5", size = 962875, upload-time = "2025-10-24T07:17:39.107Z" },
{ url = "https://files.pythonhosted.org/packages/e6/67/4a748604be360eb9c1c215f6a0da921cd1a2b44b2c5951aae6fb83019d3a/lupa-2.6-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:ebe1bbf48259382c72a6fe363dea61a0fd6fe19eab95e2ae881e20f3654587bf", size = 1935390, upload-time = "2025-10-24T07:17:41.427Z" },
{ url = "https://files.pythonhosted.org/packages/ac/0c/8ef9ee933a350428b7bdb8335a37ef170ab0bb008bbf9ca8f4f4310116b6/lupa-2.6-cp311-cp311-macosx_11_0_x86_64.whl", hash = "sha256:a8fcee258487cf77cdd41560046843bb38c2e18989cd19671dd1e2596f798306", size = 992193, upload-time = "2025-10-24T07:17:43.231Z" },
{ url = "https://files.pythonhosted.org/packages/65/46/e6c7facebdb438db8a65ed247e56908818389c1a5abbf6a36aab14f1057d/lupa-2.6-cp311-cp311-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:561a8e3be800827884e767a694727ed8482d066e0d6edfcbf423b05e63b05535", size = 1165844, upload-time = "2025-10-24T07:17:45.437Z" },
{ url = "https://files.pythonhosted.org/packages/1c/26/9f1154c6c95f175ccbf96aa96c8f569c87f64f463b32473e839137601a8b/lupa-2.6-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:af880a62d47991cae78b8e9905c008cbfdc4a3a9723a66310c2634fc7644578c", size = 1048069, upload-time = "2025-10-24T07:17:47.181Z" },
{ url = "https://files.pythonhosted.org/packages/68/67/2cc52ab73d6af81612b2ea24c870d3fa398443af8e2875e5befe142398b1/lupa-2.6-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80b22923aa4023c86c0097b235615f89d469a0c4eee0489699c494d3367c4c85", size = 2079079, upload-time = "2025-10-24T07:17:49.755Z" },
{ url = "https://files.pythonhosted.org/packages/2e/dc/f843f09bbf325f6e5ee61730cf6c3409fc78c010d968c7c78acba3019ca7/lupa-2.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:153d2cc6b643f7efb9cfc0c6bb55ec784d5bac1a3660cfc5b958a7b8f38f4a75", size = 1071428, upload-time = "2025-10-24T07:17:51.991Z" },
{ url = "https://files.pythonhosted.org/packages/2e/60/37533a8d85bf004697449acb97ecdacea851acad28f2ad3803662487dd2a/lupa-2.6-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:3fa8777e16f3ded50b72967dc17e23f5a08e4f1e2c9456aff2ebdb57f5b2869f", size = 1181756, upload-time = "2025-10-24T07:17:53.752Z" },
{ url = "https://files.pythonhosted.org/packages/e4/f2/cf29b20dbb4927b6a3d27c339ac5d73e74306ecc28c8e2c900b2794142ba/lupa-2.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8dbdcbe818c02a2f56f5ab5ce2de374dab03e84b25266cfbaef237829bc09b3f", size = 2175687, upload-time = "2025-10-24T07:17:56.228Z" },
{ url = "https://files.pythonhosted.org/packages/94/7c/050e02f80c7131b63db1474bff511e63c545b5a8636a24cbef3fc4da20b6/lupa-2.6-cp311-cp311-win32.whl", hash = "sha256:defaf188fde8f7a1e5ce3a5e6d945e533b8b8d547c11e43b96c9b7fe527f56dc", size = 1412592, upload-time = "2025-10-24T07:17:59.062Z" },
{ url = "https://files.pythonhosted.org/packages/6f/9a/6f2af98aa5d771cea661f66c8eb8f53772ec1ab1dfbce24126cfcd189436/lupa-2.6-cp311-cp311-win_amd64.whl", hash = "sha256:9505ae600b5c14f3e17e70f87f88d333717f60411faca1ddc6f3e61dce85fa9e", size = 1669194, upload-time = "2025-10-24T07:18:01.647Z" },
{ url = "https://files.pythonhosted.org/packages/94/86/ce243390535c39d53ea17ccf0240815e6e457e413e40428a658ea4ee4b8d/lupa-2.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:47ce718817ef1cc0c40d87c3d5ae56a800d61af00fbc0fad1ca9be12df2f3b56", size = 951707, upload-time = "2025-10-24T07:18:03.884Z" },
{ url = "https://files.pythonhosted.org/packages/86/85/cedea5e6cbeb54396fdcc55f6b741696f3f036d23cfaf986d50d680446da/lupa-2.6-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:7aba985b15b101495aa4b07112cdc08baa0c545390d560ad5cfde2e9e34f4d58", size = 1916703, upload-time = "2025-10-24T07:18:05.6Z" },
{ url = "https://files.pythonhosted.org/packages/24/be/3d6b5f9a8588c01a4d88129284c726017b2089f3a3fd3ba8bd977292fea0/lupa-2.6-cp312-cp312-macosx_11_0_x86_64.whl", hash = "sha256:b766f62f95b2739f2248977d29b0722e589dcf4f0ccfa827ccbd29f0148bd2e5", size = 985152, upload-time = "2025-10-24T07:18:08.561Z" },
{ url = "https://files.pythonhosted.org/packages/eb/23/9f9a05beee5d5dce9deca4cb07c91c40a90541fc0a8e09db4ee670da550f/lupa-2.6-cp312-cp312-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:00a934c23331f94cb51760097ebfab14b005d55a6b30a2b480e3c53dd2fa290d", size = 1159599, upload-time = "2025-10-24T07:18:10.346Z" },
{ url = "https://files.pythonhosted.org/packages/40/4e/e7c0583083db9d7f1fd023800a9767d8e4391e8330d56c2373d890ac971b/lupa-2.6-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21de9f38bd475303e34a042b7081aabdf50bd9bafd36ce4faea2f90fd9f15c31", size = 1038686, upload-time = "2025-10-24T07:18:12.112Z" },
{ url = "https://files.pythonhosted.org/packages/1c/9f/5a4f7d959d4feba5e203ff0c31889e74d1ca3153122be4a46dca7d92bf7c/lupa-2.6-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf3bda96d3fc41237e964a69c23647d50d4e28421111360274d4799832c560e9", size = 2071956, upload-time = "2025-10-24T07:18:14.572Z" },
{ url = "https://files.pythonhosted.org/packages/92/34/2f4f13ca65d01169b1720176aedc4af17bc19ee834598c7292db232cb6dc/lupa-2.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5a76ead245da54801a81053794aa3975f213221f6542d14ec4b859ee2e7e0323", size = 1057199, upload-time = "2025-10-24T07:18:16.379Z" },
{ url = "https://files.pythonhosted.org/packages/35/2a/5f7d2eebec6993b0dcd428e0184ad71afb06a45ba13e717f6501bfed1da3/lupa-2.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8dd0861741caa20886ddbda0a121d8e52fb9b5bb153d82fa9bba796962bf30e8", size = 1173693, upload-time = "2025-10-24T07:18:18.153Z" },
{ url = "https://files.pythonhosted.org/packages/e4/29/089b4d2f8e34417349af3904bb40bec40b65c8731f45e3fd8d497ca573e5/lupa-2.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:239e63948b0b23023f81d9a19a395e768ed3da6a299f84e7963b8f813f6e3f9c", size = 2164394, upload-time = "2025-10-24T07:18:20.403Z" },
{ url = "https://files.pythonhosted.org/packages/f3/1b/79c17b23c921f81468a111cad843b076a17ef4b684c4a8dff32a7969c3f0/lupa-2.6-cp312-cp312-win32.whl", hash = "sha256:325894e1099499e7a6f9c351147661a2011887603c71086d36fe0f964d52d1ce", size = 1420647, upload-time = "2025-10-24T07:18:23.368Z" },
{ url = "https://files.pythonhosted.org/packages/b8/15/5121e68aad3584e26e1425a5c9a79cd898f8a152292059e128c206ee817c/lupa-2.6-cp312-cp312-win_amd64.whl", hash = "sha256:c735a1ce8ee60edb0fe71d665f1e6b7c55c6021f1d340eb8c865952c602cd36f", size = 1688529, upload-time = "2025-10-24T07:18:25.523Z" },
{ url = "https://files.pythonhosted.org/packages/28/1d/21176b682ca5469001199d8b95fa1737e29957a3d185186e7a8b55345f2e/lupa-2.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:663a6e58a0f60e7d212017d6678639ac8df0119bc13c2145029dcba084391310", size = 947232, upload-time = "2025-10-24T07:18:27.878Z" },
{ url = "https://files.pythonhosted.org/packages/ce/4c/d327befb684660ca13cf79cd1f1d604331808f9f1b6fb6bf57832f8edf80/lupa-2.6-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:d1f5afda5c20b1f3217a80e9bc1b77037f8a6eb11612fd3ada19065303c8f380", size = 1908625, upload-time = "2025-10-24T07:18:29.944Z" },
{ url = "https://files.pythonhosted.org/packages/66/8e/ad22b0a19454dfd08662237a84c792d6d420d36b061f239e084f29d1a4f3/lupa-2.6-cp313-cp313-macosx_11_0_x86_64.whl", hash = "sha256:26f2b3c085fe76e9119e48c1013c1cccdc1f51585d456858290475aa38e7089e", size = 981057, upload-time = "2025-10-24T07:18:31.553Z" },
{ url = "https://files.pythonhosted.org/packages/5c/48/74859073ab276bd0566c719f9ca0108b0cfc1956ca0d68678d117d47d155/lupa-2.6-cp313-cp313-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:60d2f902c7b96fb8ab98493dcff315e7bb4d0b44dc9dd76eb37de575025d5685", size = 1156227, upload-time = "2025-10-24T07:18:33.981Z" },
{ url = "https://files.pythonhosted.org/packages/09/6c/0e9ded061916877253c2266074060eb71ed99fb21d73c8c114a76725bce2/lupa-2.6-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a02d25dee3a3250967c36590128d9220ae02f2eda166a24279da0b481519cbff", size = 1035752, upload-time = "2025-10-24T07:18:36.32Z" },
{ url = "https://files.pythonhosted.org/packages/dd/ef/f8c32e454ef9f3fe909f6c7d57a39f950996c37a3deb7b391fec7903dab7/lupa-2.6-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6eae1ee16b886b8914ff292dbefbf2f48abfbdee94b33a88d1d5475e02423203", size = 2069009, upload-time = "2025-10-24T07:18:38.072Z" },
{ url = "https://files.pythonhosted.org/packages/53/dc/15b80c226a5225815a890ee1c11f07968e0aba7a852df41e8ae6fe285063/lupa-2.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0edd5073a4ee74ab36f74fe61450148e6044f3952b8d21248581f3c5d1a58be", size = 1056301, upload-time = "2025-10-24T07:18:40.165Z" },
{ url = "https://files.pythonhosted.org/packages/31/14/2086c1425c985acfb30997a67e90c39457122df41324d3c179d6ee2292c6/lupa-2.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0c53ee9f22a8a17e7d4266ad48e86f43771951797042dd51d1494aaa4f5f3f0a", size = 1170673, upload-time = "2025-10-24T07:18:42.426Z" },
{ url = "https://files.pythonhosted.org/packages/10/e5/b216c054cf86576c0191bf9a9f05de6f7e8e07164897d95eea0078dca9b2/lupa-2.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:de7c0f157a9064a400d828789191a96da7f4ce889969a588b87ec80de9b14772", size = 2162227, upload-time = "2025-10-24T07:18:46.112Z" },
{ url = "https://files.pythonhosted.org/packages/59/2f/33ecb5bedf4f3bc297ceacb7f016ff951331d352f58e7e791589609ea306/lupa-2.6-cp313-cp313-win32.whl", hash = "sha256:ee9523941ae0a87b5b703417720c5d78f72d2f5bc23883a2ea80a949a3ed9e75", size = 1419558, upload-time = "2025-10-24T07:18:48.371Z" },
{ url = "https://files.pythonhosted.org/packages/f9/b4/55e885834c847ea610e111d87b9ed4768f0afdaeebc00cd46810f25029f6/lupa-2.6-cp313-cp313-win_amd64.whl", hash = "sha256:b1335a5835b0a25ebdbc75cf0bda195e54d133e4d994877ef025e218c2e59db9", size = 1683424, upload-time = "2025-10-24T07:18:50.976Z" },
{ url = "https://files.pythonhosted.org/packages/66/9d/d9427394e54d22a35d1139ef12e845fd700d4872a67a34db32516170b746/lupa-2.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:dcb6d0a3264873e1653bc188499f48c1fb4b41a779e315eba45256cfe7bc33c1", size = 953818, upload-time = "2025-10-24T07:18:53.378Z" },
{ url = "https://files.pythonhosted.org/packages/10/41/27bbe81953fb2f9ecfced5d9c99f85b37964cfaf6aa8453bb11283983721/lupa-2.6-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:a37e01f2128f8c36106726cb9d360bac087d58c54b4522b033cc5691c584db18", size = 1915850, upload-time = "2025-10-24T07:18:55.259Z" },
{ url = "https://files.pythonhosted.org/packages/a3/98/f9ff60db84a75ba8725506bbf448fb085bc77868a021998ed2a66d920568/lupa-2.6-cp314-cp314-macosx_11_0_x86_64.whl", hash = "sha256:458bd7e9ff3c150b245b0fcfbb9bd2593d1152ea7f0a7b91c1d185846da033fe", size = 982344, upload-time = "2025-10-24T07:18:57.05Z" },
{ url = "https://files.pythonhosted.org/packages/41/f7/f39e0f1c055c3b887d86b404aaf0ca197b5edfd235a8b81b45b25bac7fc3/lupa-2.6-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:052ee82cac5206a02df77119c325339acbc09f5ce66967f66a2e12a0f3211cad", size = 1156543, upload-time = "2025-10-24T07:18:59.251Z" },
{ url = "https://files.pythonhosted.org/packages/9e/9c/59e6cffa0d672d662ae17bd7ac8ecd2c89c9449dee499e3eb13ca9cd10d9/lupa-2.6-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96594eca3c87dd07938009e95e591e43d554c1dbd0385be03c100367141db5a8", size = 1047974, upload-time = "2025-10-24T07:19:01.449Z" },
{ url = "https://files.pythonhosted.org/packages/23/c6/a04e9cef7c052717fcb28fb63b3824802488f688391895b618e39be0f684/lupa-2.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8faddd9d198688c8884091173a088a8e920ecc96cda2ffed576a23574c4b3f6", size = 2073458, upload-time = "2025-10-24T07:19:03.369Z" },
{ url = "https://files.pythonhosted.org/packages/e6/10/824173d10f38b51fc77785228f01411b6ca28826ce27404c7c912e0e442c/lupa-2.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:daebb3a6b58095c917e76ba727ab37b27477fb926957c825205fbda431552134", size = 1067683, upload-time = "2025-10-24T07:19:06.2Z" },
{ url = "https://files.pythonhosted.org/packages/b6/dc/9692fbcf3c924d9c4ece2d8d2f724451ac2e09af0bd2a782db1cef34e799/lupa-2.6-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f3154e68972befe0f81564e37d8142b5d5d79931a18309226a04ec92487d4ea3", size = 1171892, upload-time = "2025-10-24T07:19:08.544Z" },
{ url = "https://files.pythonhosted.org/packages/84/ff/e318b628d4643c278c96ab3ddea07fc36b075a57383c837f5b11e537ba9d/lupa-2.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e4dadf77b9fedc0bfa53417cc28dc2278a26d4cbd95c29f8927ad4d8fe0a7ef9", size = 2166641, upload-time = "2025-10-24T07:19:10.485Z" },
{ url = "https://files.pythonhosted.org/packages/12/f7/a6f9ec2806cf2d50826980cdb4b3cffc7691dc6f95e13cc728846d5cb793/lupa-2.6-cp314-cp314-win32.whl", hash = "sha256:cb34169c6fa3bab3e8ac58ca21b8a7102f6a94b6a5d08d3636312f3f02fafd8f", size = 1456857, upload-time = "2025-10-24T07:19:37.989Z" },
{ url = "https://files.pythonhosted.org/packages/c5/de/df71896f25bdc18360fdfa3b802cd7d57d7fede41a0e9724a4625b412c85/lupa-2.6-cp314-cp314-win_amd64.whl", hash = "sha256:b74f944fe46c421e25d0f8692aef1e842192f6f7f68034201382ac440ef9ea67", size = 1731191, upload-time = "2025-10-24T07:19:40.281Z" },
{ url = "https://files.pythonhosted.org/packages/47/3c/a1f23b01c54669465f5f4c4083107d496fbe6fb45998771420e9aadcf145/lupa-2.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0e21b716408a21ab65723f8841cf7f2f37a844b7a965eeabb785e27fca4099cf", size = 999343, upload-time = "2025-10-24T07:19:12.519Z" },
{ url = "https://files.pythonhosted.org/packages/c5/6d/501994291cb640bfa2ccf7f554be4e6914afa21c4026bd01bff9ca8aac57/lupa-2.6-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:589db872a141bfff828340079bbdf3e9a31f2689f4ca0d88f97d9e8c2eae6142", size = 2000730, upload-time = "2025-10-24T07:19:14.869Z" },
{ url = "https://files.pythonhosted.org/packages/53/a5/457ffb4f3f20469956c2d4c4842a7675e884efc895b2f23d126d23e126cc/lupa-2.6-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:cd852a91a4a9d4dcbb9a58100f820a75a425703ec3e3f049055f60b8533b7953", size = 1021553, upload-time = "2025-10-24T07:19:17.123Z" },
{ url = "https://files.pythonhosted.org/packages/51/6b/36bb5a5d0960f2a5c7c700e0819abb76fd9bf9c1d8a66e5106416d6e9b14/lupa-2.6-cp314-cp314t-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:0334753be028358922415ca97a64a3048e4ed155413fc4eaf87dd0a7e2752983", size = 1133275, upload-time = "2025-10-24T07:19:20.51Z" },
{ url = "https://files.pythonhosted.org/packages/19/86/202ff4429f663013f37d2229f6176ca9f83678a50257d70f61a0a97281bf/lupa-2.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:661d895cd38c87658a34780fac54a690ec036ead743e41b74c3fb81a9e65a6aa", size = 1038441, upload-time = "2025-10-24T07:19:22.509Z" },
{ url = "https://files.pythonhosted.org/packages/a7/42/d8125f8e420714e5b52e9c08d88b5329dfb02dcca731b4f21faaee6cc5b5/lupa-2.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aa58454ccc13878cc177c62529a2056be734da16369e451987ff92784994ca7", size = 2058324, upload-time = "2025-10-24T07:19:24.979Z" },
{ url = "https://files.pythonhosted.org/packages/2b/2c/47bf8b84059876e877a339717ddb595a4a7b0e8740bacae78ba527562e1c/lupa-2.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1425017264e470c98022bba8cff5bd46d054a827f5df6b80274f9cc71dafd24f", size = 1060250, upload-time = "2025-10-24T07:19:27.262Z" },
{ url = "https://files.pythonhosted.org/packages/c2/06/d88add2b6406ca1bdec99d11a429222837ca6d03bea42ca75afa169a78cb/lupa-2.6-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:224af0532d216e3105f0a127410f12320f7c5f1aa0300bdf9646b8d9afb0048c", size = 1151126, upload-time = "2025-10-24T07:19:29.522Z" },
{ url = "https://files.pythonhosted.org/packages/b4/a0/89e6a024c3b4485b89ef86881c9d55e097e7cb0bdb74efb746f2fa6a9a76/lupa-2.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9abb98d5a8fd27c8285302e82199f0e56e463066f88f619d6594a450bf269d80", size = 2153693, upload-time = "2025-10-24T07:19:31.379Z" },
{ url = "https://files.pythonhosted.org/packages/b6/36/a0f007dc58fc1bbf51fb85dcc82fcb1f21b8c4261361de7dab0e3d8521ef/lupa-2.6-cp314-cp314t-win32.whl", hash = "sha256:1849efeba7a8f6fb8aa2c13790bee988fd242ae404bd459509640eeea3d1e291", size = 1590104, upload-time = "2025-10-24T07:19:33.514Z" },
{ url = "https://files.pythonhosted.org/packages/7d/5e/db903ce9cf82c48d6b91bf6d63ae4c8d0d17958939a4e04ba6b9f38b8643/lupa-2.6-cp314-cp314t-win_amd64.whl", hash = "sha256:fc1498d1a4fc028bc521c26d0fad4ca00ed63b952e32fb95949bda76a04bad52", size = 1913818, upload-time = "2025-10-24T07:19:36.039Z" },
]
[[package]]
name = "lxml"
version = "5.3.0"
@@ -3790,7 +3733,7 @@ wheels = [
[[package]]
name = "mcp"
version = "1.25.0"
version = "1.26.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
@@ -3808,9 +3751,9 @@ dependencies = [
{ name = "typing-inspection" },
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d5/2d/649d80a0ecf6a1f82632ca44bec21c0461a9d9fc8934d38cb5b319f2db5e/mcp-1.25.0.tar.gz", hash = "sha256:56310361ebf0364e2d438e5b45f7668cbb124e158bb358333cd06e49e83a6802", size = 605387, upload-time = "2025-12-19T10:19:56.985Z" }
sdist = { url = "https://files.pythonhosted.org/packages/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66", size = 608005, upload-time = "2026-01-24T19:40:32.468Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e2/fc/6dc7659c2ae5ddf280477011f4213a74f806862856b796ef08f028e664bf/mcp-1.25.0-py3-none-any.whl", hash = "sha256:b37c38144a666add0862614cc79ec276e97d72aa8ca26d622818d4e278b9721a", size = 233076, upload-time = "2025-12-19T10:19:55.416Z" },
{ url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" },
]
[package.optional-dependencies]
@@ -4408,7 +4351,7 @@ wheels = [
[[package]]
name = "office365-rest-python-client"
version = "2.5.9"
version = "2.6.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "msal" },
@@ -4416,9 +4359,9 @@ dependencies = [
{ name = "requests" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e5/7d/7219ab9e1091024a74ee32cef0205a7bd44d3058d08a4948d30c58793eee/Office365-REST-Python-Client-2.5.9.tar.gz", hash = "sha256:95c1c01d6a52c1bd3fcd8cc914c373d2c3e1578173070e65f325da9c5ba6f5ad", size = 605076, upload-time = "2024-05-06T19:38:38.98Z" }
sdist = { url = "https://files.pythonhosted.org/packages/bc/04/6dce2d581c54a8e55a3b128cf79a93821a68a62bb9a956e65476c5bb247e/office365_rest_python_client-2.6.2.tar.gz", hash = "sha256:ce27f5a1c0cc3ff97041ccd9b386145692be4c64739f243f7d6ac3edbe0a3c46", size = 659460, upload-time = "2025-05-11T10:24:21.895Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/47/d01eaa338abadcbabf69b913d91ffb54a7f62f538ceb402cd2cf572115a8/Office365_REST_Python_Client-2.5.9-py3-none-any.whl", hash = "sha256:79b2718572763492f6fd351ad388089bdb3fde07fe5c1f92d716255ceadc0e47", size = 1168849, upload-time = "2024-05-06T19:38:32.534Z" },
{ url = "https://files.pythonhosted.org/packages/3a/a4/611155711f8af347875c15b8b83f5fd9e978bd4de45f90085b9a583b684d/Office365_REST_Python_Client-2.6.2-py3-none-any.whl", hash = "sha256:06fc6829c39b503897caa9d881db419d7f97a8e4f1c95c4c2d12db36ea6c955d", size = 1337139, upload-time = "2025-05-11T10:24:18.926Z" },
]
[[package]]
@@ -4673,7 +4616,7 @@ requires-dist = [
{ name = "fastapi-limiter", marker = "extra == 'backend'", specifier = "==0.1.6" },
{ name = "fastapi-users", marker = "extra == 'backend'", specifier = "==15.0.4" },
{ name = "fastapi-users-db-sqlalchemy", marker = "extra == 'backend'", specifier = "==7.0.0" },
{ name = "fastmcp", marker = "extra == 'backend'", specifier = "==2.14.2" },
{ name = "fastmcp", marker = "extra == 'backend'", specifier = "==3.0.2" },
{ name = "filelock", marker = "extra == 'backend'", specifier = "==3.20.3" },
{ name = "google-api-python-client", marker = "extra == 'backend'", specifier = "==2.86.0" },
{ name = "google-auth-httplib2", marker = "extra == 'backend'", specifier = "==0.1.0" },
@@ -4701,7 +4644,7 @@ requires-dist = [
{ name = "manygo", marker = "extra == 'dev'", specifier = "==0.2.0" },
{ name = "markitdown", extras = ["pdf", "docx", "pptx", "xlsx", "xls"], marker = "extra == 'backend'", specifier = "==0.1.2" },
{ name = "matplotlib", marker = "extra == 'dev'", specifier = "==3.10.8" },
{ name = "mcp", extras = ["cli"], marker = "extra == 'backend'", specifier = "==1.25.0" },
{ name = "mcp", extras = ["cli"], marker = "extra == 'backend'", specifier = "==1.26.0" },
{ name = "mistune", marker = "extra == 'backend'", specifier = "==3.2.0" },
{ name = "msal", marker = "extra == 'backend'", specifier = "==1.34.0" },
{ name = "msoffcrypto-tool", marker = "extra == 'backend'", specifier = "==5.4.2" },
@@ -4710,7 +4653,7 @@ requires-dist = [
{ name = "nest-asyncio", marker = "extra == 'backend'", specifier = "==1.6.0" },
{ 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.5.9" },
{ name = "office365-rest-python-client", marker = "extra == 'backend'", specifier = "==2.6.2" },
{ name = "onyx-devtools", marker = "extra == 'dev'", specifier = "==0.6.0" },
{ name = "openai", specifier = "==2.14.0" },
{ name = "openapi-generator-cli", marker = "extra == 'dev'", specifier = "==7.17.0" },
@@ -4967,35 +4910,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/95/f1/b27d3e2e003cd9a3592c43d099d2ed8d0a947c15281bf8463a256db0b46c/opentelemetry_exporter_otlp_proto_http-1.39.1-py3-none-any.whl", hash = "sha256:d9f5207183dd752a412c4cd564ca8875ececba13be6e9c6c370ffb752fd59985", size = 19641, upload-time = "2025-12-11T13:32:22.248Z" },
]
[[package]]
name = "opentelemetry-exporter-prometheus"
version = "0.60b1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-api" },
{ name = "opentelemetry-sdk" },
{ name = "prometheus-client" },
]
sdist = { url = "https://files.pythonhosted.org/packages/14/39/7dafa6fff210737267bed35a8855b6ac7399b9e582b8cf1f25f842517012/opentelemetry_exporter_prometheus-0.60b1.tar.gz", hash = "sha256:a4011b46906323f71724649d301b4dc188aaa068852e814f4df38cc76eac616b", size = 14976, upload-time = "2025-12-11T13:32:42.944Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9b/0d/4be6bf5477a3eb3d917d2f17d3c0b6720cd6cb97898444a61d43cc983f5c/opentelemetry_exporter_prometheus-0.60b1-py3-none-any.whl", hash = "sha256:49f59178de4f4590e3cef0b8b95cf6e071aae70e1f060566df5546fad773b8fd", size = 13019, upload-time = "2025-12-11T13:32:23.974Z" },
]
[[package]]
name = "opentelemetry-instrumentation"
version = "0.60b1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-api" },
{ name = "opentelemetry-semantic-conventions" },
{ name = "packaging" },
{ name = "wrapt" },
]
sdist = { url = "https://files.pythonhosted.org/packages/41/0f/7e6b713ac117c1f5e4e3300748af699b9902a2e5e34c9cf443dde25a01fa/opentelemetry_instrumentation-0.60b1.tar.gz", hash = "sha256:57ddc7974c6eb35865af0426d1a17132b88b2ed8586897fee187fd5b8944bd6a", size = 31706, upload-time = "2025-12-11T13:36:42.515Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/77/d2/6788e83c5c86a2690101681aeef27eeb2a6bf22df52d3f263a22cee20915/opentelemetry_instrumentation-0.60b1-py3-none-any.whl", hash = "sha256:04480db952b48fb1ed0073f822f0ee26012b7be7c3eac1a3793122737c78632d", size = 33096, upload-time = "2025-12-11T13:35:33.067Z" },
]
[[package]]
name = "opentelemetry-proto"
version = "1.39.1"
@@ -5237,15 +5151,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" },
]
[[package]]
name = "pathvalidate"
version = "3.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fa/2a/52a8da6fe965dea6192eb716b357558e103aea0a1e9a8352ad575a8406ca/pathvalidate-3.3.1.tar.gz", hash = "sha256:b18c07212bfead624345bb8e1d6141cdcf15a39736994ea0b94035ad2b1ba177", size = 63262, upload-time = "2025-06-15T09:07:20.736Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/70/875f4a23bfc4731703a5835487d0d2fb999031bd415e7d17c0ae615c18b7/pathvalidate-3.3.1-py3-none-any.whl", hash = "sha256:5263baab691f8e1af96092fa5137ee17df5bdfbd6cff1fcac4d6ef4bc2e1735f", size = 24305, upload-time = "2025-06-15T09:07:19.117Z" },
]
[[package]]
name = "pdfminer-six"
version = "20251107"
@@ -5709,21 +5614,21 @@ wheels = [
[[package]]
name = "py-key-value-aio"
version = "0.3.0"
version = "0.4.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "beartype" },
{ name = "py-key-value-shared" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/93/ce/3136b771dddf5ac905cc193b461eb67967cf3979688c6696e1f2cdcde7ea/py_key_value_aio-0.3.0.tar.gz", hash = "sha256:858e852fcf6d696d231266da66042d3355a7f9871650415feef9fca7a6cd4155", size = 50801, upload-time = "2025-11-17T16:50:04.711Z" }
sdist = { url = "https://files.pythonhosted.org/packages/04/3c/0397c072a38d4bc580994b42e0c90c5f44f679303489e4376289534735e5/py_key_value_aio-0.4.4.tar.gz", hash = "sha256:e3012e6243ed7cc09bb05457bd4d03b1ba5c2b1ca8700096b3927db79ffbbe55", size = 92300, upload-time = "2026-02-16T21:21:43.245Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/99/10/72f6f213b8f0bce36eff21fda0a13271834e9eeff7f9609b01afdc253c79/py_key_value_aio-0.3.0-py3-none-any.whl", hash = "sha256:1c781915766078bfd608daa769fefb97e65d1d73746a3dfb640460e322071b64", size = 96342, upload-time = "2025-11-17T16:50:03.801Z" },
{ url = "https://files.pythonhosted.org/packages/32/69/f1b537ee70b7def42d63124a539ed3026a11a3ffc3086947a1ca6e861868/py_key_value_aio-0.4.4-py3-none-any.whl", hash = "sha256:18e17564ecae61b987f909fc2cd41ee2012c84b4b1dcb8c055cf8b4bc1bf3f5d", size = 152291, upload-time = "2026-02-16T21:21:44.241Z" },
]
[package.optional-dependencies]
disk = [
{ name = "diskcache" },
{ name = "pathvalidate" },
filetree = [
{ name = "aiofile" },
{ name = "anyio" },
]
keyring = [
{ name = "keyring" },
@@ -5731,22 +5636,6 @@ keyring = [
memory = [
{ name = "cachetools" },
]
redis = [
{ name = "redis" },
]
[[package]]
name = "py-key-value-shared"
version = "0.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "beartype" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/7b/e4/1971dfc4620a3a15b4579fe99e024f5edd6e0967a71154771a059daff4db/py_key_value_shared-0.3.0.tar.gz", hash = "sha256:8fdd786cf96c3e900102945f92aa1473138ebe960ef49da1c833790160c28a4b", size = 11666, upload-time = "2025-11-17T16:50:06.849Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/51/e4/b8b0a03ece72f47dce2307d36e1c34725b7223d209fc679315ffe6a4e2c3/py_key_value_shared-0.3.0-py3-none-any.whl", hash = "sha256:5b0efba7ebca08bb158b1e93afc2f07d30b8f40c2fc12ce24a4c0d84f42f9298", size = 19560, upload-time = "2025-11-17T16:50:05.954Z" },
]
[[package]]
name = "pyairtable"
@@ -5911,29 +5800,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c1/60/5d4751ba3f4a40a6891f24eec885f51afd78d208498268c734e256fb13c4/pydantic_settings-2.12.0-py3-none-any.whl", hash = "sha256:fddb9fd99a5b18da837b29710391e945b1e30c135477f484084ee513adb93809", size = 51880, upload-time = "2025-11-10T14:25:45.546Z" },
]
[[package]]
name = "pydocket"
version = "0.16.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cloudpickle" },
{ name = "fakeredis", extra = ["lua"] },
{ name = "opentelemetry-api" },
{ name = "opentelemetry-exporter-prometheus" },
{ name = "opentelemetry-instrumentation" },
{ name = "prometheus-client" },
{ name = "py-key-value-aio", extra = ["memory", "redis"] },
{ name = "python-json-logger" },
{ name = "redis" },
{ name = "rich" },
{ name = "typer" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e0/c5/61dcfce4d50b66a3f09743294d37fab598b81bb0975054b7f732da9243ec/pydocket-0.16.3.tar.gz", hash = "sha256:78e9da576de09e9f3f410d2471ef1c679b7741ddd21b586c97a13872b69bd265", size = 297080, upload-time = "2025-12-23T23:37:33.32Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2c/94/93b7f5981aa04f922e0d9ce7326a4587866ec7e39f7c180ffcf408e66ee8/pydocket-0.16.3-py3-none-any.whl", hash = "sha256:e2b50925356e7cd535286255195458ac7bba15f25293356651b36d223db5dd7c", size = 67087, upload-time = "2025-12-23T23:37:31.829Z" },
]
[[package]]
name = "pyee"
version = "13.0.0"
@@ -6268,15 +6134,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/b5/2d/563849c31e58eb2e273fa0c391a7d9987db32f4d9152fe6ecdac0a8ffe93/python_iso639-2025.11.16-py3-none-any.whl", hash = "sha256:65f6ac6c6d8e8207f6175f8bf7fff7db486c6dc5c1d8866c2b77d2a923370896", size = 167818, upload-time = "2025-11-16T21:53:35.36Z" },
]
[[package]]
name = "python-json-logger"
version = "4.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/29/bf/eca6a3d43db1dae7070f70e160ab20b807627ba953663ba07928cdd3dc58/python_json_logger-4.0.0.tar.gz", hash = "sha256:f58e68eb46e1faed27e0f574a55a0455eecd7b8a5b88b85a784519ba3cff047f", size = 17683, upload-time = "2025-10-06T04:15:18.984Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/51/e5/fecf13f06e5e5f67e8837d777d1bc43fac0ed2b77a676804df5c34744727/python_json_logger-4.0.0-py3-none-any.whl", hash = "sha256:af09c9daf6a813aa4cc7180395f50f2a9e5fa056034c9953aec92e381c5ba1e2", size = 15548, upload-time = "2025-10-06T04:15:17.553Z" },
]
[[package]]
name = "python-magic"
version = "0.4.27"
@@ -8064,6 +7921,93 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/bf/7c/43fb4689fe287eceb701f389863aab35211835d63bbb9a798cfefa80d7de/voyageai-0.2.3-py3-none-any.whl", hash = "sha256:59c4958bd991e83cedb5a82d5e14ac698ce67e42713ea10467631a48ee272b15", size = 19748, upload-time = "2024-05-29T08:12:44.968Z" },
]
[[package]]
name = "watchfiles"
version = "1.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1f/f8/2c5f479fb531ce2f0564eda479faecf253d886b1ab3630a39b7bf7362d46/watchfiles-1.1.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:f57b396167a2565a4e8b5e56a5a1c537571733992b226f4f1197d79e94cf0ae5", size = 406529, upload-time = "2025-10-14T15:04:32.899Z" },
{ url = "https://files.pythonhosted.org/packages/fe/cd/f515660b1f32f65df671ddf6f85bfaca621aee177712874dc30a97397977/watchfiles-1.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:421e29339983e1bebc281fab40d812742268ad057db4aee8c4d2bce0af43b741", size = 394384, upload-time = "2025-10-14T15:04:33.761Z" },
{ url = "https://files.pythonhosted.org/packages/7b/c3/28b7dc99733eab43fca2d10f55c86e03bd6ab11ca31b802abac26b23d161/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6e43d39a741e972bab5d8100b5cdacf69db64e34eb19b6e9af162bccf63c5cc6", size = 448789, upload-time = "2025-10-14T15:04:34.679Z" },
{ url = "https://files.pythonhosted.org/packages/4a/24/33e71113b320030011c8e4316ccca04194bf0cbbaeee207f00cbc7d6b9f5/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f537afb3276d12814082a2e9b242bdcf416c2e8fd9f799a737990a1dbe906e5b", size = 460521, upload-time = "2025-10-14T15:04:35.963Z" },
{ url = "https://files.pythonhosted.org/packages/f4/c3/3c9a55f255aa57b91579ae9e98c88704955fa9dac3e5614fb378291155df/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b2cd9e04277e756a2e2d2543d65d1e2166d6fd4c9b183f8808634fda23f17b14", size = 488722, upload-time = "2025-10-14T15:04:37.091Z" },
{ url = "https://files.pythonhosted.org/packages/49/36/506447b73eb46c120169dc1717fe2eff07c234bb3232a7200b5f5bd816e9/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f3f58818dc0b07f7d9aa7fe9eb1037aecb9700e63e1f6acfed13e9fef648f5d", size = 596088, upload-time = "2025-10-14T15:04:38.39Z" },
{ url = "https://files.pythonhosted.org/packages/82/ab/5f39e752a9838ec4d52e9b87c1e80f1ee3ccdbe92e183c15b6577ab9de16/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9bb9f66367023ae783551042d31b1d7fd422e8289eedd91f26754a66f44d5cff", size = 472923, upload-time = "2025-10-14T15:04:39.666Z" },
{ url = "https://files.pythonhosted.org/packages/af/b9/a419292f05e302dea372fa7e6fda5178a92998411f8581b9830d28fb9edb/watchfiles-1.1.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aebfd0861a83e6c3d1110b78ad54704486555246e542be3e2bb94195eabb2606", size = 456080, upload-time = "2025-10-14T15:04:40.643Z" },
{ url = "https://files.pythonhosted.org/packages/b0/c3/d5932fd62bde1a30c36e10c409dc5d54506726f08cb3e1d8d0ba5e2bc8db/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:5fac835b4ab3c6487b5dbad78c4b3724e26bcc468e886f8ba8cc4306f68f6701", size = 629432, upload-time = "2025-10-14T15:04:41.789Z" },
{ url = "https://files.pythonhosted.org/packages/f7/77/16bddd9779fafb795f1a94319dc965209c5641db5bf1edbbccace6d1b3c0/watchfiles-1.1.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:399600947b170270e80134ac854e21b3ccdefa11a9529a3decc1327088180f10", size = 623046, upload-time = "2025-10-14T15:04:42.718Z" },
{ url = "https://files.pythonhosted.org/packages/46/ef/f2ecb9a0f342b4bfad13a2787155c6ee7ce792140eac63a34676a2feeef2/watchfiles-1.1.1-cp311-cp311-win32.whl", hash = "sha256:de6da501c883f58ad50db3a32ad397b09ad29865b5f26f64c24d3e3281685849", size = 271473, upload-time = "2025-10-14T15:04:43.624Z" },
{ url = "https://files.pythonhosted.org/packages/94/bc/f42d71125f19731ea435c3948cad148d31a64fccde3867e5ba4edee901f9/watchfiles-1.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:35c53bd62a0b885bf653ebf6b700d1bf05debb78ad9292cf2a942b23513dc4c4", size = 287598, upload-time = "2025-10-14T15:04:44.516Z" },
{ url = "https://files.pythonhosted.org/packages/57/c9/a30f897351f95bbbfb6abcadafbaca711ce1162f4db95fc908c98a9165f3/watchfiles-1.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:57ca5281a8b5e27593cb7d82c2ac927ad88a96ed406aa446f6344e4328208e9e", size = 277210, upload-time = "2025-10-14T15:04:45.883Z" },
{ url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" },
{ url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" },
{ url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" },
{ url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" },
{ url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" },
{ url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" },
{ url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" },
{ url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" },
{ url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" },
{ url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" },
{ url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" },
{ url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" },
{ url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" },
{ url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" },
{ url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" },
{ url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" },
{ url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" },
{ url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" },
{ url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" },
{ url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" },
{ url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" },
{ url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" },
{ url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" },
{ url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" },
{ url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" },
{ url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" },
{ url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" },
{ url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" },
{ url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" },
{ url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" },
{ url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" },
{ url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" },
{ url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" },
{ url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" },
{ url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" },
{ url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" },
{ url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" },
{ url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" },
{ url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" },
{ url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" },
{ url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" },
{ url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" },
{ url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" },
{ url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" },
{ url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" },
{ url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" },
{ url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" },
{ url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" },
{ url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" },
{ url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" },
{ url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" },
{ url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" },
{ url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" },
{ url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" },
{ url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" },
{ url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" },
{ url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" },
{ url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" },
{ url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" },
{ url = "https://files.pythonhosted.org/packages/d3/8e/e500f8b0b77be4ff753ac94dc06b33d8f0d839377fee1b78e8c8d8f031bf/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:db476ab59b6765134de1d4fe96a1a9c96ddf091683599be0f26147ea1b2e4b88", size = 408250, upload-time = "2025-10-14T15:06:10.264Z" },
{ url = "https://files.pythonhosted.org/packages/bd/95/615e72cd27b85b61eec764a5ca51bd94d40b5adea5ff47567d9ebc4d275a/watchfiles-1.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:89eef07eee5e9d1fda06e38822ad167a044153457e6fd997f8a858ab7564a336", size = 396117, upload-time = "2025-10-14T15:06:11.28Z" },
{ url = "https://files.pythonhosted.org/packages/c9/81/e7fe958ce8a7fb5c73cc9fb07f5aeaf755e6aa72498c57d760af760c91f8/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce19e06cbda693e9e7686358af9cd6f5d61312ab8b00488bc36f5aabbaf77e24", size = 450493, upload-time = "2025-10-14T15:06:12.321Z" },
{ url = "https://files.pythonhosted.org/packages/6e/d4/ed38dd3b1767193de971e694aa544356e63353c33a85d948166b5ff58b9e/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49", size = 457546, upload-time = "2025-10-14T15:06:13.372Z" },
]
[[package]]
name = "wcwidth"
version = "0.2.14"

View File

@@ -3,9 +3,9 @@ import "@opal/components/tooltip.css";
import {
Interactive,
type InteractiveBaseProps,
type InteractiveContainerHeightVariant,
type InteractiveContainerWidthVariant,
} from "@opal/core";
import type { SizeVariant } from "@opal/shared";
import type { TooltipSide } from "@opal/components";
import type { IconFunctionComponent } from "@opal/types";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
@@ -22,7 +22,7 @@ const iconVariants = {
function iconWrapper(
Icon: IconFunctionComponent | undefined,
size: InteractiveContainerHeightVariant,
size: SizeVariant,
includeSpacer: boolean
) {
const { padding: p, size: s } = iconVariants[size];
@@ -75,8 +75,11 @@ type ButtonContentProps =
type ButtonProps = InteractiveBaseProps &
ButtonContentProps & {
/** Size preset — controls gap, text size, and Container height/rounding. */
size?: InteractiveContainerHeightVariant;
/**
* Size preset — controls gap, text size, and Container height/rounding.
* Uses the shared `SizeVariant` scale from `@opal/shared`.
*/
size?: SizeVariant;
/** HTML button type. When provided, Container renders a `<button>` element. */
type?: "submit" | "button" | "reset";

View File

@@ -4,7 +4,6 @@ export {
type InteractiveBaseProps,
type InteractiveBaseVariantProps,
type InteractiveContainerProps,
type InteractiveContainerHeightVariant,
type InteractiveContainerWidthVariant,
type InteractiveContainerRoundingVariant,
} from "@opal/core/interactive/components";

View File

@@ -3,6 +3,7 @@ import React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cn } from "@opal/utils";
import type { WithoutStyles } from "@opal/types";
import { sizeVariants, type SizeVariant } from "@opal/shared";
// ---------------------------------------------------------------------------
// Types
@@ -38,31 +39,6 @@ type InteractiveBaseVariantProps =
selected?: never;
};
/**
* Height presets for `Interactive.Container`.
*
* - `"lg"` — 2.25rem (36px), suitable for most buttons/items
* - `"md"` — 1.75rem (28px), standard compact size
* - `"sm"` — 1.5rem (24px), for denser UIs
* - `"xs"` — 1.25rem (20px), for inline elements
* - `"2xs"` — 1rem (16px), for micro elements
* - `"fit"` — Shrink-wraps to content height (`h-fit`), for variable-height layouts
*/
type InteractiveContainerHeightVariant =
keyof typeof interactiveContainerSizeVariants;
const interactiveContainerSizeVariants = {
lg: { height: "h-[2.25rem]", minWidth: "min-w-[2.25rem]", padding: "p-2" },
md: { height: "h-[1.75rem]", minWidth: "min-w-[1.75rem]", padding: "p-1" },
sm: { height: "h-[1.5rem]", minWidth: "min-w-[1.5rem]", padding: "p-1" },
xs: {
height: "h-[1.25rem]",
minWidth: "min-w-[1.25rem]",
padding: "p-0.5",
},
"2xs": { height: "h-[1rem]", minWidth: "min-w-[1rem]", padding: "p-0.5" },
fit: { height: "h-fit", minWidth: "", padding: "p-0" },
} as const;
/**
* Width presets for `Interactive.Container`.
*
@@ -353,18 +329,13 @@ interface InteractiveContainerProps
roundingVariant?: InteractiveContainerRoundingVariant;
/**
* Height preset controlling the container's vertical size.
*
* - `"lg"` — 2.25rem (36px), typical button/item height
* - `"md"` — 1.75rem (28px), standard compact size
* - `"sm"` — 1.5rem (24px), for denser UIs
* - `"xs"` — 1.25rem (20px), for inline elements
* - `"2xs"` — 1rem (16px), for micro elements
* - `"fit"` — Shrink-wraps to content height (`h-fit`)
* Size preset controlling the container's height, min-width, and padding.
* Uses the shared `SizeVariant` scale from `@opal/shared`.
*
* @default "lg"
* @see {@link SizeVariant} for the full list of presets.
*/
heightVariant?: InteractiveContainerHeightVariant;
heightVariant?: SizeVariant;
/**
* Width preset controlling the container's horizontal size.
@@ -433,8 +404,7 @@ function InteractiveContainer({
target?: string;
rel?: string;
};
const { height, minWidth, padding } =
interactiveContainerSizeVariants[heightVariant];
const { height, minWidth, padding } = sizeVariants[heightVariant];
const sharedProps = {
...rest,
className: cn(
@@ -520,7 +490,6 @@ export {
type InteractiveBaseVariantProps,
type InteractiveBaseSelectVariantProps,
type InteractiveContainerProps,
type InteractiveContainerHeightVariant,
type InteractiveContainerWidthVariant,
type InteractiveContainerRoundingVariant,
};

View File

@@ -1,7 +1,7 @@
"use client";
import { Button } from "@opal/components/buttons/Button/components";
import type { InteractiveContainerHeightVariant } from "@opal/core";
import type { SizeVariant } from "@opal/shared";
import SvgEdit from "@opal/icons/edit";
import type { IconFunctionComponent } from "@opal/types";
import { cn } from "@opal/utils";
@@ -25,8 +25,8 @@ interface HeadingPresetConfig {
titleFont: string;
/** Title line-height — also used as icon container min-height (CSS value). */
lineHeight: string;
/** Button `size` prop for the edit button. */
editButtonSize: InteractiveContainerHeightVariant;
/** Button `size` prop for the edit button. Uses the shared `SizeVariant` scale. */
editButtonSize: SizeVariant;
/** Tailwind padding class for the edit button container. */
editButtonPadding: string;
}

View File

@@ -2,7 +2,7 @@
import { Button } from "@opal/components/buttons/Button/components";
import { Tag, type TagProps } from "@opal/components/Tag/components";
import type { InteractiveContainerHeightVariant } from "@opal/core";
import type { SizeVariant } from "@opal/shared";
import SvgAlertCircle from "@opal/icons/alert-circle";
import SvgAlertTriangle from "@opal/icons/alert-triangle";
import SvgEdit from "@opal/icons/edit";
@@ -26,7 +26,8 @@ interface LabelPresetConfig {
titleFont: string;
lineHeight: string;
gap: string;
editButtonSize: InteractiveContainerHeightVariant;
/** Button `size` prop for the edit button. Uses the shared `SizeVariant` scale. */
editButtonSize: SizeVariant;
editButtonPadding: string;
optionalFont: string;
/** Aux icon size = lineHeight 2 × p-0.5. */

View File

@@ -0,0 +1,99 @@
# ContentAction
**Import:** `import { ContentAction, type ContentActionProps } from "@opal/layouts";`
A row layout that pairs a [`Content`](../Content/README.md) block with optional right-side action children (buttons, badges, icons, etc.).
## Why ContentAction?
`Content` renders icon + title + description but has no slot for actions. When you need a settings row, card header, or list item with an action on the right you would typically wrap `Content` in a manual flex-row. `ContentAction` standardises that pattern and adds padding alignment with `Interactive.Container` and `Button` via the shared `SizeVariant` scale.
## Props
Inherits **all** props from [`Content`](../Content/README.md) (same discriminated-union API) plus:
| Prop | Type | Default | Description |
|---|---|---|---|
| `rightChildren` | `ReactNode` | `undefined` | Content rendered on the right side. Wrapper stretches to the full height of the row. |
| `paddingVariant` | `SizeVariant` | `"lg"` | Padding preset applied around the `Content` area. Uses the shared size scale from `@opal/shared`. |
### `paddingVariant` reference
| Value | Padding class | Effective padding |
|---|---|---|
| `lg` | `p-2` | 0.5rem (8px) |
| `md` | `p-1` | 0.25rem (4px) |
| `sm` | `p-1` | 0.25rem (4px) |
| `xs` | `p-0.5` | 0.125rem (2px) |
| `2xs` | `p-0.5` | 0.125rem (2px) |
| `fit` | `p-0` | 0 |
These values are identical to the padding applied by `Interactive.Container` at each size, so `ContentAction` labels naturally align with adjacent buttons of the same size.
## Layout Structure
```
[ Content (flex-1, padded) ][ rightChildren (shrink-0, full height) ]
```
- The outer wrapper is `flex flex-row items-stretch w-full`.
- `Content` sits inside a `flex-1 min-w-0` div with padding from `paddingVariant`.
- `rightChildren` is wrapped in `flex items-stretch shrink-0` so it stretches vertically.
## Usage Examples
### Settings row with an edit button
```tsx
import { ContentAction } from "@opal/layouts";
import { Button } from "@opal/components";
import SvgSettings from "@opal/icons/settings";
<ContentAction
icon={SvgSettings}
title="OpenAI"
description="GPT"
sizePreset="main-content"
variant="section"
tag={{ title: "Default", color: "blue" }}
paddingVariant="lg"
rightChildren={
<Button icon={SvgSettings} prominence="tertiary" onClick={handleEdit} />
}
/>
```
### Card header with connect action
```tsx
import { ContentAction } from "@opal/layouts";
import { Button } from "@opal/components";
import { SvgArrowExchange, SvgCloud } from "@opal/icons";
<ContentAction
icon={SvgCloud}
title="Google Cloud Vertex AI"
description="Gemini"
sizePreset="main-content"
variant="section"
paddingVariant="md"
rightChildren={
<Button rightIcon={SvgArrowExchange} prominence="tertiary">
Connect
</Button>
}
/>
```
### No right children (padding-only wrapper)
```tsx
<ContentAction
title="Section Header"
sizePreset="main-content"
variant="section"
paddingVariant="lg"
/>
```
When `rightChildren` is omitted the component renders only the padded `Content` — useful for alignment consistency when some rows have actions and others don't.

View File

@@ -0,0 +1,75 @@
import { Content, type ContentProps } from "@opal/layouts/Content/components";
import { sizeVariants, type SizeVariant } from "@opal/shared";
import { cn } from "@opal/utils";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type ContentActionProps = ContentProps & {
/** Content rendered on the right side, stretched to full height. */
rightChildren?: React.ReactNode;
/**
* Padding applied around the `Content` area.
* Uses the shared `SizeVariant` scale from `@opal/shared`.
*
* @default "lg"
* @see {@link SizeVariant} for the full list of presets.
*/
paddingVariant?: SizeVariant;
};
// ---------------------------------------------------------------------------
// ContentAction
// ---------------------------------------------------------------------------
/**
* A row layout that pairs a {@link Content} block with optional right-side
* action children (e.g. buttons, badges).
*
* The `Content` area receives padding controlled by `paddingVariant`, using
* the same size scale as `Interactive.Container` and `Button`. The
* `rightChildren` wrapper stretches to the full height of the row.
*
* @example
* ```tsx
* import { ContentAction } from "@opal/layouts";
* import { Button } from "@opal/components";
* import SvgSettings from "@opal/icons/settings";
*
* <ContentAction
* icon={SvgSettings}
* title="OpenAI"
* description="GPT"
* sizePreset="main-content"
* variant="section"
* paddingVariant="lg"
* rightChildren={<Button icon={SvgSettings} prominence="tertiary" />}
* />
* ```
*/
function ContentAction({
rightChildren,
paddingVariant = "lg",
...contentProps
}: ContentActionProps) {
const { padding } = sizeVariants[paddingVariant];
return (
<div className="flex flex-row items-stretch w-full">
<div className={cn("flex-1 min-w-0", padding)}>
<Content {...contentProps} />
</div>
{rightChildren && (
<div className="flex items-stretch shrink-0">{rightChildren}</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// Exports
// ---------------------------------------------------------------------------
export { ContentAction, type ContentActionProps };

View File

@@ -0,0 +1,93 @@
# @opal/layouts
**Import:** `import { Content, ContentAction } from "@opal/layouts";`
Layout primitives for composing icon + title + description rows. These components handle sizing, font selection, icon alignment, and optional inline editing — things that are tedious to get right by hand and easy to get wrong.
## Components
| Component | Description | Docs |
|---|---|---|
| [`Content`](./Content/README.md) | Icon + title + description row. Routes to an internal layout (`HeadingLayout`, `LabelLayout`, or `BodyLayout`) based on `sizePreset` and `variant`. | [Content README](./Content/README.md) |
| [`ContentAction`](./ContentAction/README.md) | Wraps `Content` in a flex-row with an optional `rightChildren` slot for action buttons. Adds padding alignment via the shared `SizeVariant` scale. | [ContentAction README](./ContentAction/README.md) |
## Quick Start
```tsx
import { Content, ContentAction } from "@opal/layouts";
import { Button } from "@opal/components";
import SvgSettings from "@opal/icons/settings";
// Simple heading
<Content
icon={SvgSettings}
title="Account Settings"
description="Manage your preferences"
sizePreset="headline"
variant="heading"
/>
// Label with tag
<Content
icon={SvgSettings}
title="OpenAI"
description="GPT"
sizePreset="main-content"
variant="section"
tag={{ title: "Default", color: "blue" }}
/>
// Row with action button
<ContentAction
icon={SvgSettings}
title="Provider Name"
description="Some description"
sizePreset="main-content"
variant="section"
paddingVariant="lg"
rightChildren={
<Button icon={SvgSettings} prominence="tertiary" />
}
/>
```
## Architecture
### Two-axis design (`Content`)
`Content` uses a two-axis system:
- **`sizePreset`** — controls sizing tokens (icon size, padding, gap, font, line-height).
- **`variant`** — controls structural layout (icon placement, description rendering).
Valid preset/variant combinations are enforced at the type level via a discriminated union. See the [Content README](./Content/README.md) for the full matrix.
### Shared size scale (`ContentAction`)
`ContentAction` uses the same `SizeVariant` scale (`lg`, `md`, `sm`, `xs`, `2xs`, `fit`) defined in `@opal/shared` that powers `Interactive.Container` and `Button`. This ensures that padding on content rows aligns with adjacent interactive elements at the same size.
## Exports
From `@opal/layouts`:
```ts
// Components
Content
ContentAction
// Types
ContentProps
ContentActionProps
SizePreset
ContentVariant
```
## Internal Layout Components
These are not exported — `Content` routes to them automatically:
| Layout | Used when | File |
|---|---|---|
| `HeadingLayout` | `sizePreset` is `headline` or `section` | `Content/HeadingLayout.tsx` |
| `LabelLayout` | `sizePreset` is `main-content`, `main-ui`, or `secondary` with `variant="section"` | `Content/LabelLayout.tsx` |
| `BodyLayout` | `variant="body"` | `Content/BodyLayout.tsx` |

View File

@@ -5,3 +5,9 @@ export {
type SizePreset,
type ContentVariant,
} from "@opal/layouts/Content/components";
/* ContentAction */
export {
ContentAction,
type ContentActionProps,
} from "@opal/layouts/ContentAction/components";

View File

@@ -0,0 +1,53 @@
/**
* @opal/shared — Shared constants and types for the opal design system.
*
* This module holds design tokens that are referenced by multiple opal
* packages (core, components, layouts). Centralising them here avoids
* circular imports and gives every consumer a single source of truth.
*/
// ---------------------------------------------------------------------------
// Size Variants
//
// A named scale of size presets (lg → 2xs, plus fit) that map to Tailwind
// utility classes for height, min-width, and padding.
//
// Consumers:
// - Interactive.Container (height + min-width + padding)
// - Button (icon sizing)
// - ContentAction (padding only)
// - Content (HeadingLayout / LabelLayout) (edit-button size)
// ---------------------------------------------------------------------------
/**
* Size-variant scale.
*
* Each entry maps a named preset to Tailwind utility classes for
* `height`, `min-width`, and `padding`.
*
* | Key | Height | Padding |
* |-------|---------------|----------|
* | `lg` | 2.25rem (36px)| `p-2` |
* | `md` | 1.75rem (28px)| `p-1` |
* | `sm` | 1.5rem (24px) | `p-1` |
* | `xs` | 1.25rem (20px)| `p-0.5` |
* | `2xs` | 1rem (16px) | `p-0.5` |
* | `fit` | h-fit | `p-0` |
*/
const sizeVariants = {
lg: { height: "h-[2.25rem]", minWidth: "min-w-[2.25rem]", padding: "p-2" },
md: { height: "h-[1.75rem]", minWidth: "min-w-[1.75rem]", padding: "p-1" },
sm: { height: "h-[1.5rem]", minWidth: "min-w-[1.5rem]", padding: "p-1" },
xs: {
height: "h-[1.25rem]",
minWidth: "min-w-[1.25rem]",
padding: "p-0.5",
},
"2xs": { height: "h-[1rem]", minWidth: "min-w-[1rem]", padding: "p-0.5" },
fit: { height: "h-fit", minWidth: "", padding: "p-0" },
} as const;
/** Named size preset key. */
type SizeVariant = keyof typeof sizeVariants;
export { sizeVariants, type SizeVariant };

View File

@@ -105,6 +105,18 @@ const nextConfig = {
destination: "/app",
permanent: true,
},
// NRF routes: Redirect to /nrf which doesn't require auth
// (NRFPage handles unauthenticated users gracefully with a login modal)
{
source: "/app/nrf/side-panel",
destination: "/nrf/side-panel",
permanent: true,
},
{
source: "/app/nrf",
destination: "/nrf",
permanent: true,
},
{
source: "/chat/:path*",
destination: "/app/:path*",

View File

@@ -2,10 +2,10 @@
import Image from "next/image";
import { useMemo, useState, useReducer } from "react";
import { AdminPageTitle } from "@/components/admin/Title";
import { InfoIcon } from "@/components/icons/icons";
import Text from "@/refresh-components/texts/Text";
import Separator from "@/refresh-components/Separator";
import * as SettingsLayouts from "@/layouts/settings-layouts";
import { Content } from "@opal/layouts";
import useSWR from "swr";
import { errorHandlingFetcher, FetchError } from "@/lib/fetcher";
import { ThreeDotsLoader } from "@/components/Loading";
@@ -22,7 +22,6 @@ import {
SvgOnyxLogo,
SvgX,
} from "@opal/icons";
import { WebProviderSetupModal } from "@/app/admin/configuration/web-search/WebProviderSetupModal";
import {
SEARCH_PROVIDERS_URL,
@@ -402,36 +401,40 @@ export default function Page() {
: undefined);
return (
<>
<AdminPageTitle
title="Web Search"
<SettingsLayouts.Root>
<SettingsLayouts.Header
icon={SvgGlobe}
includeDivider={false}
title="Web Search"
description="Search settings for external search across the internet."
separator
/>
<Callout type="danger" title="Failed to load web search settings">
{message}
{detail && (
<Text as="p" className="mt-2 text-text-03" mainContentBody text03>
{detail}
</Text>
)}
</Callout>
</>
<SettingsLayouts.Body>
<Callout type="danger" title="Failed to load web search settings">
{message}
{detail && (
<Text as="p" className="mt-2 text-text-03" mainContentBody text03>
{detail}
</Text>
)}
</Callout>
</SettingsLayouts.Body>
</SettingsLayouts.Root>
);
}
if (isLoading) {
return (
<>
<AdminPageTitle
title="Web Search"
<SettingsLayouts.Root>
<SettingsLayouts.Header
icon={SvgGlobe}
includeDivider={false}
title="Web Search"
description="Search settings for external search across the internet."
separator
/>
<div className="mt-8">
<SettingsLayouts.Body>
<ThreeDotsLoader />
</div>
</>
</SettingsLayouts.Body>
</SettingsLayouts.Root>
);
}
@@ -827,32 +830,22 @@ export default function Page() {
return (
<>
<>
<AdminPageTitle icon={SvgGlobe} title="Web Search" />
<div className="pt-4 pb-4">
<Text as="p" className="text-text-dark">
Search settings for external search across the internet.
</Text>
</div>
<SettingsLayouts.Root>
<SettingsLayouts.Header
icon={SvgGlobe}
title="Web Search"
description="Search settings for external search across the internet."
separator
/>
<Separator />
<div className="flex w-full flex-col gap-8 pb-6">
<div className="flex w-full max-w-[960px] flex-col gap-3">
<div className="flex flex-col gap-0.5">
<Text as="p" mainContentEmphasis text05>
Search Engine
</Text>
<Text
as="p"
className="flex items-start gap-[2px] self-stretch text-text-03"
secondaryBody
text03
>
External search engine API used for web search result URLs,
snippets, and metadata.
</Text>
</div>
<SettingsLayouts.Body>
<div className="flex w-full flex-col gap-3">
<Content
title="Search Engine"
description="External search engine API used for web search result URLs, snippets, and metadata."
sizePreset="main-content"
variant="section"
/>
{activationError && (
<Callout type="danger" title="Unable to update default provider">
@@ -974,14 +967,12 @@ export default function Page() {
size: 16,
isHighlighted,
})}
<div className="flex flex-col gap-0.5">
<Text as="p" mainUiAction text05>
{label}
</Text>
<Text as="p" secondaryBody text03>
{subtitle}
</Text>
</div>
<Content
title={label}
description={subtitle}
sizePreset="main-ui"
variant="section"
/>
</div>
<div className="flex items-center justify-end gap-2">
{isConfigured && (
@@ -1045,20 +1036,13 @@ export default function Page() {
</div>
</div>
<div className="flex w-full max-w-[960px] flex-col gap-3">
<div className="flex flex-col gap-0.5">
<Text as="p" mainContentEmphasis text05>
Web Crawler
</Text>
<Text
as="p"
className="flex items-start gap-[2px] self-stretch text-text-03"
secondaryBody
text03
>
Used to read the full contents of search result pages.
</Text>
</div>
<div className="flex w-full flex-col gap-3">
<Content
title="Web Crawler"
description="Used to read the full contents of search result pages."
sizePreset="main-content"
variant="section"
/>
{contentActivationError && (
<Callout type="danger" title="Unable to update crawler">
@@ -1173,14 +1157,12 @@ export default function Page() {
size: 16,
isHighlighted: isCurrentCrawler,
})}
<div className="flex flex-col gap-0.5">
<Text as="p" mainUiAction text05>
{label}
</Text>
<Text as="p" secondaryBody text03>
{subtitle}
</Text>
</div>
<Content
title={label}
description={subtitle}
sizePreset="main-ui"
variant="section"
/>
</div>
<div className="flex items-center justify-end gap-2">
{provider.provider_type !== "onyx_web_crawler" &&
@@ -1244,8 +1226,8 @@ export default function Page() {
})}
</div>
</div>
</div>
</>
</SettingsLayouts.Body>
</SettingsLayouts.Root>
<WebProviderSetupModal
isOpen={selectedProviderType !== null}

View File

@@ -1,20 +0,0 @@
import { unstable_noStore as noStore } from "next/cache";
import { InstantSSRAutoRefresh } from "@/components/SSRAutoRefresh";
import { cookies } from "next/headers";
import NRFPage from "./NRFPage";
import { NRFPreferencesProvider } from "@/components/context/NRFPreferencesContext";
import * as AppLayouts from "@/layouts/app-layouts";
export default async function Page() {
noStore();
const requestCookies = await cookies();
return (
<AppLayouts.Root>
<InstantSSRAutoRefresh />
<NRFPreferencesProvider>
<NRFPage />
</NRFPreferencesProvider>
</AppLayouts.Root>
);
}

View File

@@ -126,6 +126,9 @@ export interface SendMessageParams {
temperature?: number;
// Origin of the message for telemetry tracking
origin?: MessageOrigin;
// Additional context injected into the LLM call but not stored/shown in chat.
// Used e.g. by Chrome extension "Read this tab" feature.
additionalContext?: string;
}
export async function* sendMessage({
@@ -142,6 +145,7 @@ export async function* sendMessage({
modelVersion,
temperature,
origin,
additionalContext,
}: SendMessageParams): AsyncGenerator<PacketType, void, unknown> {
// Build payload for new send-chat-message API
const payload = {
@@ -163,6 +167,7 @@ export async function* sendMessage({
: null,
// Default to "unknown" for consistency with backend; callers should set explicitly
origin: origin ?? "unknown",
additional_context: additionalContext ?? null,
};
const body = JSON.stringify(payload);

View File

@@ -75,11 +75,10 @@ export function useOnboardingModal(): OnboardingModalController {
level: existingPersona?.level,
};
// Check if user has completed initial onboarding
// Check if user has completed initial onboarding (only role required, not name)
const hasUserInfo = useMemo(() => {
const existingPersona = getBuildUserPersona();
return !!(user?.personalization?.name && existingPersona?.workArea);
}, [user?.personalization?.name]);
return !!getBuildUserPersona()?.workArea;
}, [user]);
// Check if all providers are configured (skip LLM step entirely if so)
const allProvidersConfigured = useMemo(
@@ -94,7 +93,7 @@ export function useOnboardingModal(): OnboardingModalController {
);
// Auto-open initial onboarding modal on first load
// Shows if: user info is missing OR (admin AND no providers configured)
// Shows if: user info (role) missing OR (admin AND no providers configured)
useEffect(() => {
if (hasInitialized || isLoadingLlm || !user) return;

View File

@@ -0,0 +1,26 @@
import { unstable_noStore as noStore } from "next/cache";
import AppSidebar from "@/sections/sidebar/AppSidebar";
import { getCurrentUserSS } from "@/lib/userSS";
export interface LayoutProps {
children: React.ReactNode;
}
/**
* NRF Main (New Tab) Layout
*
* Shows the app sidebar when the user is authenticated.
* This layout is NOT used by the side-panel route.
*/
export default async function Layout({ children }: LayoutProps) {
noStore();
const user = await getCurrentUserSS();
return (
<div className="flex flex-row w-full h-full">
{user && <AppSidebar />}
{children}
</div>
);
}

View File

@@ -0,0 +1,31 @@
import { unstable_noStore as noStore } from "next/cache";
import { InstantSSRAutoRefresh } from "@/components/SSRAutoRefresh";
import NRFPage from "@/app/nrf/NRFPage";
import { NRFPreferencesProvider } from "@/components/context/NRFPreferencesContext";
import NRFChrome from "../NRFChrome";
/**
* NRF (New Tab Page) Route - No Auth Required
*
* This route is placed outside /app/app/ to bypass the authentication
* requirement in /app/app/layout.tsx. The NRFPage component handles
* unauthenticated users gracefully by showing a login modal instead of
* redirecting, which is better UX for the Chrome extension.
*
* Instead of AppLayouts.Root (which pulls in heavy Header state management),
* we use NRFChrome — a lightweight overlay that renders only the search/chat
* mode toggle and footer, floating transparently over NRFPage's background.
*/
export default async function Page() {
noStore();
return (
<div className="relative w-full h-full">
<InstantSSRAutoRefresh />
<NRFPreferencesProvider>
<NRFPage />
</NRFPreferencesProvider>
<NRFChrome />
</div>
);
}

View File

@@ -0,0 +1,149 @@
"use client";
import { useState } from "react";
import { cn, ensureHrefProtocol, noProp } from "@/lib/utils";
import type { Components } from "react-markdown";
import Text from "@/refresh-components/texts/Text";
import Popover from "@/refresh-components/Popover";
import { OpenButton } from "@opal/components";
import LineItem from "@/refresh-components/buttons/LineItem";
import IconButton from "@/refresh-components/buttons/IconButton";
import { SvgBubbleText, SvgSearchMenu, SvgSidebar } from "@opal/icons";
import MinimalMarkdown from "@/components/chat/MinimalMarkdown";
import { useSettingsContext } from "@/providers/SettingsProvider";
import { AppMode, useAppMode } from "@/providers/AppModeProvider";
import useAppFocus from "@/hooks/useAppFocus";
import { useQueryController } from "@/providers/QueryControllerProvider";
import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled";
import { useAppSidebarContext } from "@/providers/AppSidebarProvider";
import useScreenSize from "@/hooks/useScreenSize";
const footerMarkdownComponents = {
p: ({ children }: { children?: React.ReactNode }) => (
<Text as="p" text03 secondaryAction className="!my-0 text-center">
{children}
</Text>
),
a: ({
href,
className,
children,
...rest
}: React.AnchorHTMLAttributes<HTMLAnchorElement>) => {
const fullHref = ensureHrefProtocol(href);
return (
<a
href={fullHref}
target="_blank"
rel="noopener noreferrer"
{...rest}
className={cn(className, "underline underline-offset-2")}
>
<Text text03 secondaryAction>
{children}
</Text>
</a>
);
},
} satisfies Partial<Components>;
/**
* Lightweight chrome overlay for the NRF page.
*
* Renders only the search/chat mode toggle (top-left) and footer (bottom),
* absolutely positioned so they float transparently over NRFPage's own
* background. This avoids pulling in the full AppLayouts.Root Header which
* carries heavy state management (share/delete/move modals) that the
* extension doesn't need.
*/
export default function NRFChrome() {
const isPaidEnterpriseFeaturesEnabled = usePaidEnterpriseFeaturesEnabled();
const { appMode, setAppMode } = useAppMode();
const settings = useSettingsContext();
const { isMobile } = useScreenSize();
const { setFolded } = useAppSidebarContext();
const appFocus = useAppFocus();
const { classification } = useQueryController();
const [modePopoverOpen, setModePopoverOpen] = useState(false);
const effectiveMode: AppMode = appFocus.isNewSession() ? appMode : "chat";
const customFooterContent =
settings?.enterpriseSettings?.custom_lower_disclaimer_content ||
`[Onyx ${
settings?.webVersion || "dev"
}](https://www.onyx.app/) - Open Source AI Platform`;
const showModeToggle =
isPaidEnterpriseFeaturesEnabled &&
settings.isSearchModeAvailable &&
appFocus.isNewSession() &&
!classification;
const showHeader = isMobile || showModeToggle;
return (
<>
{/* Header chrome — top-left, mirrors position of settings button at top-right */}
{showHeader && (
<div className="absolute top-0 left-0 p-4 z-10 flex flex-row items-center gap-2">
{isMobile && (
<IconButton
icon={SvgSidebar}
onClick={() => setFolded(false)}
internal
/>
)}
{showModeToggle && (
<Popover open={modePopoverOpen} onOpenChange={setModePopoverOpen}>
<Popover.Trigger asChild>
<OpenButton
icon={
effectiveMode === "search" ? SvgSearchMenu : SvgBubbleText
}
>
{effectiveMode === "search" ? "Search" : "Chat"}
</OpenButton>
</Popover.Trigger>
<Popover.Content align="start" width="lg">
<Popover.Menu>
<LineItem
icon={SvgSearchMenu}
selected={effectiveMode === "search"}
description="Quick search for documents"
onClick={noProp(() => {
setAppMode("search");
setModePopoverOpen(false);
})}
>
Search
</LineItem>
<LineItem
icon={SvgBubbleText}
selected={effectiveMode === "chat"}
description="Conversation and research"
onClick={noProp(() => {
setAppMode("chat");
setModePopoverOpen(false);
})}
>
Chat
</LineItem>
</Popover.Menu>
</Popover.Content>
</Popover>
)}
</div>
)}
{/* Footer — bottom-center, transparent background */}
<footer className="absolute bottom-0 left-0 w-full z-10 flex flex-row justify-center items-center gap-2 px-2 pb-2 pointer-events-auto">
<MinimalMarkdown
content={customFooterContent}
className="max-w-full text-center"
components={footerMarkdownComponents}
/>
</footer>
</>
);
}

View File

@@ -11,8 +11,10 @@ import IconButton from "@/refresh-components/buttons/IconButton";
import Modal from "@/refresh-components/Modal";
import { useFilters, useLlmManager } from "@/lib/hooks";
import Dropzone from "react-dropzone";
import { useSendMessageToParent } from "@/lib/extension/utils";
import { useSendMessageToParent, getPanelOrigin } from "@/lib/extension/utils";
import { useNRFPreferences } from "@/components/context/NRFPreferencesContext";
import SidePanelHeader from "@/app/nrf/side-panel/SidePanelHeader";
import { CHROME_MESSAGE } from "@/lib/extension/constants";
import { SettingsPanel } from "@/app/components/nrf/SettingsPanel";
import LoginPage from "@/app/auth/login/LoginPage";
import { sendSetDefaultNewTabMessage } from "@/lib/extension/utils";
@@ -33,16 +35,9 @@ import ChatScrollContainer from "@/sections/chat/ChatScrollContainer";
import WelcomeMessage from "@/app/app/components/WelcomeMessage";
import useChatSessions from "@/hooks/useChatSessions";
import { cn } from "@/lib/utils";
import Logo from "@/refresh-components/Logo";
import Spacer from "@/refresh-components/Spacer";
import { useAppSidebarContext } from "@/providers/AppSidebarProvider";
import { DEFAULT_CONTEXT_TOKENS } from "@/lib/constants";
import {
SvgUser,
SvgMenu,
SvgExternalLink,
SvgAlertTriangle,
} from "@opal/icons";
import { SvgUser, SvgMenu, SvgAlertTriangle } from "@opal/icons";
import { useAppBackground } from "@/providers/AppBackgroundProvider";
import { MinimalOnyxDocument } from "@/lib/search/interfaces";
import DocumentsSidebar from "@/sections/document-sidebar/DocumentsSidebar";
@@ -67,14 +62,6 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
const searchParams = useSearchParams();
const filterManager = useFilters();
const { user, authTypeMetadata } = useUser();
const { setFolded } = useAppSidebarContext();
// Hide sidebar when in side panel mode
useEffect(() => {
if (isSidePanel) {
setFolded(true);
}
}, [isSidePanel, setFolded]);
// Chat sessions
const { refreshChatSessions } = useChatSessions();
@@ -129,6 +116,8 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
// State
const [message, setMessage] = useState("");
const [settingsOpen, setSettingsOpen] = useState<boolean>(false);
const [tabReadingEnabled, setTabReadingEnabled] = useState<boolean>(false);
const [currentTabUrl, setCurrentTabUrl] = useState<string | null>(null);
const [presentingDocument, setPresentingDocument] =
useState<MinimalOnyxDocument | null>(null);
@@ -137,6 +126,12 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
const updateCurrentDocumentSidebarVisible = useChatSessionStore(
(state) => state.updateCurrentDocumentSidebarVisible
);
const setCurrentSession = useChatSessionStore(
(state) => state.setCurrentSession
);
const currentSessionId = useChatSessionStore(
(state) => state.currentSessionId
);
// Memoized callback for closing document sidebar
const handleDocumentSidebarClose = useCallback(() => {
@@ -201,6 +196,26 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
useSendMessageToParent();
// Listen for tab URL updates from the Chrome extension
useEffect(() => {
if (!isSidePanel) return;
function handleExtensionMessage(event: MessageEvent) {
// Only trust messages from the Chrome extension parent.
// Checking the origin (chrome-extension://) prevents a non-extension
// page that embeds NRFPage as an iframe from injecting arbitrary URLs
// into the prompt context via TAB_URL_UPDATED.
if (!event.origin.startsWith("chrome-extension://")) return;
if (event.source !== window.parent) return;
if (event.data?.type === CHROME_MESSAGE.TAB_URL_UPDATED) {
setCurrentTabUrl(event.data.url as string);
}
}
window.addEventListener("message", handleExtensionMessage);
return () => window.removeEventListener("message", handleExtensionMessage);
}, [isSidePanel]);
const toggleSettings = () => {
setSettingsOpen((prev) => !prev);
};
@@ -268,23 +283,16 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
[handleMessageSpecificFileUpload]
);
// Handler for chat submission (used by query controller)
const onChat = useCallback(
(chatMessage: string) => {
resetInputBar();
onSubmit({
message: chatMessage,
currentMessageFiles: currentMessageFiles,
deepResearch: deepResearchEnabled,
});
},
[onSubmit, currentMessageFiles, deepResearchEnabled, resetInputBar]
);
// Handle submit from AppInputBar - routes through query controller for search/chat classification
const handleChatInputSubmit = useCallback(
async (submittedMessage: string) => {
if (!submittedMessage.trim()) return;
const additionalContext =
tabReadingEnabled && currentTabUrl
? `The user is currently viewing: ${currentTabUrl}. Use the open_url tool to read this page and use its content as additional context for your response.`
: undefined;
// If we already have messages (chat session started), always use chat mode
// (matches AppPage behavior where existing sessions bypass classification)
if (hasMessages) {
@@ -293,9 +301,22 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
message: submittedMessage,
currentMessageFiles: currentMessageFiles,
deepResearch: deepResearchEnabled,
additionalContext,
});
return;
}
// Build an onChat closure that captures additionalContext for this submission
const onChat = (chatMessage: string) => {
resetInputBar();
onSubmit({
message: chatMessage,
currentMessageFiles: currentMessageFiles,
deepResearch: deepResearchEnabled,
additionalContext,
});
};
// Use submitQuery which will classify the query and either:
// - Route to search (sets classification to "search" and shows SearchUI)
// - Route to chat (calls onChat callback)
@@ -308,7 +329,8 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
deepResearchEnabled,
resetInputBar,
submitQuery,
onChat,
tabReadingEnabled,
currentTabUrl,
]
);
@@ -331,9 +353,34 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
});
}, [messageHistory, onSubmit, currentMessageFiles, deepResearchEnabled]);
const handleOpenInOnyx = () => {
window.open(`${window.location.origin}/app`, "_blank");
};
// Start a new chat session in the side panel
const handleNewChat = useCallback(() => {
setCurrentSession(null);
setTabReadingEnabled(false);
setCurrentTabUrl(null);
resetInputBar();
// Notify the service worker so it stops sending tab URL updates
window.parent.postMessage(
{ type: CHROME_MESSAGE.TAB_READING_DISABLED },
getPanelOrigin()
);
}, [setCurrentSession, resetInputBar]);
const handleToggleTabReading = useCallback(() => {
const next = !tabReadingEnabled;
setTabReadingEnabled(next);
if (!next) {
setCurrentTabUrl(null);
}
window.parent.postMessage(
{
type: next
? CHROME_MESSAGE.TAB_READING_ENABLED
: CHROME_MESSAGE.TAB_READING_DISABLED,
},
getPanelOrigin()
);
}, [tabReadingEnabled]);
// Handle search result document click
const handleSearchDocumentClick = useCallback(
@@ -362,18 +409,10 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
{/* Side panel header */}
{isSidePanel && (
<header className="flex items-center justify-between px-4 py-3 border-b border-border-01 bg-background">
<div className="flex items-center gap-2">
<Logo />
</div>
<Button
tertiary
rightIcon={SvgExternalLink}
onClick={handleOpenInOnyx}
>
Open in Onyx
</Button>
</header>
<SidePanelHeader
onNewChat={handleNewChat}
chatSessionId={currentSessionId}
/>
)}
{/* Settings button */}
@@ -382,9 +421,8 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
<IconButton
icon={SvgMenu}
onClick={toggleSettings}
tertiary
secondary
tooltip="Open settings"
className="bg-mask-02 backdrop-blur-[12px] rounded-full shadow-01 hover:bg-mask-03"
/>
</div>
)}
@@ -393,18 +431,22 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
{({ getRootProps }) => (
<div
{...getRootProps()}
className="h-full w-full flex flex-col items-center outline-none"
className={cn(
"flex-1 min-h-0 w-full flex flex-col items-center outline-none",
isSidePanel && "px-3"
)}
>
{/* Chat area with messages */}
{hasMessages && resolvedAssistant && (
<>
{/* Fake header */}
<Spacer rem={2} />
{/* Fake header - pushes content below absolute settings button (non-side-panel only) */}
{!isSidePanel && <Spacer rem={2} />}
<ChatScrollContainer
sessionId="nrf-session"
anchorSelector={anchorSelector}
autoScroll={autoScrollEnabled}
isStreaming={isStreaming}
hideScrollbar={isSidePanel}
>
<ChatUI
liveAssistant={resolvedAssistant}
@@ -433,7 +475,11 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
{/* AppInputBar container - in normal flex flow like AppPage */}
<div
ref={inputRef}
className="w-full max-w-[var(--app-page-main-content-width)] flex flex-col px-4"
className={cn(
"w-full flex flex-col",
!isSidePanel &&
"max-w-[var(--app-page-main-content-width)] px-4"
)}
>
<AppInputBar
ref={chatInputBarRef}
@@ -456,8 +502,13 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
disabled={
!llmManager.isLoadingProviders && !llmManager.hasAnyProvider
}
{...(isSidePanel && {
tabReadingEnabled,
currentTabUrl,
onToggleTabReading: handleToggleTabReading,
})}
/>
<Spacer rem={0.5} />
<Spacer rem={isSidePanel ? 1 : 0.5} />
</div>
{/* Search results - shown when query is classified as search */}

View File

@@ -0,0 +1,15 @@
import { ProjectsProvider } from "@/providers/ProjectsContext";
export interface LayoutProps {
children: React.ReactNode;
}
/**
* NRF Root Layout - Shared by all NRF routes
*
* Provides ProjectsProvider (needed by NRFPage) without auth redirect.
* Sidebar and chrome are handled by sub-layouts / individual pages.
*/
export default function Layout({ children }: LayoutProps) {
return <ProjectsProvider>{children}</ProjectsProvider>;
}

View File

@@ -0,0 +1,40 @@
"use client";
import Logo from "@/refresh-components/Logo";
import IconButton from "@/refresh-components/buttons/IconButton";
import { SvgEditBig, SvgExternalLink } from "@opal/icons";
interface SidePanelHeaderProps {
onNewChat: () => void;
chatSessionId?: string | null;
}
export default function SidePanelHeader({
onNewChat,
chatSessionId,
}: SidePanelHeaderProps) {
const handleOpenInOnyx = () => {
const path = chatSessionId ? `/app?chatId=${chatSessionId}` : "/app";
window.open(`${window.location.origin}${path}`, "_blank");
};
return (
<header className="flex items-center justify-between px-4 py-3 border-b border-border-01 bg-background">
<Logo />
<div className="flex items-center gap-1">
<IconButton
icon={SvgEditBig}
onClick={onNewChat}
tertiary
tooltip="New chat"
/>
<IconButton
icon={SvgExternalLink}
onClick={handleOpenInOnyx}
tertiary
tooltip="Open in Onyx"
/>
</div>
</header>
);
}

View File

@@ -1,18 +1,24 @@
import { unstable_noStore as noStore } from "next/cache";
import { InstantSSRAutoRefresh } from "@/components/SSRAutoRefresh";
import NRFPage from "@/app/app/nrf/NRFPage";
import NRFPage from "@/app/nrf/NRFPage";
import { NRFPreferencesProvider } from "@/components/context/NRFPreferencesContext";
import * as AppLayouts from "@/layouts/app-layouts";
/**
* NRF Side Panel Route - No Auth Required
*
* Side panel variant no NRFChrome overlay needed since the side panel
* has its own header (logo + "Open in Onyx" button) and doesn't show
* the mode toggle or footer.
*/
export default async function Page() {
noStore();
return (
<AppLayouts.Root>
<>
<InstantSSRAutoRefresh />
<NRFPreferencesProvider>
<NRFPage isSidePanel />
</NRFPreferencesProvider>
</AppLayouts.Root>
</>
);
}

View File

@@ -49,7 +49,7 @@ export async function searchDocuments(
const request: SendSearchQueryRequest = {
search_query: query,
filters: options?.filters,
num_hits: options?.numHits ?? 50,
num_hits: options?.numHits ?? 30,
include_content: options?.includeContent ?? false,
stream: false,
};

View File

@@ -67,7 +67,7 @@ export function QueryControllerProvider({
searchQuery,
{
filters,
numHits: 50,
numHits: 30,
includeContent: false,
signal: controller.signal,
}

View File

@@ -89,6 +89,8 @@ export interface OnSubmitProps {
isSeededChat?: boolean;
modelOverride?: LlmDescriptor;
regenerationRequest?: RegenerationRequest | null;
// Additional context injected into the LLM call but not stored/shown in chat.
additionalContext?: string;
}
interface RegenerationRequest {
@@ -368,6 +370,7 @@ export default function useChatController({
isSeededChat,
modelOverride,
regenerationRequest,
additionalContext,
}: OnSubmitProps) => {
const projectId = params(SEARCH_PARAM_NAMES.PROJECT_ID);
{
@@ -725,6 +728,7 @@ export default function useChatController({
: undefined,
forcedToolId: effectiveForcedToolId,
origin: messageOrigin,
additionalContext,
});
const delay = (ms: number) => {

View File

@@ -36,10 +36,11 @@
import BackButton from "@/refresh-components/buttons/BackButton";
import { cn } from "@/lib/utils";
import Separator from "@/refresh-components/Separator";
import Text from "@/refresh-components/texts/Text";
import { WithoutStyles } from "@/types";
import { IconProps } from "@opal/types";
import { IconFunctionComponent } from "@opal/types";
import { HtmlHTMLAttributes, useEffect, useRef, useState } from "react";
import { Content } from "@opal/layouts";
import Spacer from "@/refresh-components/Spacer";
const widthClasses = {
md: "w-[min(50rem,100%)]",
@@ -163,7 +164,7 @@ function SettingsRoot({ width = "md", ...props }: SettingsRootProps) {
* ```
*/
export interface SettingsHeaderProps {
icon: React.FunctionComponent<IconProps>;
icon: IconFunctionComponent;
title: string;
description?: string;
children?: React.ReactNode;
@@ -184,7 +185,10 @@ function SettingsHeader({
}: SettingsHeaderProps) {
const [showShadow, setShowShadow] = useState(false);
const headerRef = useRef<HTMLDivElement>(null);
const isSticky = !!rightChildren; //headers with actions are always sticky, others are not
// # NOTE (@Subash-Mohan)
// Headers with actions are always sticky, others are not.
const isSticky = !!rightChildren;
useEffect(() => {
if (!isSticky) return;
@@ -221,34 +225,35 @@ function SettingsHeader({
<BackButton behaviorOverride={onBack} />
</div>
)}
<div
className={cn("flex flex-col gap-6 px-4", backButton ? "pt-2" : "pt-4")}
>
<div className="flex flex-col">
<div className="flex flex-row justify-between items-center gap-4">
<Icon className="stroke-text-04 h-[1.75rem] w-[1.75rem]" />
{rightChildren}
</div>
<div className={cn("flex flex-col", separator ? "pb-6" : "pb-2")}>
<div aria-label="admin-page-title">
<Text as="p" headingH2>
{title}
</Text>
</div>
{description && (
<Text secondaryBody text03>
{description}
</Text>
)}
<Spacer vertical rem={1} />
<div className="flex flex-col gap-6 px-4">
<div className="flex w-full justify-between">
<div aria-label="admin-page-title">
<Content
icon={Icon}
title={title}
description={description}
sizePreset="headline"
variant="heading"
/>
</div>
{rightChildren}
</div>
{children}
</div>
{separator && (
{separator ? (
<>
<Spacer vertical rem={1.5} />
<Separator noPadding className="px-4" />
</>
) : (
<Spacer vertical rem={0.5} />
)}
{isSticky && (
<div
className={cn(

View File

@@ -17,6 +17,9 @@ export const CHROME_MESSAGE = {
LOAD_NEW_CHAT_PAGE: "LOAD_NEW_CHAT_PAGE",
LOAD_NEW_PAGE: "LOAD_NEW_PAGE",
AUTH_REQUIRED: "AUTH_REQUIRED",
TAB_READING_ENABLED: "TAB_READING_ENABLED",
TAB_READING_DISABLED: "TAB_READING_DISABLED",
TAB_URL_UPDATED: "TAB_URL_UPDATED",
};
export const SUBMIT_MESSAGE_TYPES = {

View File

@@ -3,6 +3,14 @@ import { CHROME_MESSAGE } from "./constants";
export type ExtensionContext = "new_tab" | "side_panel" | null;
// Returns the origin of the Chrome extension panel (our parent frame).
// window.location.ancestorOrigins is Chrome-specific and only populated
// when the page is loaded inside an iframe (e.g. the Chrome extension panel).
// Falls back to "*" in regular browser contexts (no parent frame).
export function getPanelOrigin(): string {
return window.location.ancestorOrigins?.[0] ?? "*";
}
export function getExtensionContext(): {
isExtension: boolean;
context: ExtensionContext;
@@ -11,26 +19,29 @@ export function getExtensionContext(): {
return { isExtension: false, context: null };
const pathname = window.location.pathname;
if (pathname.includes("/app/nrf/side-panel")) {
if (pathname.includes("/nrf/side-panel")) {
return { isExtension: true, context: "side_panel" };
}
if (pathname.includes("/app/nrf")) {
if (pathname.includes("/nrf")) {
return { isExtension: true, context: "new_tab" };
}
return { isExtension: false, context: null };
}
export function sendSetDefaultNewTabMessage(value: boolean) {
if (typeof window !== "undefined" && window.parent) {
if (typeof window !== "undefined" && window.parent !== window) {
window.parent.postMessage(
{ type: CHROME_MESSAGE.SET_DEFAULT_NEW_TAB, value },
"*"
getPanelOrigin()
);
}
}
export const sendAuthRequiredMessage = () => {
if (typeof window !== "undefined" && window.parent) {
window.parent.postMessage({ type: CHROME_MESSAGE.AUTH_REQUIRED }, "*");
if (typeof window !== "undefined" && window.parent !== window) {
window.parent.postMessage(
{ type: CHROME_MESSAGE.AUTH_REQUIRED },
getPanelOrigin()
);
}
};
@@ -41,8 +52,11 @@ export const useSendAuthRequiredMessage = () => {
};
export const sendMessageToParent = () => {
if (typeof window !== "undefined" && window.parent) {
window.parent.postMessage({ type: CHROME_MESSAGE.ONYX_APP_LOADED }, "*");
if (typeof window !== "undefined" && window.parent !== window) {
window.parent.postMessage(
{ type: CHROME_MESSAGE.ONYX_APP_LOADED },
getPanelOrigin()
);
}
};
export const useSendMessageToParent = () => {

View File

@@ -7,15 +7,26 @@ interface LinguistLanguage {
filenames?: string[];
}
const allLanguages = Object.values(languages) as LinguistLanguage[];
// Collect extensions that linguist-languages assigns to "Markdown" so we can
// exclude them from the code-language map
const markdownExtensions = new Set(
allLanguages
.find((lang) => lang.name === "Markdown")
?.extensions?.map((ext) => ext.toLowerCase()) ?? []
);
// Build extension → language name and filename → language name maps at module load
const extensionMap = new Map<string, string>();
const filenameMap = new Map<string, string>();
for (const lang of Object.values(languages) as LinguistLanguage[]) {
for (const lang of allLanguages) {
if (lang.type !== "programming") continue;
const name = lang.name.toLowerCase();
for (const ext of lang.extensions ?? []) {
if (markdownExtensions.has(ext.toLowerCase())) continue;
// First language to claim an extension wins
if (!extensionMap.has(ext)) {
extensionMap.set(ext, name);
@@ -38,3 +49,12 @@ export function getCodeLanguage(name: string): string | null {
const ext = lower.match(/\.[^.]+$/)?.[0];
return (ext && extensionMap.get(ext)) ?? filenameMap.get(lower) ?? null;
}
/**
* Returns true if the file name has a Markdown extension (as defined by
* linguist-languages) and should be rendered as rich text rather than code.
*/
export function isMarkdownFile(name: string): boolean {
const ext = name.toLowerCase().match(/\.[^.]+$/)?.[0];
return !!ext && markdownExtensions.has(ext);
}

View File

@@ -240,7 +240,7 @@ export interface SendSearchQueryRequest {
filters?: BaseFilters | null;
num_docs_fed_to_llm_selection?: number | null;
run_query_expansion?: boolean;
num_hits?: number; // default 50
num_hits?: number; // default 30
include_content?: boolean;
stream?: boolean;
}

View File

@@ -114,7 +114,7 @@ const heightClasses = {
* </Modal.Content>
* ```
*/
interface ModalContentProps
export interface ModalContentProps
extends WithoutStyles<
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
> {

View File

@@ -135,8 +135,10 @@ function PopoverContent({
ref={ref}
align={align}
sideOffset={sideOffset}
collisionPadding={8}
className={cn(
"bg-background-neutral-00 p-1 z-popover rounded-12 overflow-hidden border shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
"max-h-[var(--radix-popover-content-available-height)]",
widthClasses[width]
)}
{...props}

View File

@@ -0,0 +1,21 @@
import { cn } from "@/lib/utils";
interface PreviewImageProps {
src: string;
alt: string;
className?: string;
}
export default function PreviewImage({
src,
alt,
className,
}: PreviewImageProps) {
return (
<img
src={src}
alt={alt}
className={cn("object-contain object-center", className)}
/>
);
}

View File

@@ -1,8 +1,8 @@
"use client";
import Button from "@/refresh-components/buttons/Button";
import { useRouter } from "next/navigation";
import type { Route } from "next";
import { Button } from "@opal/components";
import { SvgArrowLeft } from "@opal/icons";
export interface BackButtonProps {
@@ -18,8 +18,8 @@ export default function BackButton({
return (
<Button
leftIcon={SvgArrowLeft}
tertiary
icon={SvgArrowLeft}
prominence="tertiary"
onClick={() => {
if (behaviorOverride) {
behaviorOverride();

Some files were not shown because too many files have changed in this diff Show More