Compare commits

..

1 Commits

Author SHA1 Message Date
Jamison Lahman
0c709a4fb6 nit 2026-04-09 08:20:13 -07:00
121 changed files with 1584 additions and 5597 deletions

View File

@@ -1,62 +0,0 @@
FROM ubuntu:26.04@sha256:cc925e589b7543b910fea57a240468940003fbfc0515245a495dd0ad8fe7cef1
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
fd-find \
fzf \
git \
jq \
less \
make \
neovim \
openssh-client \
python3-venv \
ripgrep \
sudo \
ca-certificates \
iptables \
ipset \
iproute2 \
dnsutils \
unzip \
wget \
zsh \
&& curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
&& apt-get install -y nodejs \
&& install -m 0755 -d /etc/apt/keyrings \
&& curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg -o /etc/apt/keyrings/githubcli-archive-keyring.gpg \
&& chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" > /etc/apt/sources.list.d/github-cli.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends gh \
&& apt-get clean && rm -rf /var/lib/apt/lists/*
# fd-find installs as fdfind on Debian/Ubuntu — symlink to fd
RUN ln -sf "$(which fdfind)" /usr/local/bin/fd
# Install uv (Python package manager)
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /usr/local/bin/
# Create non-root dev user with passwordless sudo
RUN useradd -m -s /bin/zsh dev && \
echo "dev ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/dev && \
chmod 0440 /etc/sudoers.d/dev
ENV DEVCONTAINER=true
RUN mkdir -p /workspace && \
chown -R dev:dev /workspace
WORKDIR /workspace
# Install Claude Code
ARG CLAUDE_CODE_VERSION=latest
RUN npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}
# Configure zsh — source the repo-local zshrc so shell customization
# doesn't require an image rebuild.
RUN chsh -s /bin/zsh root && \
for rc in /root/.zshrc /home/dev/.zshrc; do \
echo '[ -f /workspace/.devcontainer/zshrc ] && . /workspace/.devcontainer/zshrc' >> "$rc"; \
done && \
chown dev:dev /home/dev/.zshrc

View File

@@ -1,86 +0,0 @@
# Onyx Dev Container
A containerized development environment for working on Onyx.
## What's included
- Ubuntu 26.04 base image
- Node.js 20, uv, Claude Code
- GitHub CLI (`gh`)
- Neovim, ripgrep, fd, fzf, jq, make, wget, unzip
- Zsh as default shell (sources host `~/.zshrc` if available)
- Python venv auto-activation
- Network firewall (default-deny, whitelists npm, GitHub, Anthropic APIs, Sentry, and VS Code update servers)
## Usage
### CLI (`ods dev`)
The [`ods` devtools CLI](../tools/ods/README.md) provides workspace-aware wrappers
for all devcontainer operations (also available as `ods dc`):
```bash
# Start the container
ods dev up
# Open a shell
ods dev into
# Run a command
ods dev exec npm test
# Stop the container
ods dev stop
```
## Restarting the container
```bash
# Restart the container
ods dev restart
# Pull the latest published image and recreate
ods dev rebuild
```
## Image
The devcontainer uses a prebuilt image published to `onyxdotapp/onyx-devcontainer`.
The tag is pinned in `devcontainer.json` — no local build is required.
To build the image locally (e.g. while iterating on the Dockerfile):
```bash
docker buildx bake devcontainer
```
The `devcontainer` target is defined in `docker-bake.hcl` at the repo root.
## User & permissions
The container runs as the `dev` user by default (`remoteUser` in devcontainer.json).
An init script (`init-dev-user.sh`) runs at container start to ensure the active
user has read/write access to the bind-mounted workspace:
- **Standard Docker** — `dev`'s UID/GID is remapped to match the workspace owner,
so file permissions work seamlessly.
- **Rootless Docker** — The workspace appears as root-owned (UID 0) inside the
container due to user-namespace mapping. `ods dev up` auto-detects rootless Docker
and sets `DEVCONTAINER_REMOTE_USER=root` so the container runs as root — which
maps back to your host user via the user namespace. New files are owned by your
host UID and no ACL workarounds are needed.
To override the auto-detection, set `DEVCONTAINER_REMOTE_USER` before running
`ods dev up`.
## Firewall
The container starts with a default-deny firewall (`init-firewall.sh`) that only allows outbound traffic to:
- npm registry
- GitHub
- Anthropic API
- Sentry
- VS Code update servers
This requires the `NET_ADMIN` and `NET_RAW` capabilities, which are added via `runArgs` in `devcontainer.json`.

View File

@@ -1,23 +0,0 @@
{
"name": "Onyx Dev Sandbox",
"image": "onyxdotapp/onyx-devcontainer@sha256:12184169c5bcc9cca0388286d5ffe504b569bc9c37bfa631b76ee8eee2064055",
"runArgs": ["--cap-add=NET_ADMIN", "--cap-add=NET_RAW"],
"mounts": [
"source=${localEnv:HOME}/.claude,target=/home/dev/.claude,type=bind",
"source=${localEnv:HOME}/.claude.json,target=/home/dev/.claude.json,type=bind",
"source=${localEnv:HOME}/.zshrc,target=/home/dev/.zshrc.host,type=bind,readonly",
"source=${localEnv:HOME}/.gitconfig,target=/home/dev/.gitconfig,type=bind,readonly",
"source=${localEnv:HOME}/.config/nvim,target=/home/dev/.config/nvim,type=bind,readonly",
"source=onyx-devcontainer-cache,target=/home/dev/.cache,type=volume",
"source=onyx-devcontainer-local,target=/home/dev/.local,type=volume"
],
"containerEnv": {
"SSH_AUTH_SOCK": "/tmp/ssh-agent.sock"
},
"remoteUser": "${localEnv:DEVCONTAINER_REMOTE_USER:dev}",
"updateRemoteUserUID": false,
"workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind,consistency=delegated",
"workspaceFolder": "/workspace",
"postStartCommand": "sudo bash /workspace/.devcontainer/init-dev-user.sh && sudo bash /workspace/.devcontainer/init-firewall.sh",
"waitFor": "postStartCommand"
}

View File

@@ -1,107 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Remap the dev user's UID/GID to match the workspace owner so that
# bind-mounted files are accessible without running as root.
#
# Standard Docker: Workspace is owned by the host user's UID (e.g. 1000).
# We remap dev to that UID -- fast and seamless.
#
# Rootless Docker: Workspace appears as root-owned (UID 0) inside the
# container due to user-namespace mapping. Requires
# DEVCONTAINER_REMOTE_USER=root (set automatically by
# ods dev up). Container root IS the host user, so
# bind-mounts and named volumes are symlinked into /root.
WORKSPACE=/workspace
TARGET_USER=dev
REMOTE_USER="${SUDO_USER:-$TARGET_USER}"
WS_UID=$(stat -c '%u' "$WORKSPACE")
WS_GID=$(stat -c '%g' "$WORKSPACE")
DEV_UID=$(id -u "$TARGET_USER")
DEV_GID=$(id -g "$TARGET_USER")
# devcontainer.json bind-mounts and named volumes target /home/dev regardless
# of remoteUser. When running as root ($HOME=/root), Phase 1 bridges the gap
# with symlinks from ACTIVE_HOME → MOUNT_HOME.
MOUNT_HOME=/home/"$TARGET_USER"
if [ "$REMOTE_USER" = "root" ]; then
ACTIVE_HOME="/root"
else
ACTIVE_HOME="$MOUNT_HOME"
fi
# ── Phase 1: home directory setup ───────────────────────────────────
# ~/.local and ~/.cache are named Docker volumes mounted under MOUNT_HOME.
mkdir -p "$MOUNT_HOME"/.local/state "$MOUNT_HOME"/.local/share
# When running as root, symlink bind-mounts and named volumes into /root
# so that $HOME-relative tools (Claude Code, git, etc.) find them.
if [ "$ACTIVE_HOME" != "$MOUNT_HOME" ]; then
for item in .claude .cache .local; do
[ -d "$MOUNT_HOME/$item" ] || continue
if [ -e "$ACTIVE_HOME/$item" ] && [ ! -L "$ACTIVE_HOME/$item" ]; then
echo "warning: replacing $ACTIVE_HOME/$item with symlink to $MOUNT_HOME/$item" >&2
rm -rf "$ACTIVE_HOME/$item"
fi
ln -sfn "$MOUNT_HOME/$item" "$ACTIVE_HOME/$item"
done
# Symlink files (not directories).
for file in .claude.json .gitconfig .zshrc.host; do
[ -f "$MOUNT_HOME/$file" ] && ln -sf "$MOUNT_HOME/$file" "$ACTIVE_HOME/$file"
done
# Nested mount: .config/nvim
if [ -d "$MOUNT_HOME/.config/nvim" ]; then
mkdir -p "$ACTIVE_HOME/.config"
if [ -e "$ACTIVE_HOME/.config/nvim" ] && [ ! -L "$ACTIVE_HOME/.config/nvim" ]; then
echo "warning: replacing $ACTIVE_HOME/.config/nvim with symlink" >&2
rm -rf "$ACTIVE_HOME/.config/nvim"
fi
ln -sfn "$MOUNT_HOME/.config/nvim" "$ACTIVE_HOME/.config/nvim"
fi
fi
# ── Phase 2: workspace access ───────────────────────────────────────
# Root always has workspace access; Phase 1 handled home setup.
if [ "$REMOTE_USER" = "root" ]; then
exit 0
fi
# Already matching -- nothing to do.
if [ "$WS_UID" = "$DEV_UID" ] && [ "$WS_GID" = "$DEV_GID" ]; then
exit 0
fi
if [ "$WS_UID" != "0" ]; then
# ── Standard Docker ──────────────────────────────────────────────
# Workspace is owned by a non-root UID (the host user).
# Remap dev's UID/GID to match.
if [ "$DEV_GID" != "$WS_GID" ]; then
if ! groupmod -g "$WS_GID" "$TARGET_USER" 2>&1; then
echo "warning: failed to remap $TARGET_USER GID to $WS_GID" >&2
fi
fi
if [ "$DEV_UID" != "$WS_UID" ]; then
if ! usermod -u "$WS_UID" -g "$WS_GID" "$TARGET_USER" 2>&1; then
echo "warning: failed to remap $TARGET_USER UID to $WS_UID" >&2
fi
fi
if ! chown -R "$TARGET_USER":"$TARGET_USER" "$MOUNT_HOME" 2>&1; then
echo "warning: failed to chown $MOUNT_HOME" >&2
fi
else
# ── Rootless Docker ──────────────────────────────────────────────
# Workspace is root-owned (UID 0) due to user-namespace mapping.
# The supported path is remoteUser=root (set DEVCONTAINER_REMOTE_USER=root),
# which is handled above. If we reach here, the user is running as dev
# under rootless Docker without the override.
echo "error: rootless Docker detected but remoteUser is not root." >&2
echo " Set DEVCONTAINER_REMOTE_USER=root before starting the container," >&2
echo " or use 'ods dev up' which sets it automatically." >&2
exit 1
fi

View File

@@ -1,105 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
echo "Setting up firewall..."
# Preserve docker dns resolution
DOCKER_DNS_RULES=$(iptables-save | grep -E "^-A.*-d 127.0.0.11/32" || true)
# Flush all rules
iptables -t nat -F
iptables -t nat -X
iptables -t mangle -F
iptables -t mangle -X
iptables -F
iptables -X
# Restore docker dns rules
if [ -n "$DOCKER_DNS_RULES" ]; then
echo "$DOCKER_DNS_RULES" | iptables-restore -n
fi
# Create ipset for allowed destinations
ipset create allowed-domains hash:net || true
ipset flush allowed-domains
# Fetch GitHub IP ranges (IPv4 only -- ipset hash:net and iptables are IPv4)
GITHUB_IPS=$(curl -s https://api.github.com/meta | jq -r '.api[]' 2>/dev/null | grep -v ':' || echo "")
for ip in $GITHUB_IPS; do
if ! ipset add allowed-domains "$ip" -exist 2>&1; then
echo "warning: failed to add GitHub IP $ip to allowlist" >&2
fi
done
# Resolve allowed domains
ALLOWED_DOMAINS=(
"registry.npmjs.org"
"api.anthropic.com"
"api-staging.anthropic.com"
"files.anthropic.com"
"sentry.io"
"update.code.visualstudio.com"
"pypi.org"
"files.pythonhosted.org"
"go.dev"
"storage.googleapis.com"
"static.rust-lang.org"
)
for domain in "${ALLOWED_DOMAINS[@]}"; do
IPS=$(getent ahosts "$domain" 2>/dev/null | awk '{print $1}' | grep -v ':' | sort -u || echo "")
for ip in $IPS; do
if ! ipset add allowed-domains "$ip/32" -exist 2>&1; then
echo "warning: failed to add $domain ($ip) to allowlist" >&2
fi
done
done
# Allow traffic to the Docker gateway so the container can reach host services
# (e.g. the Onyx stack at localhost:3000, localhost:8080, etc.)
DOCKER_GATEWAY=$(ip -4 route show default | awk '{print $3}')
if [ -n "$DOCKER_GATEWAY" ]; then
if ! ipset add allowed-domains "$DOCKER_GATEWAY/32" -exist 2>&1; then
echo "warning: failed to add Docker gateway $DOCKER_GATEWAY to allowlist" >&2
fi
fi
# Set default policies to DROP
iptables -P FORWARD DROP
iptables -P INPUT DROP
iptables -P OUTPUT DROP
# Allow established connections
iptables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
iptables -A OUTPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
# Allow loopback
iptables -A INPUT -i lo -j ACCEPT
iptables -A OUTPUT -o lo -j ACCEPT
# Allow DNS
iptables -A OUTPUT -p udp --dport 53 -j ACCEPT
iptables -A OUTPUT -p tcp --dport 53 -j ACCEPT
# Allow outbound to allowed destinations
iptables -A OUTPUT -m set --match-set allowed-domains dst -j ACCEPT
# Reject unauthorized outbound
iptables -A OUTPUT -j REJECT --reject-with icmp-host-unreachable
# Validate firewall configuration
echo "Validating firewall configuration..."
BLOCKED_SITES=("example.com" "google.com" "facebook.com")
for site in "${BLOCKED_SITES[@]}"; do
if timeout 2 ping -c 1 "$site" &>/dev/null; then
echo "Warning: $site is still reachable"
fi
done
if ! timeout 5 curl -s https://api.github.com/meta > /dev/null; then
echo "Warning: GitHub API is not accessible"
fi
echo "Firewall setup complete"

View File

@@ -1,10 +0,0 @@
# Devcontainer zshrc — sourced automatically for both root and dev users.
# Edit this file to customize the shell without rebuilding the image.
# Auto-activate Python venv
if [ -f /workspace/.venv/bin/activate ]; then
. /workspace/.venv/bin/activate
fi
# Source host zshrc if bind-mounted
[ -f ~/.zshrc.host ] && . ~/.zshrc.host

View File

@@ -9,6 +9,7 @@ repos:
rev: d30b4298e4fb63ce8609e29acdbcf4c9018a483c
hooks:
- id: uv-sync
args: ["--locked", "--all-extras"]
- id: uv-lock
- id: uv-export
name: uv-export default.txt
@@ -17,7 +18,7 @@ repos:
"--no-emit-project",
"--no-default-groups",
"--no-hashes",
"--group",
"--extra",
"backend",
"-o",
"backend/requirements/default.txt",
@@ -30,7 +31,7 @@ repos:
"--no-emit-project",
"--no-default-groups",
"--no-hashes",
"--group",
"--extra",
"dev",
"-o",
"backend/requirements/dev.txt",
@@ -43,7 +44,7 @@ repos:
"--no-emit-project",
"--no-default-groups",
"--no-hashes",
"--group",
"--extra",
"ee",
"-o",
"backend/requirements/ee.txt",
@@ -56,7 +57,7 @@ repos:
"--no-emit-project",
"--no-default-groups",
"--no-hashes",
"--group",
"--extra",
"model_server",
"-o",
"backend/requirements/model_server.txt",

3
.vscode/launch.json vendored
View File

@@ -531,7 +531,8 @@
"request": "launch",
"runtimeExecutable": "uv",
"runtimeArgs": [
"sync"
"sync",
"--all-extras"
],
"cwd": "${workspaceFolder}",
"console": "integratedTerminal",

View File

@@ -117,7 +117,7 @@ If using PowerShell, the command slightly differs:
Install the required Python dependencies:
```bash
uv sync
uv sync --all-extras
```
Install Playwright for Python (headless browser required by the Web Connector):

View File

@@ -13,7 +13,6 @@ from ee.onyx.server.license.models import LicenseSource
from onyx.auth.schemas import UserRole
from onyx.cache.factory import get_cache_backend
from onyx.configs.constants import ANONYMOUS_USER_EMAIL
from onyx.db.enums import AccountType
from onyx.db.models import License
from onyx.db.models import User
from onyx.utils.logger import setup_logger
@@ -108,13 +107,12 @@ def get_used_seats(tenant_id: str | None = None) -> int:
Get current seat usage directly from database.
For multi-tenant: counts users in UserTenantMapping for this tenant.
For self-hosted: counts all active users.
For self-hosted: counts all active users (excludes EXT_PERM_USER role
and the anonymous system user).
Only human accounts count toward seat limits.
SERVICE_ACCOUNT (API key dummy users), EXT_PERM_USER, and the
anonymous system user are excluded. BOT (Slack users) ARE counted
because they represent real humans and get upgraded to STANDARD
when they log in via web.
TODO: Exclude API key dummy users from seat counting. API keys create
users with emails like `__DANSWER_API_KEY_*` that should not count toward
seat limits. See: https://linear.app/onyx-app/issue/ENG-3518
"""
if MULTI_TENANT:
from ee.onyx.server.tenants.user_mapping import get_tenant_count
@@ -131,7 +129,6 @@ def get_used_seats(tenant_id: str | None = None) -> int:
User.is_active == True, # type: ignore # noqa: E712
User.role != UserRole.EXT_PERM_USER,
User.email != ANONYMOUS_USER_EMAIL, # type: ignore
User.account_type != AccountType.SERVICE_ACCOUNT,
)
)
return result.scalar() or 0

View File

@@ -11,8 +11,6 @@ require a valid SCIM bearer token.
from __future__ import annotations
import hashlib
import struct
from uuid import UUID
from fastapi import APIRouter
@@ -24,7 +22,6 @@ from fastapi import Response
from fastapi.responses import JSONResponse
from fastapi_users.password import PasswordHelper
from sqlalchemy import func
from sqlalchemy import text
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
@@ -68,25 +65,12 @@ from onyx.db.permissions import recompute_user_permissions__no_commit
from onyx.db.users import assign_user_to_default_groups__no_commit
from onyx.utils.logger import setup_logger
from onyx.utils.variable_functionality import fetch_ee_implementation_or_noop
from shared_configs.contextvars import get_current_tenant_id
logger = setup_logger()
# Group names reserved for system default groups (seeded by migration).
_RESERVED_GROUP_NAMES = frozenset({"Admin", "Basic"})
# Namespace prefix for the seat-allocation advisory lock. Hashed together
# with the tenant ID so the lock is scoped per-tenant (unrelated tenants
# never block each other) and cannot collide with unrelated advisory locks.
_SEAT_LOCK_NAMESPACE = "onyx_scim_seat_lock"
def _seat_lock_id_for_tenant(tenant_id: str) -> int:
"""Derive a stable 64-bit signed int lock id for this tenant's seat lock."""
digest = hashlib.sha256(f"{_SEAT_LOCK_NAMESPACE}:{tenant_id}".encode()).digest()
# pg_advisory_xact_lock takes a signed 8-byte int; unpack as such.
return struct.unpack("q", digest[:8])[0]
class ScimJSONResponse(JSONResponse):
"""JSONResponse with Content-Type: application/scim+json (RFC 7644 §3.1)."""
@@ -225,37 +209,12 @@ def _apply_exclusions(
def _check_seat_availability(dal: ScimDAL) -> str | None:
"""Return an error message if seat limit is reached, else None.
Acquires a transaction-scoped advisory lock so that concurrent
SCIM requests are serialized. IdPs like Okta send provisioning
requests in parallel batches — without serialization the check is
vulnerable to a TOCTOU race where N concurrent requests each see
"seats available", all insert, and the tenant ends up over its
seat limit.
The lock is held until the caller's next COMMIT or ROLLBACK, which
means the seat count cannot change between the check here and the
subsequent INSERT/UPDATE. Each call site in this module follows
the pattern: _check_seat_availability → write → dal.commit()
(which releases the lock for the next waiting request).
"""
"""Return an error message if seat limit is reached, else None."""
check_fn = fetch_ee_implementation_or_noop(
"onyx.db.license", "check_seat_availability", None
)
if check_fn is None:
return None
# Transaction-scoped advisory lock — released on dal.commit() / dal.rollback().
# The lock id is derived from the tenant so unrelated tenants never block
# each other, and from a namespace string so it cannot collide with
# unrelated advisory locks elsewhere in the codebase.
lock_id = _seat_lock_id_for_tenant(get_current_tenant_id())
dal.session.execute(
text("SELECT pg_advisory_xact_lock(:lock_id)"),
{"lock_id": lock_id},
)
result = check_fn(dal.session, seats_needed=1)
if not result.available:
return result.error_message or "Seat limit reached"

View File

@@ -42,9 +42,6 @@ from onyx.connectors.google_drive.file_retrieval import (
get_all_files_in_my_drive_and_shared,
)
from onyx.connectors.google_drive.file_retrieval import get_external_access_for_folder
from onyx.connectors.google_drive.file_retrieval import (
get_files_by_web_view_links_batch,
)
from onyx.connectors.google_drive.file_retrieval import get_files_in_shared_drive
from onyx.connectors.google_drive.file_retrieval import get_folder_metadata
from onyx.connectors.google_drive.file_retrieval import get_root_folder_id
@@ -73,13 +70,11 @@ from onyx.connectors.interfaces import CheckpointedConnectorWithPermSync
from onyx.connectors.interfaces import CheckpointOutput
from onyx.connectors.interfaces import GenerateSlimDocumentOutput
from onyx.connectors.interfaces import NormalizationResult
from onyx.connectors.interfaces import Resolver
from onyx.connectors.interfaces import SecondsSinceUnixEpoch
from onyx.connectors.interfaces import SlimConnectorWithPermSync
from onyx.connectors.models import ConnectorFailure
from onyx.connectors.models import ConnectorMissingCredentialError
from onyx.connectors.models import Document
from onyx.connectors.models import DocumentFailure
from onyx.connectors.models import EntityFailure
from onyx.connectors.models import HierarchyNode
from onyx.connectors.models import SlimDocument
@@ -207,9 +202,7 @@ class DriveIdStatus(Enum):
class GoogleDriveConnector(
SlimConnectorWithPermSync,
CheckpointedConnectorWithPermSync[GoogleDriveCheckpoint],
Resolver,
SlimConnectorWithPermSync, CheckpointedConnectorWithPermSync[GoogleDriveCheckpoint]
):
def __init__(
self,
@@ -1672,82 +1665,6 @@ class GoogleDriveConnector(
start, end, checkpoint, include_permissions=True
)
@override
def resolve_errors(
self,
errors: list[ConnectorFailure],
include_permissions: bool = False,
) -> Generator[Document | ConnectorFailure | HierarchyNode, None, None]:
if self._creds is None or self._primary_admin_email is None:
raise RuntimeError(
"Credentials missing, should not call this method before calling load_credentials"
)
logger.info(f"Resolving {len(errors)} errors")
doc_ids = [
failure.failed_document.document_id
for failure in errors
if failure.failed_document
]
service = get_drive_service(self.creds, self.primary_admin_email)
field_type = (
DriveFileFieldType.WITH_PERMISSIONS
if include_permissions or self.exclude_domain_link_only
else DriveFileFieldType.STANDARD
)
batch_result = get_files_by_web_view_links_batch(service, doc_ids, field_type)
for doc_id, error in batch_result.errors.items():
yield ConnectorFailure(
failed_document=DocumentFailure(
document_id=doc_id,
document_link=doc_id,
),
failure_message=f"Failed to retrieve file during error resolution: {error}",
exception=error,
)
permission_sync_context = (
PermissionSyncContext(
primary_admin_email=self.primary_admin_email,
google_domain=self.google_domain,
)
if include_permissions
else None
)
retrieved_files = [
RetrievedDriveFile(
drive_file=file,
user_email=self.primary_admin_email,
completion_stage=DriveRetrievalStage.DONE,
)
for file in batch_result.files.values()
]
yield from self._get_new_ancestors_for_files(
files=retrieved_files,
seen_hierarchy_node_raw_ids=ThreadSafeSet(),
fully_walked_hierarchy_node_raw_ids=ThreadSafeSet(),
permission_sync_context=permission_sync_context,
add_prefix=True,
)
func_with_args = [
(
self._convert_retrieved_file_to_document,
(rf, permission_sync_context),
)
for rf in retrieved_files
]
results = cast(
list[Document | ConnectorFailure | None],
run_functions_tuples_in_parallel(func_with_args, max_workers=8),
)
for result in results:
if result is not None:
yield result
def _extract_slim_docs_from_google_drive(
self,
checkpoint: GoogleDriveCheckpoint,

View File

@@ -9,7 +9,6 @@ from urllib.parse import urlparse
from googleapiclient.discovery import Resource # type: ignore
from googleapiclient.errors import HttpError # type: ignore
from googleapiclient.http import BatchHttpRequest # type: ignore
from onyx.access.models import ExternalAccess
from onyx.connectors.google_drive.constants import DRIVE_FOLDER_TYPE
@@ -61,8 +60,6 @@ SLIM_FILE_FIELDS = (
)
FOLDER_FIELDS = "nextPageToken, files(id, name, permissions, modifiedTime, webViewLink, shortcutDetails)"
MAX_BATCH_SIZE = 100
HIERARCHY_FIELDS = "id, name, parents, webViewLink, mimeType, driveId"
HIERARCHY_FIELDS_WITH_PERMISSIONS = (
@@ -219,7 +216,7 @@ def get_external_access_for_folder(
def _get_fields_for_file_type(field_type: DriveFileFieldType) -> str:
"""Get the appropriate fields string for files().list() based on the field type enum."""
"""Get the appropriate fields string based on the field type enum"""
if field_type == DriveFileFieldType.SLIM:
return SLIM_FILE_FIELDS
elif field_type == DriveFileFieldType.WITH_PERMISSIONS:
@@ -228,25 +225,6 @@ def _get_fields_for_file_type(field_type: DriveFileFieldType) -> str:
return FILE_FIELDS
def _extract_single_file_fields(list_fields: str) -> str:
"""Convert a files().list() fields string to one suitable for files().get().
List fields look like "nextPageToken, files(field1, field2, ...)"
Single-file fields should be just "field1, field2, ..."
"""
start = list_fields.find("files(")
if start == -1:
return list_fields
inner_start = start + len("files(")
inner_end = list_fields.rfind(")")
return list_fields[inner_start:inner_end]
def _get_single_file_fields(field_type: DriveFileFieldType) -> str:
"""Get the appropriate fields string for files().get() based on the field type enum."""
return _extract_single_file_fields(_get_fields_for_file_type(field_type))
def _get_files_in_parent(
service: Resource,
parent_id: str,
@@ -558,74 +536,3 @@ def get_file_by_web_view_link(
)
.execute()
)
class BatchRetrievalResult:
"""Result of a batch file retrieval, separating successes from errors."""
def __init__(self) -> None:
self.files: dict[str, GoogleDriveFileType] = {}
self.errors: dict[str, Exception] = {}
def get_files_by_web_view_links_batch(
service: GoogleDriveService,
web_view_links: list[str],
field_type: DriveFileFieldType,
) -> BatchRetrievalResult:
"""Retrieve multiple Google Drive files by webViewLink using the batch API.
Returns a BatchRetrievalResult containing successful file retrievals
and errors for any files that could not be fetched.
Automatically splits into chunks of MAX_BATCH_SIZE.
"""
fields = _get_single_file_fields(field_type)
if len(web_view_links) <= MAX_BATCH_SIZE:
return _get_files_by_web_view_links_batch(service, web_view_links, fields)
combined = BatchRetrievalResult()
for i in range(0, len(web_view_links), MAX_BATCH_SIZE):
chunk = web_view_links[i : i + MAX_BATCH_SIZE]
chunk_result = _get_files_by_web_view_links_batch(service, chunk, fields)
combined.files.update(chunk_result.files)
combined.errors.update(chunk_result.errors)
return combined
def _get_files_by_web_view_links_batch(
service: GoogleDriveService,
web_view_links: list[str],
fields: str,
) -> BatchRetrievalResult:
"""Single-batch implementation."""
result = BatchRetrievalResult()
def callback(
request_id: str,
response: GoogleDriveFileType,
exception: Exception | None,
) -> None:
if exception:
logger.warning(f"Error retrieving file {request_id}: {exception}")
result.errors[request_id] = exception
else:
result.files[request_id] = response
batch = cast(BatchHttpRequest, service.new_batch_http_request(callback=callback))
for web_view_link in web_view_links:
try:
file_id = _extract_file_id_from_web_view_link(web_view_link)
request = service.files().get(
fileId=file_id,
supportsAllDrives=True,
fields=fields,
)
batch.add(request, request_id=web_view_link)
except ValueError as e:
logger.warning(f"Failed to extract file ID from {web_view_link}: {e}")
result.errors[web_view_link] = e
batch.execute()
return result

View File

@@ -298,22 +298,6 @@ class CheckpointedConnectorWithPermSync(CheckpointedConnector[CT]):
raise NotImplementedError
class Resolver(BaseConnector):
@abc.abstractmethod
def resolve_errors(
self,
errors: list[ConnectorFailure],
include_permissions: bool = False,
) -> Generator[Document | ConnectorFailure | HierarchyNode, None, None]:
"""Attempts to yield back ALL the documents described by the errors, no checkpointing.
Caller's responsibility is to delete the old ConnectorFailures and replace with the new ones.
If include_permissions is True, the documents will have permissions synced.
May also yield HierarchyNode objects for ancestor folders of resolved documents.
"""
raise NotImplementedError
class HierarchyConnector(BaseConnector):
@abc.abstractmethod
def load_hierarchy(

View File

@@ -60,10 +60,8 @@ logger = setup_logger()
ONE_HOUR = 3600
_MAX_RESULTS_FETCH_IDS = 5000
_MAX_RESULTS_FETCH_IDS = 5000 # 5000
_JIRA_FULL_PAGE_SIZE = 50
# https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issues/
_JIRA_BULK_FETCH_LIMIT = 100
# Constants for Jira field names
_FIELD_REPORTER = "reporter"
@@ -257,13 +255,15 @@ def _bulk_fetch_request(
return resp.json()["issues"]
def _bulk_fetch_batch(
jira_client: JIRA, issue_ids: list[str], fields: str | None
) -> list[dict[str, Any]]:
"""Fetch a single batch (must be <= _JIRA_BULK_FETCH_LIMIT).
On JSONDecodeError, recursively bisects until it succeeds or reaches size 1."""
def bulk_fetch_issues(
jira_client: JIRA, issue_ids: list[str], fields: str | None = None
) -> list[Issue]:
# TODO(evan): move away from this jira library if they continue to not support
# the endpoints we need. Using private fields is not ideal, but
# is likely fine for now since we pin the library version
try:
return _bulk_fetch_request(jira_client, issue_ids, fields)
raw_issues = _bulk_fetch_request(jira_client, issue_ids, fields)
except requests.exceptions.JSONDecodeError:
if len(issue_ids) <= 1:
logger.exception(
@@ -277,25 +277,12 @@ def _bulk_fetch_batch(
f"Jira bulk-fetch JSON decode failed for batch of {len(issue_ids)} issues. "
f"Splitting into sub-batches of {mid} and {len(issue_ids) - mid}."
)
left = _bulk_fetch_batch(jira_client, issue_ids[:mid], fields)
right = _bulk_fetch_batch(jira_client, issue_ids[mid:], fields)
left = bulk_fetch_issues(jira_client, issue_ids[:mid], fields)
right = bulk_fetch_issues(jira_client, issue_ids[mid:], fields)
return left + right
def bulk_fetch_issues(
jira_client: JIRA, issue_ids: list[str], fields: str | None = None
) -> list[Issue]:
# TODO(evan): move away from this jira library if they continue to not support
# the endpoints we need. Using private fields is not ideal, but
# is likely fine for now since we pin the library version
raw_issues: list[dict[str, Any]] = []
for batch in chunked(issue_ids, _JIRA_BULK_FETCH_LIMIT):
try:
raw_issues.extend(_bulk_fetch_batch(jira_client, list(batch), fields))
except Exception as e:
logger.error(f"Error fetching issues: {e}")
raise
except Exception as e:
logger.error(f"Error fetching issues: {e}")
raise
return [
Issue(jira_client._options, jira_client._session, raw=issue)

View File

@@ -1,4 +1,3 @@
from dataclasses import dataclass
from datetime import datetime
from typing import TypedDict
@@ -7,14 +6,6 @@ from pydantic import BaseModel
from onyx.onyxbot.slack.models import ChannelType
@dataclass(frozen=True)
class DirectThreadFetch:
"""Request to fetch a Slack thread directly by channel and timestamp."""
channel_id: str
thread_ts: str
class ChannelMetadata(TypedDict):
"""Type definition for cached channel metadata."""

View File

@@ -19,7 +19,6 @@ from onyx.configs.chat_configs import DOC_TIME_DECAY
from onyx.connectors.models import IndexingDocument
from onyx.connectors.models import TextSection
from onyx.context.search.federated.models import ChannelMetadata
from onyx.context.search.federated.models import DirectThreadFetch
from onyx.context.search.federated.models import SlackMessage
from onyx.context.search.federated.slack_search_utils import ALL_CHANNEL_TYPES
from onyx.context.search.federated.slack_search_utils import build_channel_query_filter
@@ -50,6 +49,7 @@ from onyx.server.federated.models import FederatedConnectorDetail
from onyx.utils.logger import setup_logger
from onyx.utils.threadpool_concurrency import run_functions_tuples_in_parallel
from onyx.utils.timing import log_function_time
from shared_configs.configs import DOC_EMBEDDING_CONTEXT_SIZE
logger = setup_logger()
@@ -58,6 +58,7 @@ HIGHLIGHT_END_CHAR = "\ue001"
CHANNEL_METADATA_CACHE_TTL = 60 * 60 * 24 # 24 hours
USER_PROFILE_CACHE_TTL = 60 * 60 * 24 # 24 hours
SLACK_THREAD_CONTEXT_WINDOW = 3 # Number of messages before matched message to include
CHANNEL_METADATA_MAX_RETRIES = 3 # Maximum retry attempts for channel metadata fetching
CHANNEL_METADATA_RETRY_DELAY = 1 # Initial retry delay in seconds (exponential backoff)
@@ -420,94 +421,6 @@ class SlackQueryResult(BaseModel):
filtered_channels: list[str] # Channels filtered out during this query
def _fetch_thread_from_url(
thread_fetch: DirectThreadFetch,
access_token: str,
channel_metadata_dict: dict[str, ChannelMetadata] | None = None,
) -> SlackQueryResult:
"""Fetch a thread directly from a Slack URL via conversations.replies."""
channel_id = thread_fetch.channel_id
thread_ts = thread_fetch.thread_ts
slack_client = WebClient(token=access_token)
try:
response = slack_client.conversations_replies(
channel=channel_id,
ts=thread_ts,
)
response.validate()
messages: list[dict[str, Any]] = response.get("messages", [])
except SlackApiError as e:
logger.warning(
f"Failed to fetch thread from URL (channel={channel_id}, ts={thread_ts}): {e}"
)
return SlackQueryResult(messages=[], filtered_channels=[])
if not messages:
logger.warning(
f"No messages found for URL override (channel={channel_id}, ts={thread_ts})"
)
return SlackQueryResult(messages=[], filtered_channels=[])
# Build thread text from all messages
thread_text = _build_thread_text(messages, access_token, None, slack_client)
# Get channel name from metadata cache or API
channel_name = "unknown"
if channel_metadata_dict and channel_id in channel_metadata_dict:
channel_name = channel_metadata_dict[channel_id].get("name", "unknown")
else:
try:
ch_response = slack_client.conversations_info(channel=channel_id)
ch_response.validate()
channel_info: dict[str, Any] = ch_response.get("channel", {})
channel_name = channel_info.get("name", "unknown")
except SlackApiError:
pass
# Build the SlackMessage
parent_msg = messages[0]
message_ts = parent_msg.get("ts", thread_ts)
username = parent_msg.get("user", "unknown_user")
parent_text = parent_msg.get("text", "")
snippet = (
parent_text[:50].rstrip() + "..." if len(parent_text) > 50 else parent_text
).replace("\n", " ")
doc_time = datetime.fromtimestamp(float(message_ts))
decay_factor = DOC_TIME_DECAY
doc_age_years = (datetime.now() - doc_time).total_seconds() / (365 * 24 * 60 * 60)
recency_bias = max(1 / (1 + decay_factor * doc_age_years), 0.75)
permalink = (
f"https://slack.com/archives/{channel_id}/p{message_ts.replace('.', '')}"
)
slack_message = SlackMessage(
document_id=f"{channel_id}_{message_ts}",
channel_id=channel_id,
message_id=message_ts,
thread_id=None, # Prevent double-enrichment in thread context fetch
link=permalink,
metadata={
"channel": channel_name,
"time": doc_time.isoformat(),
},
timestamp=doc_time,
recency_bias=recency_bias,
semantic_identifier=f"{username} in #{channel_name}: {snippet}",
text=thread_text,
highlighted_texts=set(),
slack_score=100000.0, # High priority — user explicitly asked for this thread
)
logger.info(
f"URL override: fetched thread from channel={channel_id}, ts={thread_ts}, {len(messages)} messages"
)
return SlackQueryResult(messages=[slack_message], filtered_channels=[])
def query_slack(
query_string: str,
access_token: str,
@@ -519,6 +432,7 @@ def query_slack(
available_channels: list[str] | None = None,
channel_metadata_dict: dict[str, ChannelMetadata] | None = None,
) -> SlackQueryResult:
# Check if query has channel override (user specified channels in query)
has_channel_override = query_string.startswith("__CHANNEL_OVERRIDE__")
@@ -748,6 +662,7 @@ def _fetch_thread_context(
"""
channel_id = message.channel_id
thread_id = message.thread_id
message_id = message.message_id
# If not a thread, return original text as success
if thread_id is None:
@@ -780,37 +695,62 @@ def _fetch_thread_context(
if len(messages) <= 1:
return ThreadContextResult.success(message.text)
# Build thread text from thread starter + all replies
thread_text = _build_thread_text(messages, access_token, team_id, slack_client)
# Build thread text from thread starter + context window around matched message
thread_text = _build_thread_text(
messages, message_id, thread_id, access_token, team_id, slack_client
)
return ThreadContextResult.success(thread_text)
def _build_thread_text(
messages: list[dict[str, Any]],
message_id: str,
thread_id: str,
access_token: str,
team_id: str | None,
slack_client: WebClient,
) -> str:
"""Build thread text including all replies.
Includes the thread parent message followed by all replies in order.
"""
"""Build the thread text from messages."""
msg_text = messages[0].get("text", "")
msg_sender = messages[0].get("user", "")
thread_text = f"<@{msg_sender}>: {msg_text}"
# All messages after index 0 are replies
replies = messages[1:]
if not replies:
return thread_text
logger.debug(f"Thread {messages[0].get('ts')}: {len(replies)} replies included")
thread_text += "\n\nReplies:"
if thread_id == message_id:
message_id_idx = 0
else:
message_id_idx = next(
(i for i, msg in enumerate(messages) if msg.get("ts") == message_id), 0
)
if not message_id_idx:
return thread_text
for msg in replies:
start_idx = max(1, message_id_idx - SLACK_THREAD_CONTEXT_WINDOW)
if start_idx > 1:
thread_text += "\n..."
for i in range(start_idx, message_id_idx):
msg_text = messages[i].get("text", "")
msg_sender = messages[i].get("user", "")
thread_text += f"\n\n<@{msg_sender}>: {msg_text}"
msg_text = messages[message_id_idx].get("text", "")
msg_sender = messages[message_id_idx].get("user", "")
thread_text += f"\n\n<@{msg_sender}>: {msg_text}"
# Add following replies
len_replies = 0
for msg in messages[message_id_idx + 1 :]:
msg_text = msg.get("text", "")
msg_sender = msg.get("user", "")
thread_text += f"\n\n<@{msg_sender}>: {msg_text}"
reply = f"\n\n<@{msg_sender}>: {msg_text}"
thread_text += reply
len_replies += len(reply)
if len_replies >= DOC_EMBEDDING_CONTEXT_SIZE * 4:
thread_text += "\n..."
break
# Replace user IDs with names using cached lookups
userids: set[str] = set(re.findall(r"<@([A-Z0-9]+)>", thread_text))
@@ -1036,16 +976,7 @@ def slack_retrieval(
# Query slack with entity filtering
llm = get_default_llm()
query_items = build_slack_queries(query, llm, entities, available_channels)
# Partition into direct thread fetches and search query strings
direct_fetches: list[DirectThreadFetch] = []
query_strings: list[str] = []
for item in query_items:
if isinstance(item, DirectThreadFetch):
direct_fetches.append(item)
else:
query_strings.append(item)
query_strings = build_slack_queries(query, llm, entities, available_channels)
# Determine filtering based on entities OR context (bot)
include_dm = False
@@ -1062,16 +993,8 @@ def slack_retrieval(
f"Private channel context: will only allow messages from {allowed_private_channel} + public channels"
)
# Build search tasks — direct thread fetches + keyword searches
search_tasks: list[tuple] = [
(
_fetch_thread_from_url,
(fetch, access_token, channel_metadata_dict),
)
for fetch in direct_fetches
]
search_tasks.extend(
# Build search tasks
search_tasks = [
(
query_slack,
(
@@ -1087,7 +1010,7 @@ def slack_retrieval(
),
)
for query_string in query_strings
)
]
# If include_dm is True AND we're not already searching all channels,
# add additional searches without channel filters.

View File

@@ -10,7 +10,6 @@ from pydantic import ValidationError
from onyx.configs.app_configs import MAX_SLACK_QUERY_EXPANSIONS
from onyx.context.search.federated.models import ChannelMetadata
from onyx.context.search.federated.models import DirectThreadFetch
from onyx.context.search.models import ChunkIndexRequest
from onyx.federated_connectors.slack.models import SlackEntities
from onyx.llm.interfaces import LLM
@@ -639,38 +638,12 @@ def expand_query_with_llm(query_text: str, llm: LLM) -> list[str]:
return [query_text]
SLACK_URL_PATTERN = re.compile(
r"https?://[a-z0-9-]+\.slack\.com/archives/([A-Z0-9]+)/p(\d{16})"
)
def extract_slack_message_urls(
query_text: str,
) -> list[tuple[str, str]]:
"""Extract Slack message URLs from query text.
Parses URLs like:
https://onyx-company.slack.com/archives/C097NBWMY8Y/p1775491616524769
Returns list of (channel_id, thread_ts) tuples.
The 16-digit timestamp is converted to Slack ts format (with dot).
"""
results = []
for match in SLACK_URL_PATTERN.finditer(query_text):
channel_id = match.group(1)
raw_ts = match.group(2)
# Convert p1775491616524769 -> 1775491616.524769
thread_ts = f"{raw_ts[:10]}.{raw_ts[10:]}"
results.append((channel_id, thread_ts))
return results
def build_slack_queries(
query: ChunkIndexRequest,
llm: LLM,
entities: dict[str, Any] | None = None,
available_channels: list[str] | None = None,
) -> list[str | DirectThreadFetch]:
) -> list[str]:
"""Build Slack query strings with date filtering and query expansion."""
default_search_days = 30
if entities:
@@ -695,15 +668,6 @@ def build_slack_queries(
cutoff_date = datetime.now(timezone.utc) - timedelta(days=days_back)
time_filter = f" after:{cutoff_date.strftime('%Y-%m-%d')}"
# Check for Slack message URLs — if found, add direct fetch requests
url_fetches: list[DirectThreadFetch] = []
slack_urls = extract_slack_message_urls(query.query)
for channel_id, thread_ts in slack_urls:
url_fetches.append(
DirectThreadFetch(channel_id=channel_id, thread_ts=thread_ts)
)
logger.info(f"Detected Slack URL: channel={channel_id}, ts={thread_ts}")
# ALWAYS extract channel references from the query (not just for recency queries)
channel_references = extract_channel_references_from_query(query.query)
@@ -720,9 +684,7 @@ def build_slack_queries(
# If valid channels detected, use ONLY those channels with NO keywords
# Return query with ONLY time filter + channel filter (no keywords)
return url_fetches + [
build_channel_override_query(channel_references, time_filter)
]
return [build_channel_override_query(channel_references, time_filter)]
except ValueError as e:
# If validation fails, log the error and continue with normal flow
logger.warning(f"Channel reference validation failed: {e}")
@@ -740,8 +702,7 @@ def build_slack_queries(
rephrased_queries = expand_query_with_llm(query.query, llm)
# Build final query strings with time filters
search_queries = [
return [
rephrased_query.strip() + time_filter
for rephrased_query in rephrased_queries[:MAX_SLACK_QUERY_EXPANSIONS]
]
return url_fetches + search_queries

View File

@@ -96,32 +96,6 @@ def _truncate_description(description: str | None, max_length: int = 500) -> str
return description[: max_length - 3] + "..."
# TODO: Replace mask-comparison approach with an explicit Unset sentinel from the
# frontend indicating whether each credential field was actually modified. The current
# approach is brittle (e.g. short credentials produce a fixed-length mask that could
# collide) and mutates request values, which is surprising. The frontend should signal
# "unchanged" vs "new value" directly rather than relying on masked-string equality.
def _restore_masked_oauth_credentials(
request_client_id: str | None,
request_client_secret: str | None,
existing_client: OAuthClientInformationFull,
) -> tuple[str | None, str | None]:
"""If the frontend sent back masked credentials, restore the real stored values."""
if (
request_client_id
and existing_client.client_id
and request_client_id == mask_string(existing_client.client_id)
):
request_client_id = existing_client.client_id
if (
request_client_secret
and existing_client.client_secret
and request_client_secret == mask_string(existing_client.client_secret)
):
request_client_secret = existing_client.client_secret
return request_client_id, request_client_secret
router = APIRouter(prefix="/mcp")
admin_router = APIRouter(prefix="/admin/mcp")
STATE_TTL_SECONDS = 60 * 5 # 5 minutes
@@ -418,26 +392,6 @@ async def _connect_oauth(
detail=f"Server was configured with authentication type {auth_type_str}",
)
# If the frontend sent back masked credentials (unchanged by the user),
# restore the real stored values so we don't overwrite them with masks.
if mcp_server.admin_connection_config:
existing_data = extract_connection_data(
mcp_server.admin_connection_config, apply_mask=False
)
existing_client_raw = existing_data.get(MCPOAuthKeys.CLIENT_INFO.value)
if existing_client_raw:
existing_client = OAuthClientInformationFull.model_validate(
existing_client_raw
)
(
request.oauth_client_id,
request.oauth_client_secret,
) = _restore_masked_oauth_credentials(
request.oauth_client_id,
request.oauth_client_secret,
existing_client,
)
# Create admin config with client info if provided
config_data = MCPConnectionData(headers={})
if request.oauth_client_id and request.oauth_client_secret:
@@ -1402,19 +1356,6 @@ def _upsert_mcp_server(
if client_info_raw:
client_info = OAuthClientInformationFull.model_validate(client_info_raw)
# If the frontend sent back masked credentials (unchanged by the user),
# restore the real stored values so the comparison below sees no change
# and the credentials aren't overwritten with masked strings.
if client_info and request.auth_type == MCPAuthenticationType.OAUTH:
(
request.oauth_client_id,
request.oauth_client_secret,
) = _restore_masked_oauth_credentials(
request.oauth_client_id,
request.oauth_client_secret,
client_info,
)
changing_connection_config = (
not mcp_server.admin_connection_config
or (

View File

@@ -47,6 +47,8 @@ from onyx.llm.factory import get_llm
from onyx.llm.factory import get_max_input_tokens_from_llm_provider
from onyx.llm.utils import get_bedrock_token_limit
from onyx.llm.utils import get_llm_contextual_cost
from onyx.llm.utils import get_max_input_tokens
from onyx.llm.utils import litellm_thinks_model_supports_image_input
from onyx.llm.utils import test_llm
from onyx.llm.well_known_providers.auto_update_service import (
fetch_llm_recommendations_from_github,
@@ -62,6 +64,8 @@ from onyx.server.manage.llm.models import BedrockFinalModelResponse
from onyx.server.manage.llm.models import BedrockModelsRequest
from onyx.server.manage.llm.models import BifrostFinalModelResponse
from onyx.server.manage.llm.models import BifrostModelsRequest
from onyx.server.manage.llm.models import CustomProviderModelResponse
from onyx.server.manage.llm.models import CustomProviderModelsRequest
from onyx.server.manage.llm.models import CustomProviderOption
from onyx.server.manage.llm.models import DefaultModel
from onyx.server.manage.llm.models import LitellmFinalModelResponse
@@ -111,43 +115,6 @@ def _mask_string(value: str) -> str:
return value[:4] + "****" + value[-4:]
def _resolve_api_key(
api_key: str | None,
provider_name: str | None,
api_base: str | None,
db_session: Session,
) -> str | None:
"""Return the real API key for model-fetch endpoints.
When editing an existing provider the form value is masked (e.g.
``sk-a****b1c2``). If *provider_name* is supplied we can look up
the unmasked key from the database so the external request succeeds.
The stored key is only returned when the request's *api_base*
matches the value stored in the database.
"""
if not provider_name:
return api_key
existing_provider = fetch_existing_llm_provider(
name=provider_name, db_session=db_session
)
if existing_provider and existing_provider.api_key:
# Normalise both URLs before comparing so trailing-slash
# differences don't cause a false mismatch.
stored_base = (existing_provider.api_base or "").strip().rstrip("/")
request_base = (api_base or "").strip().rstrip("/")
if stored_base != request_base:
return api_key
stored_key = existing_provider.api_key.get_value(apply_mask=False)
# Only resolve when the incoming value is the masked form of the
# stored key — i.e. the user hasn't typed a new key.
if api_key and api_key == _mask_string(stored_key):
return stored_key
return api_key
def _sync_fetched_models(
db_session: Session,
provider_name: str,
@@ -313,6 +280,158 @@ def fetch_custom_provider_names(
)
@admin_router.post("/custom/available-models")
def fetch_custom_provider_models(
request: CustomProviderModelsRequest,
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
) -> list[CustomProviderModelResponse]:
"""Fetch models for a custom provider.
When ``api_base`` is provided the endpoint hits the provider's
OpenAI-compatible ``/v1/models`` (or ``/{api_version}/models``) to
discover live models. Otherwise it falls back to the static list
that LiteLLM ships for the given provider slug.
In both cases the response is enriched with metadata from LiteLLM
(display name, max input tokens, vision support) when available.
"""
if request.api_base:
return _fetch_custom_models_from_api(
provider=request.provider,
api_base=request.api_base,
api_key=request.api_key,
api_version=request.api_version,
)
return _fetch_custom_models_from_litellm(request.provider)
def _enrich_custom_model(
name: str,
provider: str,
*,
api_display_name: str | None = None,
api_max_input_tokens: int | None = None,
api_supports_image_input: bool | None = None,
) -> CustomProviderModelResponse:
"""Build a ``CustomProviderModelResponse`` enriched with LiteLLM metadata.
Values explicitly provided by the source API take precedence; LiteLLM
metadata is used as a fallback.
"""
from onyx.llm.model_name_parser import parse_litellm_model_name
# LiteLLM keys are typically "provider/model"
litellm_key = f"{provider}/{name}" if not name.startswith(f"{provider}/") else name
parsed = parse_litellm_model_name(litellm_key)
# display_name: prefer API-provided name, then LiteLLM enrichment, then raw name
if api_display_name and api_display_name != name:
display_name = api_display_name
else:
display_name = parsed.display_name or name
# max_input_tokens: prefer API value, then LiteLLM lookup
if api_max_input_tokens is not None:
max_input_tokens: int | None = api_max_input_tokens
else:
try:
max_input_tokens = get_max_input_tokens(name, provider)
except Exception:
max_input_tokens = None
# supports_image_input: prefer API value, then LiteLLM inference
if api_supports_image_input is not None:
supports_image = api_supports_image_input
else:
supports_image = litellm_thinks_model_supports_image_input(name, provider)
return CustomProviderModelResponse(
name=name,
display_name=display_name,
max_input_tokens=max_input_tokens,
supports_image_input=supports_image,
)
def _fetch_custom_models_from_api(
provider: str,
api_base: str,
api_key: str | None,
api_version: str | None,
) -> list[CustomProviderModelResponse]:
"""Hit an OpenAI-compatible ``/v1/models`` (or versioned variant)."""
cleaned = api_base.strip().rstrip("/")
if api_version:
url = f"{cleaned}/{api_version.strip().strip('/')}/models"
elif cleaned.endswith("/v1"):
url = f"{cleaned}/models"
else:
url = f"{cleaned}/v1/models"
response_json = _get_openai_compatible_models_response(
url=url,
source_name="Custom provider",
api_key=api_key,
)
models = response_json.get("data", [])
if not isinstance(models, list) or len(models) == 0:
raise OnyxError(
OnyxErrorCode.VALIDATION_ERROR,
"No models found from the provider's API.",
)
results: list[CustomProviderModelResponse] = []
for model in models:
try:
model_id = model.get("id", "")
if not model_id:
continue
if is_embedding_model(model_id):
continue
results.append(
_enrich_custom_model(
model_id,
provider,
api_display_name=model.get("name"),
api_max_input_tokens=model.get("context_length"),
api_supports_image_input=infer_vision_support(model_id),
)
)
except Exception as e:
logger.warning(
"Failed to parse custom provider model entry",
extra={"error": str(e), "item": str(model)[:1000]},
)
if not results:
raise OnyxError(
OnyxErrorCode.VALIDATION_ERROR,
"No compatible models found from the provider's API.",
)
return sorted(results, key=lambda m: m.name.lower())
def _fetch_custom_models_from_litellm(
provider: str,
) -> list[CustomProviderModelResponse]:
"""Fall back to litellm's static ``models_by_provider`` mapping."""
import litellm
model_names = litellm.models_by_provider.get(provider)
if model_names is None:
raise OnyxError(
OnyxErrorCode.NOT_FOUND,
f"Unknown provider: {provider}",
)
return sorted(
(_enrich_custom_model(name, provider) for name in model_names),
key=lambda m: m.name.lower(),
)
@admin_router.get("/built-in/options")
def fetch_llm_options(
_: User = Depends(require_permission(Permission.FULL_ADMIN_PANEL_ACCESS)),
@@ -1211,17 +1330,16 @@ def get_ollama_available_models(
return sorted_results
def _get_openrouter_models_response(api_base: str, api_key: str | None) -> dict:
def _get_openrouter_models_response(api_base: str, api_key: str) -> dict:
"""Perform GET to OpenRouter /models and return parsed JSON."""
cleaned_api_base = api_base.strip().rstrip("/")
url = f"{cleaned_api_base}/models"
headers: dict[str, str] = {
headers = {
"Authorization": f"Bearer {api_key}",
# Optional headers recommended by OpenRouter for attribution
"HTTP-Referer": "https://onyx.app",
"X-Title": "Onyx",
}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
try:
response = httpx.get(url, headers=headers, timeout=10.0)
response.raise_for_status()
@@ -1244,12 +1362,8 @@ def get_openrouter_available_models(
Parses id, name (display), context_length, and architecture.input_modalities.
"""
api_key = _resolve_api_key(
request.api_key, request.provider_name, request.api_base, db_session
)
response_json = _get_openrouter_models_response(
api_base=request.api_base, api_key=api_key
api_base=request.api_base, api_key=request.api_key
)
data = response_json.get("data", [])
@@ -1342,18 +1456,13 @@ def get_lm_studio_available_models(
# If provider_name is given and the api_key hasn't been changed by the user,
# fall back to the stored API key from the database (the form value is masked).
# Only do so when the api_base matches what is stored.
api_key = request.api_key
if request.provider_name and not request.api_key_changed:
existing_provider = fetch_existing_llm_provider(
name=request.provider_name, db_session=db_session
)
if existing_provider and existing_provider.custom_config:
stored_base = (existing_provider.api_base or "").strip().rstrip("/")
if stored_base == cleaned_api_base:
api_key = existing_provider.custom_config.get(
LM_STUDIO_API_KEY_CONFIG_KEY
)
api_key = existing_provider.custom_config.get(LM_STUDIO_API_KEY_CONFIG_KEY)
url = f"{cleaned_api_base}/api/v1/models"
headers: dict[str, str] = {}
@@ -1437,12 +1546,8 @@ def get_litellm_available_models(
db_session: Session = Depends(get_session),
) -> list[LitellmFinalModelResponse]:
"""Fetch available models from Litellm proxy /v1/models endpoint."""
api_key = _resolve_api_key(
request.api_key, request.provider_name, request.api_base, db_session
)
response_json = _get_litellm_models_response(
api_key=api_key, api_base=request.api_base
api_key=request.api_key, api_base=request.api_base
)
models = response_json.get("data", [])
@@ -1499,7 +1604,7 @@ def get_litellm_available_models(
return sorted_results
def _get_litellm_models_response(api_key: str | None, api_base: str) -> dict:
def _get_litellm_models_response(api_key: str, api_base: str) -> dict:
"""Perform GET to Litellm proxy /api/v1/models and return parsed JSON."""
cleaned_api_base = api_base.strip().rstrip("/")
url = f"{cleaned_api_base}/v1/models"
@@ -1574,12 +1679,8 @@ def get_bifrost_available_models(
db_session: Session = Depends(get_session),
) -> list[BifrostFinalModelResponse]:
"""Fetch available models from Bifrost gateway /v1/models endpoint."""
api_key = _resolve_api_key(
request.api_key, request.provider_name, request.api_base, db_session
)
response_json = _get_bifrost_models_response(
api_base=request.api_base, api_key=api_key
api_base=request.api_base, api_key=request.api_key
)
models = response_json.get("data", [])
@@ -1668,12 +1769,8 @@ def get_openai_compatible_server_available_models(
db_session: Session = Depends(get_session),
) -> list[OpenAICompatibleFinalModelResponse]:
"""Fetch available models from a generic OpenAI-compatible /v1/models endpoint."""
api_key = _resolve_api_key(
request.api_key, request.provider_name, request.api_base, db_session
)
response_json = _get_openai_compatible_server_response(
api_base=request.api_base, api_key=api_key
api_base=request.api_base, api_key=request.api_key
)
models = response_json.get("data", [])

View File

@@ -477,6 +477,21 @@ class BifrostFinalModelResponse(BaseModel):
supports_reasoning: bool
# Custom provider dynamic models fetch
class CustomProviderModelsRequest(BaseModel):
provider: str # LiteLLM provider slug (e.g. "deepseek", "fireworks_ai")
api_base: str | None = None # If set, fetches live models via /v1/models
api_key: str | None = None
api_version: str | None = None # If set, used to construct the models URL
class CustomProviderModelResponse(BaseModel):
name: str
display_name: str
max_input_tokens: int | None
supports_image_input: bool
# OpenAI Compatible dynamic models fetch
class OpenAICompatibleModelsRequest(BaseModel):
api_base: str

10
backend/pyproject.toml Normal file
View File

@@ -0,0 +1,10 @@
[project]
name = "onyx-backend"
version = "0.0.0"
requires-python = ">=3.11"
dependencies = [
"onyx[backend,dev,ee]",
]
[tool.uv.sources]
onyx = { workspace = true }

View File

@@ -46,11 +46,11 @@ curl -LsSf https://astral.py/uv/install.sh | sh
1. Edit `pyproject.toml`
2. Add/update/remove dependencies in the appropriate section:
- `[dependency-groups]` for dev tools
- `[project.dependencies]` for **shared** dependencies (used by both backend and model_server)
- `[dependency-groups.backend]` for backend-only dependencies
- `[dependency-groups.dev]` for dev tools
- `[dependency-groups.ee]` for EE features
- `[dependency-groups.model_server]` for model_server-only dependencies (ML packages)
- `[project.optional-dependencies.backend]` for backend-only dependencies
- `[project.optional-dependencies.model_server]` for model_server-only dependencies (ML packages)
- `[project.optional-dependencies.ee]` for EE features
3. Commit your changes - pre-commit hooks will automatically regenerate the lock file and requirements
### 3. Generating Lock File and Requirements
@@ -64,10 +64,10 @@ To manually regenerate:
```bash
uv lock
uv export --no-emit-project --no-default-groups --no-hashes --group backend -o backend/requirements/default.txt
uv export --no-emit-project --no-default-groups --no-hashes --extra backend -o backend/requirements/default.txt
uv export --no-emit-project --no-default-groups --no-hashes --group dev -o backend/requirements/dev.txt
uv export --no-emit-project --no-default-groups --no-hashes --group ee -o backend/requirements/ee.txt
uv export --no-emit-project --no-default-groups --no-hashes --group model_server -o backend/requirements/model_server.txt
uv export --no-emit-project --no-default-groups --no-hashes --extra ee -o backend/requirements/ee.txt
uv export --no-emit-project --no-default-groups --no-hashes --extra model_server -o backend/requirements/model_server.txt
```
### 4. Installing Dependencies
@@ -76,14 +76,30 @@ If enabled, all packages are installed automatically by the `uv-sync` pre-commit
branches or pulling new changes.
```bash
# For development (most common) — installs shared + backend + dev + ee
uv sync
# For everything (most common)
uv sync --all-extras
# For backend production only (shared + backend dependencies)
uv sync --no-default-groups --group backend
# For backend production (shared + backend dependencies)
uv sync --extra backend
# For backend development (shared + backend + dev tools)
uv sync --extra backend --extra dev
# For backend with EE (shared + backend + ee)
uv sync --extra backend --extra ee
# For model server (shared + model_server, NO backend deps!)
uv sync --no-default-groups --group model_server
uv sync --extra model_server
```
`uv` aggressively [ignores active virtual environments](https://docs.astral.sh/uv/concepts/projects/config/#project-environment-path) and prefers the root virtual environment.
When working in workspace packages, be sure to pass `--active` when syncing the virtual environment:
```bash
cd backend/
source .venv/bin/activate
uv sync --active
uv run --active ...
```
### 5. Upgrading Dependencies

View File

@@ -1,5 +1,5 @@
# This file was autogenerated by uv via the following command:
# uv export --no-emit-project --no-default-groups --no-hashes --group backend -o backend/requirements/default.txt
# uv export --no-emit-project --no-default-groups --no-hashes --extra backend -o backend/requirements/default.txt
agent-client-protocol==0.7.1
# via onyx
aioboto3==15.1.0
@@ -19,6 +19,7 @@ aiohttp==3.13.4
# aiobotocore
# discord-py
# litellm
# onyx
# voyageai
aioitertools==0.13.0
# via aiobotocore
@@ -27,6 +28,7 @@ aiolimiter==1.2.1
aiosignal==1.4.0
# via aiohttp
alembic==1.10.4
# via onyx
amqp==5.3.1
# via kombu
annotated-doc==0.0.4
@@ -49,10 +51,13 @@ argon2-cffi==23.1.0
argon2-cffi-bindings==25.1.0
# via argon2-cffi
asana==5.0.8
# via onyx
async-timeout==5.0.1 ; python_full_version < '3.11.3'
# via redis
asyncpg==0.30.0
# via onyx
atlassian-python-api==3.41.16
# via onyx
attrs==25.4.0
# via
# aiohttp
@@ -63,6 +68,7 @@ attrs==25.4.0
authlib==1.6.9
# via fastmcp
azure-cognitiveservices-speech==1.38.0
# via onyx
babel==2.17.0
# via courlan
backoff==2.2.1
@@ -80,6 +86,7 @@ beautifulsoup4==4.12.3
# atlassian-python-api
# markdownify
# markitdown
# onyx
# unstructured
billiard==4.2.3
# via celery
@@ -87,7 +94,9 @@ boto3==1.39.11
# via
# aiobotocore
# cohere
# onyx
boto3-stubs==1.39.11
# via onyx
botocore==1.39.11
# via
# aiobotocore
@@ -96,6 +105,7 @@ botocore==1.39.11
botocore-stubs==1.40.74
# via boto3-stubs
braintrust==0.3.9
# via onyx
brotli==1.2.0
# via onyx
bytecode==0.17.0
@@ -105,6 +115,7 @@ cachetools==6.2.2
caio==0.9.25
# via aiofile
celery==5.5.1
# via onyx
certifi==2025.11.12
# via
# asana
@@ -123,6 +134,7 @@ cffi==2.0.0
# pynacl
# zstandard
chardet==5.2.0
# via onyx
charset-normalizer==3.4.4
# via
# htmldate
@@ -134,6 +146,7 @@ charset-normalizer==3.4.4
chevron==0.14.0
# via braintrust
chonkie==1.0.10
# via onyx
claude-agent-sdk==0.1.19
# via onyx
click==8.3.1
@@ -188,12 +201,15 @@ cryptography==46.0.6
cyclopts==4.2.4
# via fastmcp
dask==2026.1.1
# via distributed
# via
# distributed
# onyx
dataclasses-json==0.6.7
# via unstructured
dateparser==1.2.2
# via htmldate
ddtrace==3.10.0
# via onyx
decorator==5.2.1
# via retry
defusedxml==0.7.1
@@ -207,6 +223,7 @@ deprecated==1.3.1
discord-py==2.4.0
# via onyx
distributed==2026.1.1
# via onyx
distro==1.9.0
# via
# openai
@@ -218,6 +235,7 @@ docstring-parser==0.17.0
docutils==0.22.3
# via rich-rst
dropbox==12.0.2
# via onyx
durationpy==0.10
# via kubernetes
email-validator==2.2.0
@@ -233,6 +251,7 @@ et-xmlfile==2.0.0
events==0.5
# via opensearch-py
exa-py==1.15.4
# via onyx
exceptiongroup==1.3.0
# via
# braintrust
@@ -243,16 +262,23 @@ fastapi==0.133.1
# fastapi-users
# onyx
fastapi-limiter==0.1.6
# via onyx
fastapi-users==15.0.4
# via fastapi-users-db-sqlalchemy
# via
# fastapi-users-db-sqlalchemy
# onyx
fastapi-users-db-sqlalchemy==7.0.0
# via onyx
fastavro==1.12.1
# via cohere
fastmcp==3.2.0
# via onyx
fastuuid==0.14.0
# via litellm
filelock==3.20.3
# via huggingface-hub
# via
# huggingface-hub
# onyx
filetype==1.2.0
# via unstructured
flatbuffers==25.9.23
@@ -272,6 +298,7 @@ gitpython==3.1.45
google-api-core==2.28.1
# via google-api-python-client
google-api-python-client==2.86.0
# via onyx
google-auth==2.48.0
# via
# google-api-core
@@ -281,8 +308,11 @@ google-auth==2.48.0
# google-genai
# kubernetes
google-auth-httplib2==0.1.0
# via google-api-python-client
# via
# google-api-python-client
# onyx
google-auth-oauthlib==1.0.0
# via onyx
google-genai==1.52.0
# via onyx
googleapis-common-protos==1.72.0
@@ -310,6 +340,7 @@ htmldate==1.9.1
httpcore==1.0.9
# via
# httpx
# onyx
# unstructured-client
httplib2==0.31.0
# via
@@ -326,16 +357,21 @@ httpx==0.28.1
# langsmith
# litellm
# mcp
# onyx
# openai
# unstructured-client
httpx-oauth==0.15.1
# via onyx
httpx-sse==0.4.3
# via
# cohere
# mcp
hubspot-api-client==11.1.0
# via onyx
huggingface-hub==0.35.3
# via tokenizers
# via
# onyx
# tokenizers
humanfriendly==10.0
# via coloredlogs
hyperframe==6.1.0
@@ -354,7 +390,9 @@ importlib-metadata==8.7.0
# litellm
# opentelemetry-api
inflection==0.5.1
# via pyairtable
# via
# onyx
# pyairtable
iniconfig==2.3.0
# via pytest
isodate==0.7.2
@@ -376,6 +414,7 @@ jinja2==3.1.6
# distributed
# litellm
jira==3.10.5
# via onyx
jiter==0.12.0
# via openai
jmespath==1.0.1
@@ -391,7 +430,9 @@ jsonpatch==1.33
jsonpointer==3.0.0
# via jsonpatch
jsonref==1.1.0
# via fastmcp
# via
# fastmcp
# onyx
jsonschema==4.25.1
# via
# litellm
@@ -409,12 +450,15 @@ kombu==5.5.4
kubernetes==31.0.0
# via onyx
langchain-core==1.2.22
# via onyx
langdetect==1.0.9
# via unstructured
langfuse==3.10.0
# via onyx
langsmith==0.3.45
# via langchain-core
lazy-imports==1.0.1
# via onyx
legacy-cgi==2.6.4 ; python_full_version >= '3.13'
# via ddtrace
litellm==1.81.6
@@ -429,6 +473,7 @@ lxml==5.3.0
# justext
# lxml-html-clean
# markitdown
# onyx
# python-docx
# python-pptx
# python3-saml
@@ -443,7 +488,9 @@ magika==0.6.3
makefun==1.16.0
# via fastapi-users
mako==1.2.4
# via alembic
# via
# alembic
# onyx
mammoth==1.11.0
# via markitdown
markdown-it-py==4.0.0
@@ -451,6 +498,7 @@ markdown-it-py==4.0.0
markdownify==1.2.2
# via markitdown
markitdown==0.1.2
# via onyx
markupsafe==3.0.3
# via
# jinja2
@@ -464,9 +512,11 @@ mcp==1.26.0
# via
# claude-agent-sdk
# fastmcp
# onyx
mdurl==0.1.2
# via markdown-it-py
mistune==3.2.0
# via onyx
more-itertools==10.8.0
# via
# jaraco-classes
@@ -475,10 +525,13 @@ more-itertools==10.8.0
mpmath==1.3.0
# via sympy
msal==1.34.0
# via office365-rest-python-client
# via
# office365-rest-python-client
# onyx
msgpack==1.1.2
# via distributed
msoffcrypto-tool==5.4.2
# via onyx
multidict==6.7.0
# via
# aiobotocore
@@ -495,6 +548,7 @@ mypy-extensions==1.0.0
# mypy
# typing-inspect
nest-asyncio==1.6.0
# via onyx
nltk==3.9.4
# via unstructured
numpy==2.4.1
@@ -509,8 +563,10 @@ oauthlib==3.2.2
# via
# atlassian-python-api
# kubernetes
# onyx
# requests-oauthlib
office365-rest-python-client==2.6.2
# via onyx
olefile==0.47
# via
# msoffcrypto-tool
@@ -526,11 +582,15 @@ openai==2.14.0
openapi-pydantic==0.5.1
# via fastmcp
openinference-instrumentation==0.1.42
# via onyx
openinference-semantic-conventions==0.1.25
# via openinference-instrumentation
openpyxl==3.0.10
# via markitdown
# via
# markitdown
# onyx
opensearch-py==3.0.0
# via onyx
opentelemetry-api==1.39.1
# via
# ddtrace
@@ -546,6 +606,7 @@ opentelemetry-exporter-otlp-proto-http==1.39.1
# via langfuse
opentelemetry-proto==1.39.1
# via
# onyx
# opentelemetry-exporter-otlp-proto-common
# opentelemetry-exporter-otlp-proto-http
opentelemetry-sdk==1.39.1
@@ -579,6 +640,7 @@ parameterized==0.9.0
partd==1.4.2
# via dask
passlib==1.7.4
# via onyx
pathable==0.4.4
# via jsonschema-path
pdfminer-six==20251107
@@ -590,7 +652,9 @@ platformdirs==4.5.0
# fastmcp
# zeep
playwright==1.55.0
# via pytest-playwright
# via
# onyx
# pytest-playwright
pluggy==1.6.0
# via pytest
ply==3.11
@@ -620,9 +684,12 @@ protobuf==6.33.5
psutil==7.1.3
# via
# distributed
# onyx
# unstructured
psycopg2-binary==2.9.9
# via onyx
puremagic==1.28
# via onyx
pwdlib==0.3.0
# via fastapi-users
py==1.11.0
@@ -630,6 +697,7 @@ py==1.11.0
py-key-value-aio==0.4.4
# via fastmcp
pyairtable==3.0.1
# via onyx
pyasn1==0.6.3
# via
# pyasn1-modules
@@ -639,6 +707,7 @@ pyasn1-modules==0.4.2
pycparser==2.23 ; implementation_name != 'PyPy'
# via cffi
pycryptodome==3.19.1
# via onyx
pydantic==2.11.7
# via
# agent-client-protocol
@@ -665,6 +734,7 @@ pydantic-settings==2.12.0
pyee==13.0.0
# via playwright
pygithub==2.5.0
# via onyx
pygments==2.20.0
# via rich
pyjwt==2.12.0
@@ -675,13 +745,17 @@ pyjwt==2.12.0
# pygithub
# simple-salesforce
pympler==1.1
# via onyx
pynacl==1.6.2
# via pygithub
pypandoc-binary==1.16.2
# via onyx
pyparsing==3.2.5
# via httplib2
pypdf==6.9.2
# via unstructured-client
# via
# onyx
# unstructured-client
pyperclip==1.11.0
# via fastmcp
pyreadline3==3.5.4 ; sys_platform == 'win32'
@@ -694,7 +768,9 @@ pytest==8.3.5
pytest-base-url==2.1.0
# via pytest-playwright
pytest-mock==3.12.0
# via onyx
pytest-playwright==0.7.0
# via onyx
python-dateutil==2.8.2
# via
# aiobotocore
@@ -705,9 +781,11 @@ python-dateutil==2.8.2
# htmldate
# hubspot-api-client
# kubernetes
# onyx
# opensearch-py
# pandas
python-docx==1.1.2
# via onyx
python-dotenv==1.1.1
# via
# braintrust
@@ -715,8 +793,10 @@ python-dotenv==1.1.1
# litellm
# magika
# mcp
# onyx
# pydantic-settings
python-gitlab==5.6.0
# via onyx
python-http-client==3.3.7
# via sendgrid
python-iso639==2025.11.16
@@ -727,15 +807,19 @@ python-multipart==0.0.22
# via
# fastapi-users
# mcp
# onyx
python-oxmsg==0.0.2
# via unstructured
python-pptx==0.6.23
# via markitdown
# via
# markitdown
# onyx
python-slugify==8.0.4
# via
# braintrust
# pytest-playwright
python3-saml==1.15.0
# via onyx
pytz==2025.2
# via
# dateparser
@@ -743,6 +827,7 @@ pytz==2025.2
# pandas
# zeep
pywikibot==9.0.0
# via onyx
pywin32==311 ; sys_platform == 'win32'
# via
# mcp
@@ -759,9 +844,13 @@ pyyaml==6.0.3
# kubernetes
# langchain-core
rapidfuzz==3.13.0
# via unstructured
# via
# onyx
# unstructured
redis==5.0.8
# via fastapi-limiter
# via
# fastapi-limiter
# onyx
referencing==0.36.2
# via
# jsonschema
@@ -792,6 +881,7 @@ requests==2.33.0
# matrix-client
# msal
# office365-rest-python-client
# onyx
# opensearch-py
# opentelemetry-exporter-otlp-proto-http
# pyairtable
@@ -817,6 +907,7 @@ requests-oauthlib==1.3.1
# google-auth-oauthlib
# jira
# kubernetes
# onyx
requests-toolbelt==1.0.0
# via
# jira
@@ -827,6 +918,7 @@ requests-toolbelt==1.0.0
retry==0.9.2
# via onyx
rfc3986==1.5.0
# via onyx
rich==14.2.0
# via
# cyclopts
@@ -846,12 +938,15 @@ s3transfer==0.13.1
secretstorage==3.5.0 ; sys_platform == 'linux'
# via keyring
sendgrid==6.12.5
# via onyx
sentry-sdk==2.14.0
# via onyx
shapely==2.0.6
# via onyx
shellingham==1.5.4
# via typer
simple-salesforce==1.12.6
# via onyx
six==1.17.0
# via
# asana
@@ -866,6 +961,7 @@ six==1.17.0
# python-dateutil
# stone
slack-sdk==3.20.2
# via onyx
smmap==5.0.2
# via gitdb
sniffio==1.3.1
@@ -880,6 +976,7 @@ sqlalchemy==2.0.15
# via
# alembic
# fastapi-users-db-sqlalchemy
# onyx
sse-starlette==3.0.3
# via mcp
sseclient-py==1.8.0
@@ -888,11 +985,14 @@ starlette==0.49.3
# via
# fastapi
# mcp
# onyx
# prometheus-fastapi-instrumentator
stone==3.3.1
# via dropbox
stripe==10.12.0
# via onyx
supervisor==4.3.0
# via onyx
sympy==1.14.0
# via onnxruntime
tblib==3.2.2
@@ -905,8 +1005,11 @@ tenacity==9.1.2
text-unidecode==1.3
# via python-slugify
tiktoken==0.7.0
# via litellm
# via
# litellm
# onyx
timeago==1.0.16
# via onyx
tld==0.13.1
# via courlan
tokenizers==0.21.4
@@ -930,11 +1033,13 @@ tqdm==4.67.1
# openai
# unstructured
trafilatura==1.12.2
# via onyx
typer==0.20.0
# via mcp
types-awscrt==0.28.4
# via botocore-stubs
types-openpyxl==3.0.4.7
# via onyx
types-requests==2.32.0.20250328
# via cohere
types-s3transfer==0.14.0
@@ -1000,8 +1105,11 @@ tzlocal==5.3.1
uncalled-for==0.2.0
# via fastmcp
unstructured==0.18.27
# via onyx
unstructured-client==0.42.6
# via unstructured
# via
# onyx
# unstructured
uritemplate==4.2.0
# via google-api-python-client
urllib3==2.6.3
@@ -1013,6 +1121,7 @@ urllib3==2.6.3
# htmldate
# hubspot-api-client
# kubernetes
# onyx
# opensearch-py
# pyairtable
# pygithub
@@ -1062,7 +1171,9 @@ xlrd==2.0.2
xlsxwriter==3.2.9
# via python-pptx
xmlsec==1.3.14
# via python3-saml
# via
# onyx
# python3-saml
xmltodict==1.0.2
# via ddtrace
yarl==1.22.0
@@ -1076,3 +1187,4 @@ zipp==3.23.0
zstandard==0.23.0
# via langsmith
zulip==0.8.2
# via onyx

View File

@@ -1,5 +1,5 @@
# This file was autogenerated by uv via the following command:
# uv export --no-emit-project --no-default-groups --no-hashes --group dev -o backend/requirements/dev.txt
# uv export --no-emit-project --no-default-groups --no-hashes --extra dev -o backend/requirements/dev.txt
agent-client-protocol==0.7.1
# via onyx
aioboto3==15.1.0
@@ -47,6 +47,7 @@ attrs==25.4.0
# jsonschema
# referencing
black==25.1.0
# via onyx
boto3==1.39.11
# via
# aiobotocore
@@ -59,6 +60,7 @@ botocore==1.39.11
brotli==1.2.0
# via onyx
celery-types==0.19.0
# via onyx
certifi==2025.11.12
# via
# httpcore
@@ -120,6 +122,7 @@ execnet==2.1.2
executing==2.2.1
# via stack-data
faker==40.1.2
# via onyx
fastapi==0.133.1
# via
# onyx
@@ -153,6 +156,7 @@ h11==0.16.0
# httpcore
# uvicorn
hatchling==1.28.0
# via onyx
hf-xet==1.2.0 ; platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'
# via huggingface-hub
httpcore==1.0.9
@@ -183,6 +187,7 @@ importlib-metadata==8.7.0
iniconfig==2.3.0
# via pytest
ipykernel==6.29.5
# via onyx
ipython==9.7.0
# via ipykernel
ipython-pygments-lexers==1.1.1
@@ -219,11 +224,13 @@ litellm==1.81.6
mako==1.2.4
# via alembic
manygo==0.2.0
# via onyx
markupsafe==3.0.3
# via
# jinja2
# mako
matplotlib==3.10.8
# via onyx
matplotlib-inline==0.2.1
# via
# ipykernel
@@ -236,10 +243,12 @@ multidict==6.7.0
# aiohttp
# yarl
mypy==1.13.0
# via onyx
mypy-extensions==1.0.0
# via
# black
# mypy
# onyx
nest-asyncio==1.6.0
# via ipykernel
nodeenv==1.9.1
@@ -254,13 +263,16 @@ oauthlib==3.2.2
# via
# kubernetes
# requests-oauthlib
onyx-devtools==0.7.5
onyx-devtools==0.7.3
# via onyx
openai==2.14.0
# via
# litellm
# onyx
openapi-generator-cli==7.17.0
# via onyx-devtools
# via
# onyx
# onyx-devtools
packaging==24.2
# via
# black
@@ -270,6 +282,7 @@ packaging==24.2
# matplotlib
# pytest
pandas-stubs==2.3.3.251201
# via onyx
parameterized==0.9.0
# via cohere
parso==0.8.5
@@ -292,6 +305,7 @@ pluggy==1.6.0
# hatchling
# pytest
pre-commit==3.2.2
# via onyx
prometheus-client==0.23.1
# via
# onyx
@@ -345,16 +359,22 @@ pyparsing==3.2.5
# via matplotlib
pytest==8.3.5
# via
# onyx
# pytest-alembic
# pytest-asyncio
# pytest-dotenv
# pytest-repeat
# pytest-xdist
pytest-alembic==0.12.1
# via onyx
pytest-asyncio==1.3.0
# via onyx
pytest-dotenv==0.5.2
# via onyx
pytest-repeat==0.9.4
# via onyx
pytest-xdist==3.8.0
# via onyx
python-dateutil==2.8.2
# via
# aiobotocore
@@ -387,7 +407,9 @@ referencing==0.36.2
regex==2025.11.3
# via tiktoken
release-tag==0.5.2
# via onyx
reorder-python-imports-black==3.14.0
# via onyx
requests==2.33.0
# via
# cohere
@@ -408,6 +430,7 @@ rpds-py==0.29.0
rsa==4.9.1
# via google-auth
ruff==0.12.0
# via onyx
s3transfer==0.13.1
# via boto3
sentry-sdk==2.14.0
@@ -461,22 +484,39 @@ traitlets==5.14.3
trove-classifiers==2025.12.1.14
# via hatchling
types-beautifulsoup4==4.12.0.3
# via onyx
types-html5lib==1.1.11.13
# via types-beautifulsoup4
# via
# onyx
# types-beautifulsoup4
types-oauthlib==3.2.0.9
# via onyx
types-passlib==1.7.7.20240106
# via onyx
types-pillow==10.2.0.20240822
# via onyx
types-psutil==7.1.3.20251125
# via onyx
types-psycopg2==2.9.21.10
# via onyx
types-python-dateutil==2.8.19.13
# via onyx
types-pytz==2023.3.1.1
# via pandas-stubs
# via
# onyx
# pandas-stubs
types-pyyaml==6.0.12.11
# via onyx
types-regex==2023.3.23.1
# via onyx
types-requests==2.32.0.20250328
# via cohere
# via
# cohere
# onyx
types-retry==0.9.9.3
# via onyx
types-setuptools==68.0.0.3
# via onyx
typing-extensions==4.15.0
# via
# aiosignal
@@ -534,3 +574,4 @@ yarl==1.22.0
zipp==3.23.0
# via importlib-metadata
zizmor==1.18.0
# via onyx

View File

@@ -1,5 +1,5 @@
# This file was autogenerated by uv via the following command:
# uv export --no-emit-project --no-default-groups --no-hashes --group ee -o backend/requirements/ee.txt
# uv export --no-emit-project --no-default-groups --no-hashes --extra ee -o backend/requirements/ee.txt
agent-client-protocol==0.7.1
# via onyx
aioboto3==15.1.0
@@ -182,6 +182,7 @@ packaging==24.2
parameterized==0.9.0
# via cohere
posthog==3.7.4
# via onyx
prometheus-client==0.23.1
# via
# onyx

View File

@@ -1,6 +1,7 @@
# This file was autogenerated by uv via the following command:
# uv export --no-emit-project --no-default-groups --no-hashes --group model_server -o backend/requirements/model_server.txt
# uv export --no-emit-project --no-default-groups --no-hashes --extra model_server -o backend/requirements/model_server.txt
accelerate==1.6.0
# via onyx
agent-client-protocol==0.7.1
# via onyx
aioboto3==15.1.0
@@ -104,6 +105,7 @@ distro==1.9.0
durationpy==0.10
# via kubernetes
einops==0.8.1
# via onyx
fastapi==0.133.1
# via
# onyx
@@ -205,6 +207,7 @@ networkx==3.5
numpy==2.4.1
# via
# accelerate
# onyx
# scikit-learn
# scipy
# transformers
@@ -360,6 +363,7 @@ s3transfer==0.13.1
safetensors==0.5.3
# via
# accelerate
# onyx
# transformers
scikit-learn==1.7.2
# via sentence-transformers
@@ -368,6 +372,7 @@ scipy==1.16.3
# scikit-learn
# sentence-transformers
sentence-transformers==4.0.2
# via onyx
sentry-sdk==2.14.0
# via onyx
setuptools==80.9.0 ; python_full_version >= '3.12'
@@ -406,6 +411,7 @@ tokenizers==0.21.4
torch==2.9.1
# via
# accelerate
# onyx
# sentence-transformers
tqdm==4.67.1
# via
@@ -414,7 +420,9 @@ tqdm==4.67.1
# sentence-transformers
# transformers
transformers==4.53.0
# via sentence-transformers
# via
# onyx
# sentence-transformers
triton==3.5.1 ; platform_machine == 'x86_64' and sys_platform == 'linux'
# via torch
types-requests==2.32.0.20250328

View File

@@ -1,239 +0,0 @@
"""Tests for GoogleDriveConnector.resolve_errors against real Google Drive."""
import json
import os
from collections.abc import Callable
from unittest.mock import patch
from onyx.connectors.google_drive.connector import GoogleDriveConnector
from onyx.connectors.models import ConnectorFailure
from onyx.connectors.models import Document
from onyx.connectors.models import DocumentFailure
from onyx.connectors.models import HierarchyNode
from tests.daily.connectors.google_drive.consts_and_utils import ADMIN_EMAIL
from tests.daily.connectors.google_drive.consts_and_utils import (
ALL_EXPECTED_HIERARCHY_NODES,
)
from tests.daily.connectors.google_drive.consts_and_utils import FOLDER_1_ID
from tests.daily.connectors.google_drive.consts_and_utils import SHARED_DRIVE_1_ID
_DRIVE_ID_MAPPING_PATH = os.path.join(
os.path.dirname(__file__), "drive_id_mapping.json"
)
def _load_web_view_links(file_ids: list[int]) -> list[str]:
with open(_DRIVE_ID_MAPPING_PATH) as f:
mapping: dict[str, str] = json.load(f)
return [mapping[str(fid)] for fid in file_ids]
def _build_failures(web_view_links: list[str]) -> list[ConnectorFailure]:
return [
ConnectorFailure(
failed_document=DocumentFailure(
document_id=link,
document_link=link,
),
failure_message=f"Synthetic failure for {link}",
)
for link in web_view_links
]
@patch("onyx.file_processing.extract_file_text.get_unstructured_api_key")
def test_resolve_single_file(
mock_api_key: None, # noqa: ARG001
google_drive_service_acct_connector_factory: Callable[..., GoogleDriveConnector],
) -> None:
"""Resolve a single known file and verify we get back exactly one Document."""
connector = google_drive_service_acct_connector_factory(
primary_admin_email=ADMIN_EMAIL,
include_shared_drives=True,
shared_drive_urls=None,
include_my_drives=True,
my_drive_emails=None,
shared_folder_urls=None,
include_files_shared_with_me=False,
)
web_view_links = _load_web_view_links([0])
failures = _build_failures(web_view_links)
results = list(connector.resolve_errors(failures))
docs = [r for r in results if isinstance(r, Document)]
new_failures = [r for r in results if isinstance(r, ConnectorFailure)]
hierarchy_nodes = [r for r in results if isinstance(r, HierarchyNode)]
assert len(docs) == 1
assert len(new_failures) == 0
assert docs[0].semantic_identifier == "file_0.txt"
# Should yield at least one hierarchy node (the file's parent folder chain)
assert len(hierarchy_nodes) > 0
@patch("onyx.file_processing.extract_file_text.get_unstructured_api_key")
def test_resolve_multiple_files(
mock_api_key: None, # noqa: ARG001
google_drive_service_acct_connector_factory: Callable[..., GoogleDriveConnector],
) -> None:
"""Resolve multiple files across different folders via batch API."""
connector = google_drive_service_acct_connector_factory(
primary_admin_email=ADMIN_EMAIL,
include_shared_drives=True,
shared_drive_urls=None,
include_my_drives=True,
my_drive_emails=None,
shared_folder_urls=None,
include_files_shared_with_me=False,
)
# Pick files from different folders: admin files (0-4), shared drive 1 (20-24), folder_2 (45-49)
file_ids = [0, 1, 20, 21, 45]
web_view_links = _load_web_view_links(file_ids)
failures = _build_failures(web_view_links)
results = list(connector.resolve_errors(failures))
docs = [r for r in results if isinstance(r, Document)]
new_failures = [r for r in results if isinstance(r, ConnectorFailure)]
hierarchy_nodes = [r for r in results if isinstance(r, HierarchyNode)]
assert len(new_failures) == 0
retrieved_names = {doc.semantic_identifier for doc in docs}
expected_names = {f"file_{fid}.txt" for fid in file_ids}
assert expected_names == retrieved_names
# Files span multiple folders, so we should get hierarchy nodes
assert len(hierarchy_nodes) > 0
@patch("onyx.file_processing.extract_file_text.get_unstructured_api_key")
def test_resolve_hierarchy_nodes_are_valid(
mock_api_key: None, # noqa: ARG001
google_drive_service_acct_connector_factory: Callable[..., GoogleDriveConnector],
) -> None:
"""Verify that hierarchy nodes from resolve_errors match expected structure."""
connector = google_drive_service_acct_connector_factory(
primary_admin_email=ADMIN_EMAIL,
include_shared_drives=True,
shared_drive_urls=None,
include_my_drives=True,
my_drive_emails=None,
shared_folder_urls=None,
include_files_shared_with_me=False,
)
# File in folder_1 (inside shared_drive_1) — should walk up to shared_drive_1 root
web_view_links = _load_web_view_links([25])
failures = _build_failures(web_view_links)
results = list(connector.resolve_errors(failures))
hierarchy_nodes = [r for r in results if isinstance(r, HierarchyNode)]
node_ids = {node.raw_node_id for node in hierarchy_nodes}
# File 25 is in folder_1 which is inside shared_drive_1.
# The parent walk must yield at least these two ancestors.
assert (
FOLDER_1_ID in node_ids
), f"Expected folder_1 ({FOLDER_1_ID}) in hierarchy nodes, got: {node_ids}"
assert (
SHARED_DRIVE_1_ID in node_ids
), f"Expected shared_drive_1 ({SHARED_DRIVE_1_ID}) in hierarchy nodes, got: {node_ids}"
for node in hierarchy_nodes:
if node.raw_node_id not in ALL_EXPECTED_HIERARCHY_NODES:
continue
expected = ALL_EXPECTED_HIERARCHY_NODES[node.raw_node_id]
assert node.display_name == expected.display_name, (
f"Display name mismatch for {node.raw_node_id}: "
f"expected '{expected.display_name}', got '{node.display_name}'"
)
assert node.node_type == expected.node_type, (
f"Node type mismatch for {node.raw_node_id}: "
f"expected '{expected.node_type}', got '{node.node_type}'"
)
@patch("onyx.file_processing.extract_file_text.get_unstructured_api_key")
def test_resolve_with_invalid_link(
mock_api_key: None, # noqa: ARG001
google_drive_service_acct_connector_factory: Callable[..., GoogleDriveConnector],
) -> None:
"""Resolve with a mix of valid and invalid links — invalid ones yield ConnectorFailure."""
connector = google_drive_service_acct_connector_factory(
primary_admin_email=ADMIN_EMAIL,
include_shared_drives=True,
shared_drive_urls=None,
include_my_drives=True,
my_drive_emails=None,
shared_folder_urls=None,
include_files_shared_with_me=False,
)
valid_links = _load_web_view_links([0])
invalid_link = "https://drive.google.com/file/d/NONEXISTENT_FILE_ID_12345"
failures = _build_failures(valid_links + [invalid_link])
results = list(connector.resolve_errors(failures))
docs = [r for r in results if isinstance(r, Document)]
new_failures = [r for r in results if isinstance(r, ConnectorFailure)]
assert len(docs) == 1
assert docs[0].semantic_identifier == "file_0.txt"
assert len(new_failures) == 1
assert new_failures[0].failed_document is not None
assert new_failures[0].failed_document.document_id == invalid_link
@patch("onyx.file_processing.extract_file_text.get_unstructured_api_key")
def test_resolve_empty_errors(
mock_api_key: None, # noqa: ARG001
google_drive_service_acct_connector_factory: Callable[..., GoogleDriveConnector],
) -> None:
"""Resolving an empty error list should yield nothing."""
connector = google_drive_service_acct_connector_factory(
primary_admin_email=ADMIN_EMAIL,
include_shared_drives=True,
shared_drive_urls=None,
include_my_drives=True,
my_drive_emails=None,
shared_folder_urls=None,
include_files_shared_with_me=False,
)
results = list(connector.resolve_errors([]))
assert len(results) == 0
@patch("onyx.file_processing.extract_file_text.get_unstructured_api_key")
def test_resolve_entity_failures_are_skipped(
mock_api_key: None, # noqa: ARG001
google_drive_service_acct_connector_factory: Callable[..., GoogleDriveConnector],
) -> None:
"""Entity failures (not document failures) should be skipped by resolve_errors."""
from onyx.connectors.models import EntityFailure
connector = google_drive_service_acct_connector_factory(
primary_admin_email=ADMIN_EMAIL,
include_shared_drives=True,
shared_drive_urls=None,
include_my_drives=True,
my_drive_emails=None,
shared_folder_urls=None,
include_files_shared_with_me=False,
)
entity_failure = ConnectorFailure(
failed_entity=EntityFailure(entity_id="some_stage"),
failure_message="retrieval failure",
)
results = list(connector.resolve_errors([entity_failure]))
assert len(results) == 0

View File

@@ -9,7 +9,6 @@ from unittest.mock import patch
from ee.onyx.db.license import check_seat_availability
from ee.onyx.db.license import delete_license
from ee.onyx.db.license import get_license
from ee.onyx.db.license import get_used_seats
from ee.onyx.db.license import upsert_license
from ee.onyx.server.license.models import LicenseMetadata
from ee.onyx.server.license.models import LicenseSource
@@ -215,43 +214,3 @@ class TestCheckSeatAvailabilityMultiTenant:
assert result.available is False
assert result.error_message is not None
mock_tenant_count.assert_called_once_with("tenant-abc")
class TestGetUsedSeatsAccountTypeFiltering:
"""Verify get_used_seats query excludes SERVICE_ACCOUNT but includes BOT."""
@patch("ee.onyx.db.license.MULTI_TENANT", False)
@patch("onyx.db.engine.sql_engine.get_session_with_current_tenant")
def test_excludes_service_accounts(self, mock_get_session: MagicMock) -> None:
"""SERVICE_ACCOUNT users should not count toward seats."""
mock_session = MagicMock()
mock_get_session.return_value.__enter__ = MagicMock(return_value=mock_session)
mock_get_session.return_value.__exit__ = MagicMock(return_value=False)
mock_session.execute.return_value.scalar.return_value = 5
result = get_used_seats()
assert result == 5
# Inspect the compiled query to verify account_type filter
call_args = mock_session.execute.call_args
query = call_args[0][0]
compiled = str(query.compile(compile_kwargs={"literal_binds": True}))
assert "SERVICE_ACCOUNT" in compiled
# BOT should NOT be excluded
assert "BOT" not in compiled
@patch("ee.onyx.db.license.MULTI_TENANT", False)
@patch("onyx.db.engine.sql_engine.get_session_with_current_tenant")
def test_still_excludes_ext_perm_user(self, mock_get_session: MagicMock) -> None:
"""EXT_PERM_USER exclusion should still be present."""
mock_session = MagicMock()
mock_get_session.return_value.__enter__ = MagicMock(return_value=mock_session)
mock_get_session.return_value.__exit__ = MagicMock(return_value=False)
mock_session.execute.return_value.scalar.return_value = 3
get_used_seats()
call_args = mock_session.execute.call_args
query = call_args[0][0]
compiled = str(query.compile(compile_kwargs={"literal_binds": True}))
assert "EXT_PERM_USER" in compiled

View File

@@ -6,7 +6,6 @@ import requests
from jira import JIRA
from jira.resources import Issue
from onyx.connectors.jira.connector import _JIRA_BULK_FETCH_LIMIT
from onyx.connectors.jira.connector import bulk_fetch_issues
@@ -146,29 +145,3 @@ def test_bulk_fetch_recursive_splitting_raises_on_bad_issue() -> None:
with pytest.raises(requests.exceptions.JSONDecodeError):
bulk_fetch_issues(client, ["1", "2", bad_id, "3", "4", "5"])
def test_bulk_fetch_respects_api_batch_limit() -> None:
"""Requests to the bulkfetch endpoint never exceed _JIRA_BULK_FETCH_LIMIT IDs."""
client = _mock_jira_client()
total_issues = _JIRA_BULK_FETCH_LIMIT * 3 + 7
all_ids = [str(i) for i in range(total_issues)]
batch_sizes: list[int] = []
def _post_side_effect(url: str, json: dict[str, Any]) -> MagicMock: # noqa: ARG001
ids = json["issueIdsOrKeys"]
batch_sizes.append(len(ids))
resp = MagicMock()
resp.json.return_value = {"issues": [_make_raw_issue(i) for i in ids]}
return resp
client._session.post.side_effect = _post_side_effect
result = bulk_fetch_issues(client, all_ids)
assert len(result) == total_issues
# keeping this hardcoded because it's the documented limit
# https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-issues/
assert all(size <= 100 for size in batch_sizes)
assert len(batch_sizes) == 4

View File

@@ -1,67 +0,0 @@
"""Tests for _build_thread_text function."""
from unittest.mock import MagicMock
from unittest.mock import patch
from onyx.context.search.federated.slack_search import _build_thread_text
def _make_msg(user: str, text: str, ts: str) -> dict[str, str]:
return {"user": user, "text": text, "ts": ts}
class TestBuildThreadText:
"""Verify _build_thread_text includes full thread replies up to cap."""
@patch("onyx.context.search.federated.slack_search.batch_get_user_profiles")
def test_includes_all_replies(self, mock_profiles: MagicMock) -> None:
"""All replies within cap are included in output."""
mock_profiles.return_value = {}
messages = [
_make_msg("U1", "parent msg", "1000.0"),
_make_msg("U2", "reply 1", "1001.0"),
_make_msg("U3", "reply 2", "1002.0"),
_make_msg("U4", "reply 3", "1003.0"),
]
result = _build_thread_text(messages, "token", "T123", MagicMock())
assert "parent msg" in result
assert "reply 1" in result
assert "reply 2" in result
assert "reply 3" in result
assert "..." not in result
@patch("onyx.context.search.federated.slack_search.batch_get_user_profiles")
def test_non_thread_returns_parent_only(self, mock_profiles: MagicMock) -> None:
"""Single message (no replies) returns just the parent text."""
mock_profiles.return_value = {}
messages = [_make_msg("U1", "just a message", "1000.0")]
result = _build_thread_text(messages, "token", "T123", MagicMock())
assert "just a message" in result
assert "Replies:" not in result
@patch("onyx.context.search.federated.slack_search.batch_get_user_profiles")
def test_parent_always_first(self, mock_profiles: MagicMock) -> None:
"""Thread parent message is always the first line of output."""
mock_profiles.return_value = {}
messages = [
_make_msg("U1", "I am the parent", "1000.0"),
_make_msg("U2", "I am a reply", "1001.0"),
]
result = _build_thread_text(messages, "token", "T123", MagicMock())
parent_pos = result.index("I am the parent")
reply_pos = result.index("I am a reply")
assert parent_pos < reply_pos
@patch("onyx.context.search.federated.slack_search.batch_get_user_profiles")
def test_user_profiles_resolved(self, mock_profiles: MagicMock) -> None:
"""User IDs in thread text are replaced with display names."""
mock_profiles.return_value = {"U1": "Alice", "U2": "Bob"}
messages = [
_make_msg("U1", "hello", "1000.0"),
_make_msg("U2", "world", "1001.0"),
]
result = _build_thread_text(messages, "token", "T123", MagicMock())
assert "Alice" in result
assert "Bob" in result
assert "<@U1>" not in result
assert "<@U2>" not in result

View File

@@ -1,108 +0,0 @@
"""Tests for Slack URL parsing and direct thread fetch via URL override."""
from unittest.mock import MagicMock
from unittest.mock import patch
from onyx.context.search.federated.models import DirectThreadFetch
from onyx.context.search.federated.slack_search import _fetch_thread_from_url
from onyx.context.search.federated.slack_search_utils import extract_slack_message_urls
class TestExtractSlackMessageUrls:
"""Verify URL parsing extracts channel_id and timestamp correctly."""
def test_standard_url(self) -> None:
query = "summarize https://mycompany.slack.com/archives/C097NBWMY8Y/p1775491616524769"
results = extract_slack_message_urls(query)
assert len(results) == 1
assert results[0] == ("C097NBWMY8Y", "1775491616.524769")
def test_multiple_urls(self) -> None:
query = (
"compare https://co.slack.com/archives/C111/p1234567890123456 "
"and https://co.slack.com/archives/C222/p9876543210987654"
)
results = extract_slack_message_urls(query)
assert len(results) == 2
assert results[0] == ("C111", "1234567890.123456")
assert results[1] == ("C222", "9876543210.987654")
def test_no_urls(self) -> None:
query = "what happened in #general last week?"
results = extract_slack_message_urls(query)
assert len(results) == 0
def test_non_slack_url_ignored(self) -> None:
query = "check https://google.com/archives/C111/p1234567890123456"
results = extract_slack_message_urls(query)
assert len(results) == 0
def test_timestamp_conversion(self) -> None:
"""p prefix removed, dot inserted after 10th digit."""
query = "https://x.slack.com/archives/CABC123/p1775491616524769"
results = extract_slack_message_urls(query)
channel_id, ts = results[0]
assert channel_id == "CABC123"
assert ts == "1775491616.524769"
assert not ts.startswith("p")
assert "." in ts
class TestFetchThreadFromUrl:
"""Verify _fetch_thread_from_url calls conversations.replies and returns SlackMessage."""
@patch("onyx.context.search.federated.slack_search._build_thread_text")
@patch("onyx.context.search.federated.slack_search.WebClient")
def test_successful_fetch(
self, mock_webclient_cls: MagicMock, mock_build_thread: MagicMock
) -> None:
mock_client = MagicMock()
mock_webclient_cls.return_value = mock_client
# Mock conversations_replies
mock_response = MagicMock()
mock_response.get.return_value = [
{"user": "U1", "text": "parent", "ts": "1775491616.524769"},
{"user": "U2", "text": "reply 1", "ts": "1775491617.000000"},
{"user": "U3", "text": "reply 2", "ts": "1775491618.000000"},
]
mock_client.conversations_replies.return_value = mock_response
# Mock channel info
mock_ch_response = MagicMock()
mock_ch_response.get.return_value = {"name": "general"}
mock_client.conversations_info.return_value = mock_ch_response
mock_build_thread.return_value = (
"U1: parent\n\nReplies:\n\nU2: reply 1\n\nU3: reply 2"
)
fetch = DirectThreadFetch(
channel_id="C097NBWMY8Y", thread_ts="1775491616.524769"
)
result = _fetch_thread_from_url(fetch, "xoxp-token")
assert len(result.messages) == 1
msg = result.messages[0]
assert msg.channel_id == "C097NBWMY8Y"
assert msg.thread_id is None # Prevents double-enrichment
assert msg.slack_score == 100000.0
assert "parent" in msg.text
mock_client.conversations_replies.assert_called_once_with(
channel="C097NBWMY8Y", ts="1775491616.524769"
)
@patch("onyx.context.search.federated.slack_search.WebClient")
def test_api_error_returns_empty(self, mock_webclient_cls: MagicMock) -> None:
from slack_sdk.errors import SlackApiError
mock_client = MagicMock()
mock_webclient_cls.return_value = mock_client
mock_client.conversations_replies.side_effect = SlackApiError(
message="channel_not_found",
response=MagicMock(status_code=404),
)
fetch = DirectThreadFetch(channel_id="CBAD", thread_ts="1234567890.123456")
result = _fetch_thread_from_url(fetch, "xoxp-token")
assert len(result.messages) == 0

View File

@@ -1,769 +0,0 @@
"""Unit tests for Chunker._chunk_document_with_sections.
These tests use a fake character-level tokenizer so every char counts as
exactly one token. This makes token-limit arithmetic deterministic and lets
us exercise every branch of the method without pulling real embedding
models into the test.
"""
import pytest
from onyx.configs.constants import DocumentSource
from onyx.configs.constants import SECTION_SEPARATOR
from onyx.connectors.models import IndexingDocument
from onyx.connectors.models import Section
from onyx.indexing import chunker as chunker_module
from onyx.indexing.chunker import Chunker
from onyx.natural_language_processing.utils import BaseTokenizer
class CharTokenizer(BaseTokenizer):
"""1 character == 1 token. Deterministic & trivial to reason about."""
def encode(self, string: str) -> list[int]:
return [ord(c) for c in string]
def tokenize(self, string: str) -> list[str]:
return list(string)
def decode(self, tokens: list[int]) -> str:
return "".join(chr(t) for t in tokens)
# With a char-level tokenizer, each char is a token. 200 is comfortably
# above BLURB_SIZE (128) so the blurb splitter won't get weird on small text.
CHUNK_LIMIT = 200
def _make_chunker(
chunk_token_limit: int = CHUNK_LIMIT,
enable_multipass: bool = False,
) -> Chunker:
return Chunker(
tokenizer=CharTokenizer(),
enable_multipass=enable_multipass,
enable_large_chunks=False,
enable_contextual_rag=False,
chunk_token_limit=chunk_token_limit,
)
def _make_doc(
sections: list[Section],
title: str | None = "Test Doc",
doc_id: str = "doc1",
) -> IndexingDocument:
return IndexingDocument(
id=doc_id,
source=DocumentSource.WEB,
semantic_identifier=doc_id,
title=title,
metadata={},
sections=[], # real sections unused — method reads processed_sections
processed_sections=sections,
)
# --- Empty / degenerate input -------------------------------------------------
def test_empty_processed_sections_returns_single_empty_safety_chunk() -> None:
"""No sections at all should still yield one empty chunk (the
`or not chunks` safety branch at the end)."""
chunker = _make_chunker()
doc = _make_doc(sections=[])
chunks = chunker._chunk_document_with_sections(
document=doc,
sections=[],
title_prefix="TITLE\n",
metadata_suffix_semantic="meta_sem",
metadata_suffix_keyword="meta_kw",
content_token_limit=CHUNK_LIMIT,
)
assert len(chunks) == 1
assert chunks[0].content == ""
assert chunks[0].chunk_id == 0
assert chunks[0].title_prefix == "TITLE\n"
assert chunks[0].metadata_suffix_semantic == "meta_sem"
assert chunks[0].metadata_suffix_keyword == "meta_kw"
# safe default link offsets
assert chunks[0].source_links == {0: ""}
def test_empty_section_on_first_position_without_title_is_skipped() -> None:
"""Doc has no title, first section has empty text — the guard
`(not document.title or section_idx > 0)` means it IS skipped."""
chunker = _make_chunker()
doc = _make_doc(
sections=[Section(text="", link="l0")],
title=None,
)
chunks = chunker._chunk_document_with_sections(
document=doc,
sections=doc.processed_sections,
title_prefix="",
metadata_suffix_semantic="",
metadata_suffix_keyword="",
content_token_limit=CHUNK_LIMIT,
)
# skipped → no real content, but safety branch still yields 1 empty chunk
assert len(chunks) == 1
assert chunks[0].content == ""
def test_empty_section_on_later_position_is_skipped_even_with_title() -> None:
"""Index > 0 empty sections are skipped regardless of title."""
chunker = _make_chunker()
doc = _make_doc(
sections=[
Section(text="Alpha.", link="l0"),
Section(text="", link="l1"), # should be skipped
Section(text="Beta.", link="l2"),
],
)
chunks = chunker._chunk_document_with_sections(
document=doc,
sections=doc.processed_sections,
title_prefix="",
metadata_suffix_semantic="",
metadata_suffix_keyword="",
content_token_limit=CHUNK_LIMIT,
)
assert len(chunks) == 1
assert "Alpha." in chunks[0].content
assert "Beta." in chunks[0].content
# link offsets should only contain l0 and l2 (no l1)
assert "l1" not in (chunks[0].source_links or {}).values()
# --- Single text section ------------------------------------------------------
def test_single_small_text_section_becomes_one_chunk() -> None:
chunker = _make_chunker()
doc = _make_doc(sections=[Section(text="Hello world.", link="https://a")])
chunks = chunker._chunk_document_with_sections(
document=doc,
sections=doc.processed_sections,
title_prefix="TITLE\n",
metadata_suffix_semantic="ms",
metadata_suffix_keyword="mk",
content_token_limit=CHUNK_LIMIT,
)
assert len(chunks) == 1
chunk = chunks[0]
assert chunk.content == "Hello world."
assert chunk.source_links == {0: "https://a"}
assert chunk.title_prefix == "TITLE\n"
assert chunk.metadata_suffix_semantic == "ms"
assert chunk.metadata_suffix_keyword == "mk"
assert chunk.section_continuation is False
assert chunk.image_file_id is None
# --- Multiple text sections combined -----------------------------------------
def test_multiple_small_sections_combine_into_one_chunk() -> None:
chunker = _make_chunker()
sections = [
Section(text="Part one.", link="l1"),
Section(text="Part two.", link="l2"),
Section(text="Part three.", link="l3"),
]
doc = _make_doc(sections=sections)
chunks = chunker._chunk_document_with_sections(
document=doc,
sections=doc.processed_sections,
title_prefix="",
metadata_suffix_semantic="",
metadata_suffix_keyword="",
content_token_limit=CHUNK_LIMIT,
)
assert len(chunks) == 1
expected = SECTION_SEPARATOR.join(["Part one.", "Part two.", "Part three."])
assert chunks[0].content == expected
# link_offsets: indexed by shared_precompare_cleanup length of the
# chunk_text *before* each section was appended.
# "" -> "", len 0
# "Part one." -> "partone", len 7
# "Part one.\n\nPart two." -> "partoneparttwo", len 14
assert chunks[0].source_links == {0: "l1", 7: "l2", 14: "l3"}
def test_sections_overflow_into_second_chunk() -> None:
"""Two sections that together exceed content_token_limit should
finalize the first as one chunk and start a new one."""
chunker = _make_chunker()
# char-level: 120 char section → 120 tokens. 2 of these plus separator
# exceed a 200-token limit, forcing a flush.
a = "A" * 120
b = "B" * 120
doc = _make_doc(
sections=[
Section(text=a, link="la"),
Section(text=b, link="lb"),
],
)
chunks = chunker._chunk_document_with_sections(
document=doc,
sections=doc.processed_sections,
title_prefix="",
metadata_suffix_semantic="",
metadata_suffix_keyword="",
content_token_limit=CHUNK_LIMIT,
)
assert len(chunks) == 2
assert chunks[0].content == a
assert chunks[1].content == b
# first chunk is not a continuation; second starts a new section → not either
assert chunks[0].section_continuation is False
assert chunks[1].section_continuation is False
# chunk_ids should be sequential starting at 0
assert chunks[0].chunk_id == 0
assert chunks[1].chunk_id == 1
# links routed appropriately
assert chunks[0].source_links == {0: "la"}
assert chunks[1].source_links == {0: "lb"}
# --- Image section handling --------------------------------------------------
def test_image_only_section_produces_single_chunk_with_image_id() -> None:
chunker = _make_chunker()
doc = _make_doc(
sections=[
Section(
text="summary of image",
link="https://img",
image_file_id="img-abc",
)
],
)
chunks = chunker._chunk_document_with_sections(
document=doc,
sections=doc.processed_sections,
title_prefix="",
metadata_suffix_semantic="",
metadata_suffix_keyword="",
content_token_limit=CHUNK_LIMIT,
)
assert len(chunks) == 1
assert chunks[0].image_file_id == "img-abc"
assert chunks[0].content == "summary of image"
assert chunks[0].source_links == {0: "https://img"}
def test_image_section_flushes_pending_text_and_creates_its_own_chunk() -> None:
"""A buffered text section followed by an image section:
the pending text should be flushed first, then the image chunk."""
chunker = _make_chunker()
doc = _make_doc(
sections=[
Section(text="Pending text.", link="ltext"),
Section(
text="image summary",
link="limage",
image_file_id="img-1",
),
Section(text="Trailing text.", link="ltail"),
],
)
chunks = chunker._chunk_document_with_sections(
document=doc,
sections=doc.processed_sections,
title_prefix="",
metadata_suffix_semantic="",
metadata_suffix_keyword="",
content_token_limit=CHUNK_LIMIT,
)
assert len(chunks) == 3
# 0: flushed pending text
assert chunks[0].content == "Pending text."
assert chunks[0].image_file_id is None
assert chunks[0].source_links == {0: "ltext"}
# 1: image chunk
assert chunks[1].content == "image summary"
assert chunks[1].image_file_id == "img-1"
assert chunks[1].source_links == {0: "limage"}
# 2: trailing text, started fresh after image
assert chunks[2].content == "Trailing text."
assert chunks[2].image_file_id is None
assert chunks[2].source_links == {0: "ltail"}
def test_image_section_without_link_gets_empty_links_dict() -> None:
"""If an image section has no link, links param is {} (not {0: ""})."""
chunker = _make_chunker()
doc = _make_doc(
sections=[
Section(text="img", link=None, image_file_id="img-xyz"),
],
)
chunks = chunker._chunk_document_with_sections(
document=doc,
sections=doc.processed_sections,
title_prefix="",
metadata_suffix_semantic="",
metadata_suffix_keyword="",
content_token_limit=CHUNK_LIMIT,
)
assert len(chunks) == 1
assert chunks[0].image_file_id == "img-xyz"
# _create_chunk falls back to {0: ""} when given an empty dict
assert chunks[0].source_links == {0: ""}
# --- Oversized section splitting ---------------------------------------------
def test_oversized_section_is_split_across_multiple_chunks() -> None:
"""A section whose text exceeds content_token_limit should be passed
through chunk_splitter and yield >1 chunks; only the first is not a
continuation."""
chunker = _make_chunker()
# Build a section whose char-count is well over CHUNK_LIMIT (200), made
# of many short sentences so chonkie's SentenceChunker can split cleanly.
section_text = (
"Alpha beta gamma. Delta epsilon zeta. Eta theta iota. "
"Kappa lambda mu. Nu xi omicron. Pi rho sigma. Tau upsilon phi. "
"Chi psi omega. One two three. Four five six. Seven eight nine. "
"Ten eleven twelve. Thirteen fourteen fifteen. "
"Sixteen seventeen eighteen. Nineteen twenty."
)
assert len(section_text) > CHUNK_LIMIT
doc = _make_doc(
sections=[Section(text=section_text, link="big-link")],
)
chunks = chunker._chunk_document_with_sections(
document=doc,
sections=doc.processed_sections,
title_prefix="",
metadata_suffix_semantic="",
metadata_suffix_keyword="",
content_token_limit=CHUNK_LIMIT,
)
assert len(chunks) >= 2
# First chunk is fresh, rest are continuations
assert chunks[0].section_continuation is False
for c in chunks[1:]:
assert c.section_continuation is True
# Every produced chunk should carry the section's link
for c in chunks:
assert c.source_links == {0: "big-link"}
# Concatenated content should roughly cover the original (allowing
# for chunker boundary whitespace differences).
joined = "".join(c.content for c in chunks)
for word in ("Alpha", "omega", "twenty"):
assert word in joined
def test_oversized_section_flushes_pending_text_first() -> None:
"""A buffered text section followed by an oversized section should
flush the pending chunk first, then emit the split chunks."""
chunker = _make_chunker()
pending = "Pending buffered text."
big = (
"Alpha beta gamma. Delta epsilon zeta. Eta theta iota. "
"Kappa lambda mu. Nu xi omicron. Pi rho sigma. Tau upsilon phi. "
"Chi psi omega. One two three. Four five six. Seven eight nine. "
"Ten eleven twelve. Thirteen fourteen fifteen. Sixteen seventeen."
)
assert len(big) > CHUNK_LIMIT
doc = _make_doc(
sections=[
Section(text=pending, link="l-pending"),
Section(text=big, link="l-big"),
],
)
chunks = chunker._chunk_document_with_sections(
document=doc,
sections=doc.processed_sections,
title_prefix="",
metadata_suffix_semantic="",
metadata_suffix_keyword="",
content_token_limit=CHUNK_LIMIT,
)
# First chunk is the flushed pending text
assert chunks[0].content == pending
assert chunks[0].source_links == {0: "l-pending"}
assert chunks[0].section_continuation is False
# Remaining chunks correspond to the oversized section
assert len(chunks) >= 2
for c in chunks[1:]:
assert c.source_links == {0: "l-big"}
# Within the oversized section, the first is fresh and the rest are
# continuations.
assert chunks[1].section_continuation is False
for c in chunks[2:]:
assert c.section_continuation is True
# --- Title prefix / metadata propagation -------------------------------------
def test_title_prefix_and_metadata_propagate_to_all_chunks() -> None:
chunker = _make_chunker()
doc = _make_doc(
sections=[
Section(text="A" * 120, link="la"),
Section(text="B" * 120, link="lb"),
],
)
chunks = chunker._chunk_document_with_sections(
document=doc,
sections=doc.processed_sections,
title_prefix="MY_TITLE\n",
metadata_suffix_semantic="MS",
metadata_suffix_keyword="MK",
content_token_limit=CHUNK_LIMIT,
)
assert len(chunks) == 2
for chunk in chunks:
assert chunk.title_prefix == "MY_TITLE\n"
assert chunk.metadata_suffix_semantic == "MS"
assert chunk.metadata_suffix_keyword == "MK"
# --- chunk_id monotonicity ---------------------------------------------------
def test_chunk_ids_are_sequential_starting_at_zero() -> None:
chunker = _make_chunker()
doc = _make_doc(
sections=[
Section(text="A" * 120, link="la"),
Section(text="B" * 120, link="lb"),
Section(text="C" * 120, link="lc"),
],
)
chunks = chunker._chunk_document_with_sections(
document=doc,
sections=doc.processed_sections,
title_prefix="",
metadata_suffix_semantic="",
metadata_suffix_keyword="",
content_token_limit=CHUNK_LIMIT,
)
assert [c.chunk_id for c in chunks] == list(range(len(chunks)))
# --- Overflow accumulation behavior ------------------------------------------
def test_overflow_flush_then_subsequent_section_joins_new_chunk() -> None:
"""After an overflow flush starts a new chunk, the next fitting section
should combine into that same new chunk (not spawn a third)."""
chunker = _make_chunker()
# 120 + 120 > 200 → first two sections produce two chunks.
# Third section is small (20 chars) → should fit with second.
doc = _make_doc(
sections=[
Section(text="A" * 120, link="la"),
Section(text="B" * 120, link="lb"),
Section(text="C" * 20, link="lc"),
],
)
chunks = chunker._chunk_document_with_sections(
document=doc,
sections=doc.processed_sections,
title_prefix="",
metadata_suffix_semantic="",
metadata_suffix_keyword="",
content_token_limit=CHUNK_LIMIT,
)
assert len(chunks) == 2
assert chunks[0].content == "A" * 120
assert chunks[1].content == ("B" * 120) + SECTION_SEPARATOR + ("C" * 20)
# link_offsets on second chunk: lb at 0, lc at precompare-len("BBBB...")=120
assert chunks[1].source_links == {0: "lb", 120: "lc"}
def test_small_section_after_oversized_starts_a_fresh_chunk() -> None:
"""After an oversized section is emitted as its own chunks, the internal
accumulator should be empty so a following small section starts a new
chunk instead of being swallowed."""
chunker = _make_chunker()
big = (
"Alpha beta gamma. Delta epsilon zeta. Eta theta iota. "
"Kappa lambda mu. Nu xi omicron. Pi rho sigma. Tau upsilon phi. "
"Chi psi omega. One two three. Four five six. Seven eight nine. "
"Ten eleven twelve. Thirteen fourteen fifteen. Sixteen seventeen."
)
assert len(big) > CHUNK_LIMIT
doc = _make_doc(
sections=[
Section(text=big, link="l-big"),
Section(text="Tail text.", link="l-tail"),
],
)
chunks = chunker._chunk_document_with_sections(
document=doc,
sections=doc.processed_sections,
title_prefix="",
metadata_suffix_semantic="",
metadata_suffix_keyword="",
content_token_limit=CHUNK_LIMIT,
)
# All-but-last chunks belong to the oversized section; the very last is
# the tail text starting fresh (not a continuation).
assert len(chunks) >= 2
assert chunks[-1].content == "Tail text."
assert chunks[-1].source_links == {0: "l-tail"}
assert chunks[-1].section_continuation is False
# And earlier oversized chunks never leaked the tail link
for c in chunks[:-1]:
assert c.source_links == {0: "l-big"}
# --- STRICT_CHUNK_TOKEN_LIMIT fallback path ----------------------------------
def test_strict_chunk_token_limit_subdivides_oversized_split(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""When STRICT_CHUNK_TOKEN_LIMIT is enabled and chonkie's chunk_splitter
still produces a piece larger than content_token_limit (e.g. a single
no-period run), the code must fall back to _split_oversized_chunk."""
monkeypatch.setattr(chunker_module, "STRICT_CHUNK_TOKEN_LIMIT", True)
chunker = _make_chunker()
# 500 non-whitespace chars with no sentence boundaries — chonkie will
# return it as one oversized piece (>200) which triggers the fallback.
run = "a" * 500
doc = _make_doc(sections=[Section(text=run, link="l-run")])
chunks = chunker._chunk_document_with_sections(
document=doc,
sections=doc.processed_sections,
title_prefix="",
metadata_suffix_semantic="",
metadata_suffix_keyword="",
content_token_limit=CHUNK_LIMIT,
)
# With CHUNK_LIMIT=200 and a 500-char run we expect ceil(500/200)=3 sub-chunks.
assert len(chunks) == 3
# First is fresh, rest are continuations (is_continuation=(j != 0))
assert chunks[0].section_continuation is False
assert chunks[1].section_continuation is True
assert chunks[2].section_continuation is True
# All carry the section link
for c in chunks:
assert c.source_links == {0: "l-run"}
# NOTE: we do NOT assert the chunks are at or below content_token_limit.
# _split_oversized_chunk joins tokens with " ", which means the resulting
# chunk contents can exceed the limit when tokens are short. That's a
# quirk of the current implementation and this test pins the window
# slicing, not the post-join length.
def test_strict_chunk_token_limit_disabled_allows_oversized_split(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Same pathological input, but with STRICT disabled: the oversized
split is emitted verbatim as a single chunk (current behavior)."""
monkeypatch.setattr(chunker_module, "STRICT_CHUNK_TOKEN_LIMIT", False)
chunker = _make_chunker()
run = "a" * 500
doc = _make_doc(sections=[Section(text=run, link="l-run")])
chunks = chunker._chunk_document_with_sections(
document=doc,
sections=doc.processed_sections,
title_prefix="",
metadata_suffix_semantic="",
metadata_suffix_keyword="",
content_token_limit=CHUNK_LIMIT,
)
assert len(chunks) == 1
assert chunks[0].content == run
assert chunks[0].section_continuation is False
# --- First-section-with-empty-text-but-document-has-title edge case ----------
def test_first_empty_section_with_title_is_processed_not_skipped() -> None:
"""The guard `(not document.title or section_idx > 0)` means: when
the doc has a title AND it's the first section, an empty text section
is NOT skipped. This pins current behavior so a refactor can't silently
change it."""
chunker = _make_chunker()
doc = _make_doc(
sections=[
Section(text="", link="l0"), # empty first section, kept
Section(text="Real content.", link="l1"),
],
title="Has A Title",
)
chunks = chunker._chunk_document_with_sections(
document=doc,
sections=doc.processed_sections,
title_prefix="",
metadata_suffix_semantic="",
metadata_suffix_keyword="",
content_token_limit=CHUNK_LIMIT,
)
assert len(chunks) == 1
assert chunks[0].content == "Real content."
# First (empty) section did register a link_offset at 0 before being
# overwritten; that offset is then reused when "Real content." is added,
# because shared_precompare_cleanup("") is still "". End state: {0: "l1"}
assert chunks[0].source_links == {0: "l1"}
# --- clean_text is applied to section text -----------------------------------
def test_clean_text_strips_control_chars_from_section_content() -> None:
"""clean_text() should remove control chars before the text enters the
accumulator — verifies the call isn't dropped by a refactor."""
chunker = _make_chunker()
# NUL + BEL are control chars below 0x20 and not \n or \t → should be
# stripped by clean_text.
dirty = "Hello\x00 World\x07!"
doc = _make_doc(sections=[Section(text=dirty, link="l1")])
chunks = chunker._chunk_document_with_sections(
document=doc,
sections=doc.processed_sections,
title_prefix="",
metadata_suffix_semantic="",
metadata_suffix_keyword="",
content_token_limit=CHUNK_LIMIT,
)
assert len(chunks) == 1
assert chunks[0].content == "Hello World!"
# --- None-valued fields ------------------------------------------------------
def test_section_with_none_text_behaves_like_empty_string() -> None:
"""`section.text` may be None — the method coerces via
`str(section.text or "")`, so a None-text section behaves identically
to an empty one (skipped unless it's the first section of a titled doc)."""
chunker = _make_chunker()
doc = _make_doc(
sections=[
Section(text="Alpha.", link="la"),
Section(text=None, link="lnone"), # idx 1 → skipped
Section(text="Beta.", link="lb"),
],
)
chunks = chunker._chunk_document_with_sections(
document=doc,
sections=doc.processed_sections,
title_prefix="",
metadata_suffix_semantic="",
metadata_suffix_keyword="",
content_token_limit=CHUNK_LIMIT,
)
assert len(chunks) == 1
assert "Alpha." in chunks[0].content
assert "Beta." in chunks[0].content
assert "lnone" not in (chunks[0].source_links or {}).values()
# --- Trailing empty chunk suppression ----------------------------------------
def test_no_trailing_empty_chunk_when_last_section_was_image() -> None:
"""If the final section was an image (which emits its own chunk and
resets chunk_text), the safety `or not chunks` branch should NOT fire
because chunks is non-empty. Pin this explicitly."""
chunker = _make_chunker()
doc = _make_doc(
sections=[
Section(text="Leading text.", link="ltext"),
Section(
text="img summary", link="limg", image_file_id="img-final"
),
],
)
chunks = chunker._chunk_document_with_sections(
document=doc,
sections=doc.processed_sections,
title_prefix="",
metadata_suffix_semantic="",
metadata_suffix_keyword="",
content_token_limit=CHUNK_LIMIT,
)
assert len(chunks) == 2
assert chunks[0].content == "Leading text."
assert chunks[0].image_file_id is None
assert chunks[1].content == "img summary"
assert chunks[1].image_file_id == "img-final"
# Crucially: no third empty chunk got appended at the end.
def test_no_trailing_empty_chunk_when_last_section_was_oversized() -> None:
"""Same guarantee for oversized sections: their splits fully clear the
accumulator, and the trailing safety branch should be a no-op."""
chunker = _make_chunker()
big = (
"Alpha beta gamma. Delta epsilon zeta. Eta theta iota. "
"Kappa lambda mu. Nu xi omicron. Pi rho sigma. Tau upsilon phi. "
"Chi psi omega. One two three. Four five six. Seven eight nine. "
"Ten eleven twelve. Thirteen fourteen fifteen. Sixteen seventeen."
)
assert len(big) > CHUNK_LIMIT
doc = _make_doc(sections=[Section(text=big, link="l-big")])
chunks = chunker._chunk_document_with_sections(
document=doc,
sections=doc.processed_sections,
title_prefix="",
metadata_suffix_semantic="",
metadata_suffix_keyword="",
content_token_limit=CHUNK_LIMIT,
)
# Every chunk should be non-empty — no dangling "" chunk at the tail.
assert all(c.content.strip() for c in chunks)

View File

@@ -505,7 +505,6 @@ class TestGetLMStudioAvailableModels:
mock_session = MagicMock()
mock_provider = MagicMock()
mock_provider.api_base = "http://localhost:1234"
mock_provider.custom_config = {"LM_STUDIO_API_KEY": "stored-secret"}
response = {

View File

@@ -2,7 +2,6 @@
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock
from unittest.mock import patch
from uuid import uuid4
@@ -10,9 +9,7 @@ from uuid import uuid4
from fastapi import Response
from sqlalchemy.exc import IntegrityError
from ee.onyx.server.scim.api import _check_seat_availability
from ee.onyx.server.scim.api import _scim_name_to_str
from ee.onyx.server.scim.api import _seat_lock_id_for_tenant
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
@@ -744,80 +741,3 @@ class TestEmailCasePreservation:
resource = parse_scim_user(result)
assert resource.userName == "Alice@Example.COM"
assert resource.emails[0].value == "Alice@Example.COM"
class TestSeatLock:
"""Tests for the advisory lock in _check_seat_availability."""
@patch("ee.onyx.server.scim.api.get_current_tenant_id", return_value="tenant_abc")
def test_acquires_advisory_lock_before_checking(
self,
_mock_tenant: MagicMock,
mock_dal: MagicMock,
) -> None:
"""The advisory lock must be acquired before the seat check runs."""
call_order: list[str] = []
def track_execute(stmt: Any, _params: Any = None) -> None:
if "pg_advisory_xact_lock" in str(stmt):
call_order.append("lock")
mock_dal.session.execute.side_effect = track_execute
with patch(
"ee.onyx.server.scim.api.fetch_ee_implementation_or_noop"
) as mock_fetch:
mock_result = MagicMock()
mock_result.available = True
mock_fn = MagicMock(return_value=mock_result)
mock_fetch.return_value = mock_fn
def track_check(*_args: Any, **_kwargs: Any) -> Any:
call_order.append("check")
return mock_result
mock_fn.side_effect = track_check
_check_seat_availability(mock_dal)
assert call_order == ["lock", "check"]
@patch("ee.onyx.server.scim.api.get_current_tenant_id", return_value="tenant_xyz")
def test_lock_uses_tenant_scoped_key(
self,
_mock_tenant: MagicMock,
mock_dal: MagicMock,
) -> None:
"""The lock id must be derived from the tenant via _seat_lock_id_for_tenant."""
mock_result = MagicMock()
mock_result.available = True
mock_check = MagicMock(return_value=mock_result)
with patch(
"ee.onyx.server.scim.api.fetch_ee_implementation_or_noop",
return_value=mock_check,
):
_check_seat_availability(mock_dal)
mock_dal.session.execute.assert_called_once()
params = mock_dal.session.execute.call_args[0][1]
assert params["lock_id"] == _seat_lock_id_for_tenant("tenant_xyz")
def test_seat_lock_id_is_stable_and_tenant_scoped(self) -> None:
"""Lock id must be deterministic and differ across tenants."""
assert _seat_lock_id_for_tenant("t1") == _seat_lock_id_for_tenant("t1")
assert _seat_lock_id_for_tenant("t1") != _seat_lock_id_for_tenant("t2")
def test_no_lock_when_ee_absent(
self,
mock_dal: MagicMock,
) -> None:
"""No advisory lock should be acquired when the EE check is absent."""
with patch(
"ee.onyx.server.scim.api.fetch_ee_implementation_or_noop",
return_value=None,
):
result = _check_seat_availability(mock_dal)
assert result is None
mock_dal.session.execute.assert_not_called()

View File

@@ -1,17 +1,3 @@
# OAuth callback page must be served by the web server (Next.js),
# not the MCP server. Exact match takes priority over the regex below.
location = /mcp/oauth/callback {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Port $server_port;
proxy_set_header Host $host;
proxy_http_version 1.1;
proxy_redirect off;
proxy_pass http://web_server;
}
# MCP Server - Model Context Protocol for LLM integrations
# Match /mcp, /mcp/, or /mcp/* but NOT /mcpserver, /mcpapi, etc.
location ~ ^/mcp(/.*)?$ {

View File

@@ -5,7 +5,7 @@ home: https://www.onyx.app/
sources:
- "https://github.com/onyx-dot-app/onyx"
type: application
version: 0.4.44
version: 0.4.40
appVersion: latest
annotations:
category: Productivity

View File

@@ -1,349 +0,0 @@
{
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": { "type": "grafana", "uid": "-- Grafana --" },
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 1,
"id": null,
"links": [],
"liveNow": true,
"panels": [
{
"title": "Client-Side Search Latency (P50 / P95 / P99)",
"description": "End-to-end latency as measured by the Python client, including network round-trip and serialization overhead.",
"type": "timeseries",
"gridPos": { "h": 10, "w": 12, "x": 0, "y": 0 },
"id": 1,
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisLabel": "seconds",
"axisPlacement": "auto",
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"lineInterpolation": "smooth",
"lineWidth": 2,
"pointSize": 5,
"scaleDistribution": { "type": "linear" },
"showPoints": "never",
"spanNulls": false,
"stacking": { "group": "A", "mode": "none" },
"thresholdsStyle": { "mode": "dashed" }
},
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 0.5 },
{ "color": "red", "value": 2.0 }
]
},
"unit": "s",
"min": 0
},
"overrides": []
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
"expr": "histogram_quantile(0.5, sum by (le) (rate(onyx_opensearch_search_client_duration_seconds_bucket[5m])))",
"legendFormat": "P50",
"refId": "A"
},
{
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
"expr": "histogram_quantile(0.95, sum by (le) (rate(onyx_opensearch_search_client_duration_seconds_bucket[5m])))",
"legendFormat": "P95",
"refId": "B"
},
{
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
"expr": "histogram_quantile(0.99, sum by (le) (rate(onyx_opensearch_search_client_duration_seconds_bucket[5m])))",
"legendFormat": "P99",
"refId": "C"
}
]
},
{
"title": "Server-Side Search Latency (P50 / P95 / P99)",
"description": "OpenSearch server-side execution time from the 'took' field in the response. Does not include network or client-side overhead.",
"type": "timeseries",
"gridPos": { "h": 10, "w": 12, "x": 12, "y": 0 },
"id": 2,
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisLabel": "seconds",
"axisPlacement": "auto",
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"lineInterpolation": "smooth",
"lineWidth": 2,
"pointSize": 5,
"scaleDistribution": { "type": "linear" },
"showPoints": "never",
"spanNulls": false,
"stacking": { "group": "A", "mode": "none" },
"thresholdsStyle": { "mode": "dashed" }
},
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 0.5 },
{ "color": "red", "value": 2.0 }
]
},
"unit": "s",
"min": 0
},
"overrides": []
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
"expr": "histogram_quantile(0.5, sum by (le) (rate(onyx_opensearch_search_server_duration_seconds_bucket[5m])))",
"legendFormat": "P50",
"refId": "A"
},
{
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
"expr": "histogram_quantile(0.95, sum by (le) (rate(onyx_opensearch_search_server_duration_seconds_bucket[5m])))",
"legendFormat": "P95",
"refId": "B"
},
{
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
"expr": "histogram_quantile(0.99, sum by (le) (rate(onyx_opensearch_search_server_duration_seconds_bucket[5m])))",
"legendFormat": "P99",
"refId": "C"
}
]
},
{
"title": "Client-Side Latency by Search Type (P95)",
"description": "P95 client-side latency broken down by search type (hybrid, keyword, semantic, random, doc_id_retrieval).",
"type": "timeseries",
"gridPos": { "h": 10, "w": 12, "x": 0, "y": 10 },
"id": 3,
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisLabel": "seconds",
"axisPlacement": "auto",
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"lineInterpolation": "smooth",
"lineWidth": 2,
"pointSize": 5,
"scaleDistribution": { "type": "linear" },
"showPoints": "never",
"spanNulls": false,
"stacking": { "group": "A", "mode": "none" },
"thresholdsStyle": { "mode": "off" }
},
"unit": "s",
"min": 0
},
"overrides": []
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
"expr": "histogram_quantile(0.95, sum by (search_type, le) (rate(onyx_opensearch_search_client_duration_seconds_bucket[5m])))",
"legendFormat": "{{ search_type }}",
"refId": "A"
}
]
},
{
"title": "Search Throughput by Type",
"description": "Searches per second broken down by search type.",
"type": "timeseries",
"gridPos": { "h": 10, "w": 12, "x": 12, "y": 10 },
"id": 4,
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisLabel": "searches/s",
"axisPlacement": "auto",
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"lineInterpolation": "smooth",
"lineWidth": 2,
"pointSize": 5,
"scaleDistribution": { "type": "linear" },
"showPoints": "never",
"spanNulls": false,
"stacking": { "group": "A", "mode": "normal" },
"thresholdsStyle": { "mode": "off" }
},
"unit": "ops",
"min": 0
},
"overrides": []
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
"expr": "sum by (search_type) (rate(onyx_opensearch_search_total[5m]))",
"legendFormat": "{{ search_type }}",
"refId": "A"
}
]
},
{
"title": "Concurrent Searches In Progress",
"description": "Number of OpenSearch searches currently in flight, broken down by search type. Summed across all instances.",
"type": "timeseries",
"gridPos": { "h": 10, "w": 12, "x": 0, "y": 20 },
"id": 5,
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisLabel": "searches",
"axisPlacement": "auto",
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"lineInterpolation": "smooth",
"lineWidth": 2,
"pointSize": 5,
"scaleDistribution": { "type": "linear" },
"showPoints": "never",
"spanNulls": false,
"stacking": { "group": "A", "mode": "normal" },
"thresholdsStyle": { "mode": "off" }
},
"min": 0
},
"overrides": []
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
"expr": "sum by (search_type) (onyx_opensearch_searches_in_progress)",
"legendFormat": "{{ search_type }}",
"refId": "A"
}
]
},
{
"title": "Client vs Server Latency Overhead (P50)",
"description": "Difference between client-side and server-side P50 latency. Reveals network, serialization, and untracked OpenSearch overhead.",
"type": "timeseries",
"gridPos": { "h": 10, "w": 12, "x": 12, "y": 20 },
"id": 6,
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
"fieldConfig": {
"defaults": {
"color": { "mode": "palette-classic" },
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisLabel": "seconds",
"axisPlacement": "auto",
"drawStyle": "line",
"fillOpacity": 0,
"gradientMode": "none",
"lineInterpolation": "smooth",
"lineWidth": 2,
"pointSize": 5,
"scaleDistribution": { "type": "linear" },
"showPoints": "never",
"spanNulls": false,
"stacking": { "group": "A", "mode": "none" },
"thresholdsStyle": { "mode": "off" }
},
"unit": "s",
"min": 0
},
"overrides": []
},
"targets": [
{
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
"expr": "histogram_quantile(0.5, sum by (le) (rate(onyx_opensearch_search_client_duration_seconds_bucket[5m]))) - histogram_quantile(0.5, sum by (le) (rate(onyx_opensearch_search_server_duration_seconds_bucket[5m])))",
"legendFormat": "Client - Server overhead (P50)",
"refId": "A"
},
{
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
"expr": "histogram_quantile(0.5, sum by (le) (rate(onyx_opensearch_search_client_duration_seconds_bucket[5m])))",
"legendFormat": "Client P50",
"refId": "B"
},
{
"datasource": { "type": "prometheus", "uid": "${DS_PROMETHEUS}" },
"expr": "histogram_quantile(0.5, sum by (le) (rate(onyx_opensearch_search_server_duration_seconds_bucket[5m])))",
"legendFormat": "Server P50",
"refId": "C"
}
]
}
],
"refresh": "5s",
"schemaVersion": 37,
"style": "dark",
"tags": ["onyx", "opensearch", "search", "latency"],
"templating": {
"list": [
{
"current": {
"text": "Prometheus",
"value": "prometheus"
},
"includeAll": false,
"name": "DS_PROMETHEUS",
"options": [],
"query": "prometheus",
"refresh": 1,
"type": "datasource"
}
]
},
"time": { "from": "now-60m", "to": "now" },
"timepicker": {
"refresh_intervals": ["5s", "10s", "30s", "1m"]
},
"timezone": "",
"title": "Onyx OpenSearch Search Latency",
"uid": "onyx-opensearch-search-latency",
"version": 0,
"weekStart": ""
}

View File

@@ -1,606 +0,0 @@
{
"id": null,
"annotations": {
"list": [
{
"builtIn": 1,
"datasource": {
"type": "grafana",
"uid": "-- Grafana --"
},
"enable": true,
"hide": true,
"iconColor": "rgba(0, 211, 255, 1)",
"name": "Annotations & Alerts",
"type": "dashboard"
}
]
},
"editable": true,
"fiscalYearStartMonth": 0,
"graphTooltip": 0,
"links": [],
"panels": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "line",
"fillOpacity": 18,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"insertNulls": false,
"lineInterpolation": "linear",
"lineWidth": 2,
"pointSize": 4,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "never",
"spanNulls": false,
"stacking": {
"group": "A",
"mode": "none"
},
"thresholdsStyle": {
"mode": "off"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "orange",
"value": 10
},
{
"color": "red",
"value": 50
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 10,
"w": 24,
"x": 0,
"y": 0
},
"id": 1,
"options": {
"legend": {
"calcs": ["lastNotNull", "max"],
"displayMode": "table",
"placement": "right",
"showLegend": true
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "onyx_celery_queue_depth{queue=~\"$queue\"}",
"legendFormat": "{{queue}}",
"range": true,
"refId": "A"
}
],
"title": "Queue Depth by Queue",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "orange",
"value": 20
},
{
"color": "red",
"value": 100
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 4,
"w": 6,
"x": 0,
"y": 10
},
"id": 2,
"options": {
"colorMode": "background",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": ["lastNotNull"],
"fields": "",
"values": false
},
"textMode": "auto"
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "sum(onyx_celery_queue_depth)",
"refId": "A"
}
],
"title": "Total Queued Tasks",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "orange",
"value": 20
},
{
"color": "red",
"value": 100
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 4,
"w": 6,
"x": 6,
"y": 10
},
"id": 3,
"options": {
"colorMode": "background",
"graphMode": "area",
"justifyMode": "auto",
"orientation": "auto",
"reduceOptions": {
"calcs": ["lastNotNull"],
"fields": "",
"values": false
},
"textMode": "auto"
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "onyx_celery_unacked_tasks",
"refId": "A"
}
],
"title": "Unacked Tasks",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "orange",
"value": 10
},
{
"color": "red",
"value": 50
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 4,
"w": 6,
"x": 12,
"y": 10
},
"id": 4,
"options": {
"colorMode": "background",
"graphMode": "none",
"justifyMode": "center",
"orientation": "auto",
"reduceOptions": {
"calcs": ["lastNotNull"],
"fields": "",
"values": false
},
"textMode": "auto"
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "onyx_celery_queue_depth{queue=\"docprocessing\"}",
"refId": "A"
}
],
"title": "Docprocessing Queue",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "thresholds"
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "orange",
"value": 10
},
{
"color": "red",
"value": 50
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 4,
"w": 6,
"x": 18,
"y": 10
},
"id": 5,
"options": {
"colorMode": "background",
"graphMode": "none",
"justifyMode": "center",
"orientation": "auto",
"reduceOptions": {
"calcs": ["lastNotNull"],
"fields": "",
"values": false
},
"textMode": "auto"
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "onyx_celery_queue_depth{queue=\"connector_doc_fetching\"}",
"refId": "A"
}
],
"title": "Docfetching Queue",
"type": "stat"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"color": {
"mode": "palette-classic"
},
"custom": {
"axisBorderShow": false,
"axisCenteredZero": false,
"axisColorMode": "text",
"axisLabel": "",
"axisPlacement": "auto",
"barAlignment": 0,
"drawStyle": "bars",
"fillOpacity": 80,
"gradientMode": "none",
"hideFrom": {
"legend": false,
"tooltip": false,
"viz": false
},
"lineWidth": 1,
"scaleDistribution": {
"type": "linear"
},
"showPoints": "never",
"stacking": {
"group": "A",
"mode": "none"
}
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "orange",
"value": 10
},
{
"color": "red",
"value": 50
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 10,
"w": 12,
"x": 0,
"y": 14
},
"id": 6,
"options": {
"legend": {
"calcs": ["lastNotNull"],
"displayMode": "list",
"placement": "bottom",
"showLegend": false
},
"tooltip": {
"mode": "single",
"sort": "none"
}
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "topk(10, onyx_celery_queue_depth)",
"legendFormat": "{{queue}}",
"range": true,
"refId": "A"
}
],
"title": "Top 10 Queue Backlogs",
"type": "timeseries"
},
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"fieldConfig": {
"defaults": {
"custom": {
"align": "auto",
"cellOptions": {
"type": "auto"
},
"inspect": false
},
"mappings": [],
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "orange",
"value": 10
},
{
"color": "red",
"value": 50
}
]
}
},
"overrides": []
},
"gridPos": {
"h": 10,
"w": 12,
"x": 12,
"y": 14
},
"id": 7,
"options": {
"cellHeight": "sm",
"footer": {
"countRows": false,
"fields": "",
"reducer": ["sum"],
"show": false
},
"showHeader": true,
"sortBy": [
{
"desc": true,
"displayName": "Value"
}
]
},
"targets": [
{
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"expr": "sort_desc(onyx_celery_queue_depth)",
"format": "table",
"instant": true,
"refId": "A"
}
],
"title": "Current Queue Depth",
"transformations": [
{
"id": "labelsToFields",
"options": {
"mode": "columns"
}
}
],
"type": "table"
}
],
"refresh": "30s",
"schemaVersion": 39,
"style": "dark",
"tags": ["onyx", "redis", "celery"],
"templating": {
"list": [
{
"current": {
"selected": true,
"text": "Prometheus",
"value": "Prometheus"
},
"hide": 0,
"includeAll": false,
"label": "Datasource",
"name": "DS_PROMETHEUS",
"options": [],
"query": "prometheus",
"refresh": 1,
"regex": "",
"type": "datasource"
},
{
"allValue": ".*",
"current": {
"selected": true,
"text": "All",
"value": ".*"
},
"datasource": {
"type": "prometheus",
"uid": "${DS_PROMETHEUS}"
},
"definition": "label_values(onyx_celery_queue_depth, queue)",
"hide": 0,
"includeAll": true,
"label": "Queue",
"multi": true,
"name": "queue",
"options": [],
"query": {
"query": "label_values(onyx_celery_queue_depth, queue)",
"refId": "StandardVariableQuery"
},
"refresh": 2,
"regex": "",
"sort": 1,
"type": "query"
}
]
},
"time": {
"from": "now-6h",
"to": "now"
},
"timepicker": {},
"timezone": "",
"title": "Onyx Redis Queues",
"uid": "onyx-redis-queues",
"version": 1,
"weekStart": ""
}

View File

@@ -1,23 +0,0 @@
{{- if .Values.monitoring.serviceMonitors.enabled }}
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: {{ include "onyx.fullname" . }}-api
labels:
{{- include "onyx.labels" . | nindent 4 }}
{{- with .Values.monitoring.serviceMonitors.labels }}
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
namespaceSelector:
matchNames:
- {{ .Release.Namespace }}
selector:
matchLabels:
app: {{ .Values.api.deploymentLabels.app }}
endpoints:
- port: api-server-port
path: /metrics
interval: 30s
scrapeTimeout: 10s
{{- end }}

View File

@@ -70,10 +70,6 @@ spec:
"-Q",
"connector_pruning,connector_doc_permissions_sync,connector_external_group_sync,csv_generation,sandbox",
]
ports:
- name: metrics
containerPort: 9094
protocol: TCP
resources:
{{- toYaml .Values.celery_worker_heavy.resources | nindent 12 }}
envFrom:

View File

@@ -74,29 +74,4 @@ spec:
interval: 30s
scrapeTimeout: 10s
{{- end }}
{{- if gt (int .Values.celery_worker_heavy.replicaCount) 0 }}
---
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: {{ include "onyx.fullname" . }}-celery-worker-heavy
labels:
{{- include "onyx.labels" . | nindent 4 }}
{{- with .Values.monitoring.serviceMonitors.labels }}
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
namespaceSelector:
matchNames:
- {{ .Release.Namespace }}
selector:
matchLabels:
app: {{ .Values.celery_worker_heavy.deploymentLabels.app }}
metrics: "true"
endpoints:
- port: metrics
path: /metrics
interval: 30s
scrapeTimeout: 10s
{{- end }}
{{- end }}

View File

@@ -12,30 +12,4 @@ metadata:
data:
onyx-indexing-pipeline.json: |
{{- .Files.Get "dashboards/indexing-pipeline.json" | nindent 4 }}
---
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "onyx.fullname" . }}-opensearch-search-latency-dashboard
labels:
{{- include "onyx.labels" . | nindent 4 }}
grafana_dashboard: "1"
annotations:
grafana_folder: "Onyx"
data:
onyx-opensearch-search-latency.json: |
{{- .Files.Get "dashboards/opensearch-search-latency.json" | nindent 4 }}
---
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "onyx.fullname" . }}-redis-queues-dashboard
labels:
{{- include "onyx.labels" . | nindent 4 }}
grafana_dashboard: "1"
annotations:
grafana_folder: "Onyx"
data:
onyx-redis-queues.json: |
{{- .Files.Get "dashboards/redis-queues.json" | nindent 4 }}
{{- end }}

View File

@@ -1,30 +0,0 @@
{{- if and .Values.ingress.enabled .Values.mcpServer.enabled -}}
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "onyx.fullname" . }}-ingress-mcp-oauth-callback
annotations:
{{- if not .Values.ingress.className }}
kubernetes.io/ingress.class: nginx
{{- end }}
cert-manager.io/cluster-issuer: {{ include "onyx.fullname" . }}-letsencrypt
spec:
{{- if .Values.ingress.className }}
ingressClassName: {{ .Values.ingress.className }}
{{- end }}
rules:
- host: {{ .Values.ingress.api.host }}
http:
paths:
- path: /mcp/oauth/callback
pathType: Exact
backend:
service:
name: {{ include "onyx.fullname" . }}-webserver
port:
number: {{ .Values.webserver.service.servicePort }}
tls:
- hosts:
- {{ .Values.ingress.api.host }}
secretName: {{ include "onyx.fullname" . }}-ingress-mcp-oauth-callback-tls
{{- end }}

View File

@@ -42,22 +42,6 @@ data:
client_max_body_size 5G;
{{- if .Values.mcpServer.enabled }}
# OAuth callback page must be served by the web server (Next.js),
# not the MCP server. Exact match takes priority over the regex below.
location = /mcp/oauth/callback {
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header Host $host;
proxy_http_version 1.1;
proxy_redirect off;
proxy_connect_timeout {{ .Values.nginx.timeouts.connect }}s;
proxy_send_timeout {{ .Values.nginx.timeouts.send }}s;
proxy_read_timeout {{ .Values.nginx.timeouts.read }}s;
proxy_pass http://web_server;
}
# MCP Server - Model Context Protocol for LLM integrations
# Match /mcp, /mcp/, or /mcp/* but NOT /mcpserver, /mcpapi, etc.
location ~ ^/mcp(/.*)?$ {

View File

@@ -264,7 +264,7 @@ monitoring:
# The sidecar must be configured with label selector: grafana_dashboard=1
enabled: false
serviceMonitors:
# -- Set to true to deploy ServiceMonitor resources for API server and Celery worker metrics endpoints.
# -- Set to true to deploy ServiceMonitor resources for Celery worker metrics endpoints.
# Requires the Prometheus Operator CRDs (included in kube-prometheus-stack).
# Use `labels` to match your Prometheus CR's serviceMonitorSelector (e.g. release: onyx-monitoring).
enabled: false
@@ -296,7 +296,7 @@ nginx:
# The ingress-nginx subchart doesn't auto-detect our custom ConfigMap changes.
# Workaround: Helm upgrade will restart if the following annotation value changes.
podAnnotations:
onyx.app/nginx-config-version: "4"
onyx.app/nginx-config-version: "3"
# Propagate DOMAIN into nginx so server_name continues to use the same env var
extraEnvs:

View File

@@ -22,10 +22,6 @@ variable "CLI_REPOSITORY" {
default = "onyxdotapp/onyx-cli"
}
variable "DEVCONTAINER_REPOSITORY" {
default = "onyxdotapp/onyx-devcontainer"
}
variable "TAG" {
default = "latest"
}
@@ -94,16 +90,3 @@ target "cli" {
tags = ["${CLI_REPOSITORY}:${TAG}"]
}
target "devcontainer" {
context = ".devcontainer"
dockerfile = "Dockerfile"
cache-from = [
"type=registry,ref=${DEVCONTAINER_REPOSITORY}:latest",
"type=registry,ref=${DEVCONTAINER_REPOSITORY}:edge",
]
cache-to = ["type=inline"]
tags = ["${DEVCONTAINER_REPOSITORY}:${TAG}"]
}

View File

@@ -28,7 +28,7 @@ dependencies = [
"kubernetes>=31.0.0",
]
[dependency-groups]
[project.optional-dependencies]
# Main backend application dependencies
backend = [
"aiohttp==3.13.4",
@@ -148,7 +148,7 @@ dev = [
"matplotlib==3.10.8",
"mypy-extensions==1.0.0",
"mypy==1.13.0",
"onyx-devtools==0.7.5",
"onyx-devtools==0.7.3",
"openapi-generator-cli==7.17.0",
"pandas-stubs~=2.3.3",
"pre-commit==3.2.2",
@@ -195,9 +195,6 @@ model_server = [
"sentry-sdk[fastapi,celery,starlette]==2.14.0",
]
[tool.uv]
default-groups = ["backend", "dev", "ee", "model_server"]
[tool.mypy]
plugins = "sqlalchemy.ext.mypy.plugin"
mypy_path = "backend"
@@ -233,7 +230,7 @@ follow_imports = "skip"
ignore_errors = true
[tool.uv.workspace]
members = ["tools/ods"]
members = ["backend", "tools/ods"]
[tool.basedpyright]
include = ["backend"]

View File

@@ -244,54 +244,6 @@ ods web lint
ods web test --watch
```
### `dev` - Devcontainer Management
Manage the Onyx devcontainer. Also available as `ods dc`.
Requires the [devcontainer CLI](https://github.com/devcontainers/cli) (`npm install -g @devcontainers/cli`).
```shell
ods dev <subcommand>
```
**Subcommands:**
- `up` - Start the devcontainer (pulls the image if needed)
- `into` - Open a zsh shell inside the running devcontainer
- `exec` - Run an arbitrary command inside the devcontainer
- `restart` - Remove and recreate the devcontainer
- `rebuild` - Pull the latest published image and recreate
- `stop` - Stop the running devcontainer
The devcontainer image is published to `onyxdotapp/onyx-devcontainer` and
referenced by tag in `.devcontainer/devcontainer.json` — no local build needed.
**Examples:**
```shell
# Start the devcontainer
ods dev up
# Open a shell
ods dev into
# Run a command
ods dev exec -- npm test
# Restart the container
ods dev restart
# Pull latest image and recreate
ods dev rebuild
# Stop the container
ods dev stop
# Same commands work with the dc alias
ods dc up
ods dc into
```
### `db` - Database Administration
Manage PostgreSQL database dumps, restores, and migrations.

View File

@@ -1,34 +0,0 @@
package cmd
import (
"github.com/spf13/cobra"
)
// NewDevCommand creates the parent dev command for devcontainer operations.
func NewDevCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "dev",
Aliases: []string{"dc"},
Short: "Manage the devcontainer",
Long: `Manage the Onyx devcontainer.
Wraps the devcontainer CLI with workspace-aware defaults.
Commands:
up Start the devcontainer
into Open a shell inside the running devcontainer
exec Run a command inside the devcontainer
restart Remove and recreate the devcontainer
rebuild Pull the latest image and recreate
stop Stop the running devcontainer`,
}
cmd.AddCommand(newDevUpCommand())
cmd.AddCommand(newDevIntoCommand())
cmd.AddCommand(newDevExecCommand())
cmd.AddCommand(newDevRestartCommand())
cmd.AddCommand(newDevRebuildCommand())
cmd.AddCommand(newDevStopCommand())
return cmd
}

View File

@@ -1,29 +0,0 @@
package cmd
import (
"github.com/spf13/cobra"
)
func newDevExecCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "exec [--] <command> [args...]",
Short: "Run a command inside the devcontainer",
Long: `Run an arbitrary command inside the running devcontainer.
All arguments are treated as positional (flags like -it are passed through).
Examples:
ods dev exec npm test
ods dev exec -- ls -la
ods dev exec -it echo hello`,
Args: cobra.MinimumNArgs(1),
DisableFlagParsing: true,
Run: func(cmd *cobra.Command, args []string) {
if len(args) > 0 && args[0] == "--" {
args = args[1:]
}
runDevExec(args)
},
}
return cmd
}

View File

@@ -1,53 +0,0 @@
package cmd
import (
"os"
"os/exec"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"github.com/onyx-dot-app/onyx/tools/ods/internal/paths"
)
func newDevIntoCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "into",
Short: "Open a shell inside the running devcontainer",
Long: `Open an interactive zsh shell inside the running devcontainer.
Examples:
ods dev into`,
Run: func(cmd *cobra.Command, args []string) {
runDevExec([]string{"zsh"})
},
}
return cmd
}
// runDevExec executes "devcontainer exec --workspace-folder <root> <command...>".
func runDevExec(command []string) {
checkDevcontainerCLI()
ensureDockerSock()
ensureRemoteUser()
root, err := paths.GitRoot()
if err != nil {
log.Fatalf("Failed to find git root: %v", err)
}
args := []string{"exec", "--workspace-folder", root}
args = append(args, command...)
log.Debugf("Running: devcontainer %v", args)
c := exec.Command("devcontainer", args...)
c.Stdout = os.Stdout
c.Stderr = os.Stderr
c.Stdin = os.Stdin
if err := c.Run(); err != nil {
log.Fatalf("devcontainer exec failed: %v", err)
}
}

View File

@@ -1,41 +0,0 @@
package cmd
import (
"os"
"os/exec"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
)
func newDevRebuildCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "rebuild",
Short: "Pull the latest devcontainer image and recreate",
Long: `Pull the latest devcontainer image and recreate the container.
Use after the published image has been updated or after changing devcontainer.json.
Examples:
ods dev rebuild`,
Run: func(cmd *cobra.Command, args []string) {
runDevRebuild()
},
}
return cmd
}
func runDevRebuild() {
image := devcontainerImage()
log.Infof("Pulling %s...", image)
pull := exec.Command("docker", "pull", image)
pull.Stdout = os.Stdout
pull.Stderr = os.Stderr
if err := pull.Run(); err != nil {
log.Warnf("Failed to pull image (continuing with local copy): %v", err)
}
runDevcontainer("up", []string{"--remove-existing-container"})
}

View File

@@ -1,23 +0,0 @@
package cmd
import (
"github.com/spf13/cobra"
)
func newDevRestartCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "restart",
Short: "Remove and recreate the devcontainer",
Long: `Remove the existing devcontainer and recreate it.
Uses the cached image — for a full image rebuild, use "ods dev rebuild".
Examples:
ods dev restart`,
Run: func(cmd *cobra.Command, args []string) {
runDevcontainer("up", []string{"--remove-existing-container"})
},
}
return cmd
}

View File

@@ -1,56 +0,0 @@
package cmd
import (
"os/exec"
"strings"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"github.com/onyx-dot-app/onyx/tools/ods/internal/paths"
)
func newDevStopCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "stop",
Short: "Stop the running devcontainer",
Long: `Stop the running devcontainer.
Examples:
ods dev stop`,
Run: func(cmd *cobra.Command, args []string) {
runDevStop()
},
}
return cmd
}
func runDevStop() {
root, err := paths.GitRoot()
if err != nil {
log.Fatalf("Failed to find git root: %v", err)
}
// Find the container by the devcontainer label
out, err := exec.Command(
"docker", "ps", "-q",
"--filter", "label=devcontainer.local_folder="+root,
).Output()
if err != nil {
log.Fatalf("Failed to find devcontainer: %v", err)
}
containerID := strings.TrimSpace(string(out))
if containerID == "" {
log.Info("No running devcontainer found")
return
}
log.Infof("Stopping devcontainer %s...", containerID)
c := exec.Command("docker", "stop", containerID)
if err := c.Run(); err != nil {
log.Fatalf("Failed to stop devcontainer: %v", err)
}
log.Info("Devcontainer stopped")
}

View File

@@ -1,223 +0,0 @@
package cmd
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"github.com/onyx-dot-app/onyx/tools/ods/internal/paths"
)
func newDevUpCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "up",
Short: "Start the devcontainer",
Long: `Start the devcontainer, pulling the image if needed.
Examples:
ods dev up`,
Run: func(cmd *cobra.Command, args []string) {
runDevcontainer("up", nil)
},
}
return cmd
}
// devcontainerImage reads the image field from .devcontainer/devcontainer.json.
func devcontainerImage() string {
root, err := paths.GitRoot()
if err != nil {
log.Fatalf("Failed to find git root: %v", err)
}
data, err := os.ReadFile(filepath.Join(root, ".devcontainer", "devcontainer.json"))
if err != nil {
log.Fatalf("Failed to read devcontainer.json: %v", err)
}
var cfg struct {
Image string `json:"image"`
}
if err := json.Unmarshal(data, &cfg); err != nil {
log.Fatalf("Failed to parse devcontainer.json: %v", err)
}
if cfg.Image == "" {
log.Fatal("No image field in devcontainer.json")
}
return cfg.Image
}
// checkDevcontainerCLI ensures the devcontainer CLI is installed.
func checkDevcontainerCLI() {
if _, err := exec.LookPath("devcontainer"); err != nil {
log.Fatal("devcontainer CLI is not installed. Install it with: npm install -g @devcontainers/cli")
}
}
// ensureDockerSock sets the DOCKER_SOCK environment variable if not already set.
// Used by ensureRemoteUser to detect rootless Docker.
func ensureDockerSock() {
if os.Getenv("DOCKER_SOCK") != "" {
return
}
sock := detectDockerSock()
if err := os.Setenv("DOCKER_SOCK", sock); err != nil {
log.Fatalf("Failed to set DOCKER_SOCK: %v", err)
}
}
// detectDockerSock returns the path to the Docker socket on the host.
func detectDockerSock() string {
// Prefer explicit DOCKER_HOST (strip unix:// prefix if present).
if dh := os.Getenv("DOCKER_HOST"); dh != "" {
const prefix = "unix://"
if len(dh) > len(prefix) && dh[:len(prefix)] == prefix {
return dh[len(prefix):]
}
// Only bare paths (starting with /) are valid socket paths.
// Non-unix schemes (e.g. tcp://) can't be bind-mounted.
if len(dh) > 0 && dh[0] == '/' {
return dh
}
log.Warnf("DOCKER_HOST=%q is not a unix socket path; falling back to local socket detection", dh)
}
// Linux rootless Docker: $XDG_RUNTIME_DIR/docker.sock
if runtime.GOOS == "linux" {
if xdg := os.Getenv("XDG_RUNTIME_DIR"); xdg != "" {
sock := filepath.Join(xdg, "docker.sock")
if _, err := os.Stat(sock); err == nil {
return sock
}
}
}
// macOS Docker Desktop: ~/.docker/run/docker.sock
if runtime.GOOS == "darwin" {
if home, err := os.UserHomeDir(); err == nil {
sock := filepath.Join(home, ".docker", "run", "docker.sock")
if _, err := os.Stat(sock); err == nil {
return sock
}
}
}
// Fallback: standard socket path (Linux with standard Docker, macOS symlink)
return "/var/run/docker.sock"
}
// worktreeGitMount returns a --mount flag value that makes a git worktree's
// .git reference resolve inside the container. In a worktree, .git is a file
// containing "gitdir: /path/to/main/.git/worktrees/<name>", so we need the
// main repo's .git directory to exist at the same absolute host path inside
// the container.
//
// Returns ("", false) when the workspace is not a worktree.
func worktreeGitMount(root string) (string, bool) {
dotgit := filepath.Join(root, ".git")
info, err := os.Lstat(dotgit)
if err != nil || info.IsDir() {
return "", false // regular repo or no .git
}
// .git is a file — parse the gitdir path.
out, err := exec.Command("git", "-C", root, "rev-parse", "--git-common-dir").Output()
if err != nil {
log.Warnf("Failed to detect git common dir: %v", err)
return "", false
}
commonDir := strings.TrimSpace(string(out))
// Resolve to absolute path.
if !filepath.IsAbs(commonDir) {
commonDir = filepath.Join(root, commonDir)
}
commonDir, _ = filepath.EvalSymlinks(commonDir)
mount := fmt.Sprintf("type=bind,source=%s,target=%s", commonDir, commonDir)
log.Debugf("Worktree detected — mounting main .git: %s", commonDir)
return mount, true
}
// sshAgentMount returns a --mount flag value that forwards the host's SSH agent
// socket into the container. Returns ("", false) when SSH_AUTH_SOCK is unset or
// the socket is not accessible.
func sshAgentMount() (string, bool) {
sock := os.Getenv("SSH_AUTH_SOCK")
if sock == "" {
log.Debug("SSH_AUTH_SOCK not set — skipping SSH agent forwarding")
return "", false
}
if _, err := os.Stat(sock); err != nil {
log.Debugf("SSH_AUTH_SOCK=%s not accessible: %v", sock, err)
return "", false
}
mount := fmt.Sprintf("type=bind,source=%s,target=/tmp/ssh-agent.sock", sock)
log.Debugf("Forwarding SSH agent: %s", sock)
return mount, true
}
// ensureRemoteUser sets DEVCONTAINER_REMOTE_USER when rootless Docker is
// detected. Container root maps to the host user in rootless mode, so running
// as root inside the container avoids the UID mismatch on new files.
// Must be called after ensureDockerSock.
func ensureRemoteUser() {
if os.Getenv("DEVCONTAINER_REMOTE_USER") != "" {
return
}
if runtime.GOOS == "linux" {
sock := os.Getenv("DOCKER_SOCK")
xdg := os.Getenv("XDG_RUNTIME_DIR")
// Heuristic: rootless Docker on Linux typically places its socket
// under $XDG_RUNTIME_DIR. If DOCKER_SOCK was set to a custom path
// outside XDG_RUNTIME_DIR, set DEVCONTAINER_REMOTE_USER=root manually.
if xdg != "" && strings.HasPrefix(sock, xdg) {
log.Debug("Rootless Docker detected — setting DEVCONTAINER_REMOTE_USER=root")
if err := os.Setenv("DEVCONTAINER_REMOTE_USER", "root"); err != nil {
log.Warnf("Failed to set DEVCONTAINER_REMOTE_USER: %v", err)
}
}
}
}
// runDevcontainer executes "devcontainer <action> --workspace-folder <root> [extraArgs...]".
func runDevcontainer(action string, extraArgs []string) {
checkDevcontainerCLI()
ensureDockerSock()
ensureRemoteUser()
root, err := paths.GitRoot()
if err != nil {
log.Fatalf("Failed to find git root: %v", err)
}
args := []string{action, "--workspace-folder", root}
if mount, ok := worktreeGitMount(root); ok {
args = append(args, "--mount", mount)
}
if mount, ok := sshAgentMount(); ok {
args = append(args, "--mount", mount)
}
args = append(args, extraArgs...)
log.Debugf("Running: devcontainer %v", args)
c := exec.Command("devcontainer", args...)
c.Stdout = os.Stdout
c.Stderr = os.Stderr
c.Stdin = os.Stdin
if err := c.Run(); err != nil {
log.Fatalf("devcontainer %s failed: %v", action, err)
}
}

View File

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

324
uv.lock generated
View File

@@ -14,6 +14,12 @@ resolution-markers = [
"python_full_version < '3.12' and sys_platform != 'win32'",
]
[manifest]
members = [
"onyx",
"onyx-backend",
]
[[package]]
name = "accelerate"
version = "1.6.0"
@@ -4228,7 +4234,7 @@ dependencies = [
{ name = "voyageai" },
]
[package.dev-dependencies]
[package.optional-dependencies]
backend = [
{ name = "aiohttp" },
{ name = "alembic" },
@@ -4382,191 +4388,195 @@ model-server = [
[package.metadata]
requires-dist = [
{ name = "accelerate", marker = "extra == 'model-server'", specifier = "==1.6.0" },
{ name = "agent-client-protocol", specifier = ">=0.7.1" },
{ name = "aioboto3", specifier = "==15.1.0" },
{ name = "aiohttp", marker = "extra == 'backend'", specifier = "==3.13.4" },
{ name = "alembic", marker = "extra == 'backend'", specifier = "==1.10.4" },
{ name = "asana", marker = "extra == 'backend'", specifier = "==5.0.8" },
{ name = "asyncpg", marker = "extra == 'backend'", specifier = "==0.30.0" },
{ name = "atlassian-python-api", marker = "extra == 'backend'", specifier = "==3.41.16" },
{ name = "azure-cognitiveservices-speech", marker = "extra == 'backend'", specifier = "==1.38.0" },
{ name = "beautifulsoup4", marker = "extra == 'backend'", specifier = "==4.12.3" },
{ name = "black", marker = "extra == 'dev'", specifier = "==25.1.0" },
{ name = "boto3", marker = "extra == 'backend'", specifier = "==1.39.11" },
{ name = "boto3-stubs", extras = ["s3"], marker = "extra == 'backend'", specifier = "==1.39.11" },
{ name = "braintrust", marker = "extra == 'backend'", specifier = "==0.3.9" },
{ name = "brotli", specifier = ">=1.2.0" },
{ name = "celery", marker = "extra == 'backend'", specifier = "==5.5.1" },
{ name = "celery-types", marker = "extra == 'dev'", specifier = "==0.19.0" },
{ name = "chardet", marker = "extra == 'backend'", specifier = "==5.2.0" },
{ name = "chonkie", marker = "extra == 'backend'", specifier = "==1.0.10" },
{ name = "claude-agent-sdk", specifier = ">=0.1.19" },
{ name = "cohere", specifier = "==5.6.1" },
{ name = "dask", marker = "extra == 'backend'", specifier = "==2026.1.1" },
{ name = "ddtrace", marker = "extra == 'backend'", specifier = "==3.10.0" },
{ name = "discord-py", specifier = "==2.4.0" },
{ name = "discord-py", marker = "extra == 'backend'", specifier = "==2.4.0" },
{ name = "distributed", marker = "extra == 'backend'", specifier = "==2026.1.1" },
{ name = "dropbox", marker = "extra == 'backend'", specifier = "==12.0.2" },
{ name = "einops", marker = "extra == 'model-server'", specifier = "==0.8.1" },
{ name = "exa-py", marker = "extra == 'backend'", specifier = "==1.15.4" },
{ name = "faker", marker = "extra == 'dev'", specifier = "==40.1.2" },
{ name = "fastapi", specifier = "==0.133.1" },
{ 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 = "==3.2.0" },
{ 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" },
{ name = "google-auth-oauthlib", marker = "extra == 'backend'", specifier = "==1.0.0" },
{ name = "google-genai", specifier = "==1.52.0" },
{ name = "hatchling", marker = "extra == 'dev'", specifier = "==1.28.0" },
{ name = "httpcore", marker = "extra == 'backend'", specifier = "==1.0.9" },
{ name = "httpx", extras = ["http2"], marker = "extra == 'backend'", specifier = "==0.28.1" },
{ name = "httpx-oauth", marker = "extra == 'backend'", specifier = "==0.15.1" },
{ name = "hubspot-api-client", marker = "extra == 'backend'", specifier = "==11.1.0" },
{ name = "huggingface-hub", marker = "extra == 'backend'", specifier = "==0.35.3" },
{ name = "inflection", marker = "extra == 'backend'", specifier = "==0.5.1" },
{ name = "ipykernel", marker = "extra == 'dev'", specifier = "==6.29.5" },
{ name = "jira", marker = "extra == 'backend'", specifier = "==3.10.5" },
{ name = "jsonref", marker = "extra == 'backend'", specifier = "==1.1.0" },
{ name = "kubernetes", specifier = ">=31.0.0" },
{ name = "kubernetes", marker = "extra == 'backend'", specifier = "==31.0.0" },
{ name = "langchain-core", marker = "extra == 'backend'", specifier = "==1.2.22" },
{ name = "langfuse", marker = "extra == 'backend'", specifier = "==3.10.0" },
{ name = "lazy-imports", marker = "extra == 'backend'", specifier = "==1.0.1" },
{ name = "litellm", specifier = "==1.81.6" },
{ name = "lxml", marker = "extra == 'backend'", specifier = "==5.3.0" },
{ name = "mako", marker = "extra == 'backend'", specifier = "==1.2.4" },
{ 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.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" },
{ name = "mypy", marker = "extra == 'dev'", specifier = "==1.13.0" },
{ name = "mypy-extensions", marker = "extra == 'dev'", specifier = "==1.0.0" },
{ 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.6.2" },
{ name = "onyx-devtools", marker = "extra == 'dev'", specifier = "==0.7.3" },
{ name = "openai", specifier = "==2.14.0" },
{ name = "openapi-generator-cli", marker = "extra == 'dev'", specifier = "==7.17.0" },
{ name = "openinference-instrumentation", marker = "extra == 'backend'", specifier = "==0.1.42" },
{ name = "openpyxl", marker = "extra == 'backend'", specifier = "==3.0.10" },
{ name = "opensearch-py", marker = "extra == 'backend'", specifier = "==3.0.0" },
{ name = "opentelemetry-proto", marker = "extra == 'backend'", specifier = ">=1.39.0" },
{ name = "pandas-stubs", marker = "extra == 'dev'", specifier = "~=2.3.3" },
{ name = "passlib", marker = "extra == 'backend'", specifier = "==1.7.4" },
{ name = "playwright", marker = "extra == 'backend'", specifier = "==1.55.0" },
{ name = "posthog", marker = "extra == 'ee'", specifier = "==3.7.4" },
{ name = "pre-commit", marker = "extra == 'dev'", specifier = "==3.2.2" },
{ name = "prometheus-client", specifier = ">=0.21.1" },
{ name = "prometheus-fastapi-instrumentator", specifier = "==7.1.0" },
{ name = "psutil", marker = "extra == 'backend'", specifier = "==7.1.3" },
{ name = "psycopg2-binary", marker = "extra == 'backend'", specifier = "==2.9.9" },
{ name = "puremagic", marker = "extra == 'backend'", specifier = "==1.28" },
{ name = "pyairtable", marker = "extra == 'backend'", specifier = "==3.0.1" },
{ name = "pycryptodome", marker = "extra == 'backend'", specifier = "==3.19.1" },
{ name = "pydantic", specifier = "==2.11.7" },
{ name = "pygithub", marker = "extra == 'backend'", specifier = "==2.5.0" },
{ name = "pympler", marker = "extra == 'backend'", specifier = "==1.1" },
{ name = "pypandoc-binary", marker = "extra == 'backend'", specifier = "==1.16.2" },
{ name = "pypdf", marker = "extra == 'backend'", specifier = "==6.9.2" },
{ name = "pytest", marker = "extra == 'dev'", specifier = "==8.3.5" },
{ name = "pytest-alembic", marker = "extra == 'dev'", specifier = "==0.12.1" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = "==1.3.0" },
{ name = "pytest-dotenv", marker = "extra == 'dev'", specifier = "==0.5.2" },
{ name = "pytest-mock", marker = "extra == 'backend'", specifier = "==3.12.0" },
{ name = "pytest-playwright", marker = "extra == 'backend'", specifier = "==0.7.0" },
{ name = "pytest-repeat", marker = "extra == 'dev'", specifier = "==0.9.4" },
{ name = "pytest-xdist", marker = "extra == 'dev'", specifier = "==3.8.0" },
{ name = "python-dateutil", marker = "extra == 'backend'", specifier = "==2.8.2" },
{ name = "python-docx", marker = "extra == 'backend'", specifier = "==1.1.2" },
{ name = "python-dotenv", marker = "extra == 'backend'", specifier = "==1.1.1" },
{ name = "python-gitlab", marker = "extra == 'backend'", specifier = "==5.6.0" },
{ name = "python-multipart", marker = "extra == 'backend'", specifier = "==0.0.22" },
{ name = "python-pptx", marker = "extra == 'backend'", specifier = "==0.6.23" },
{ name = "python3-saml", marker = "extra == 'backend'", specifier = "==1.15.0" },
{ name = "pywikibot", marker = "extra == 'backend'", specifier = "==9.0.0" },
{ name = "rapidfuzz", marker = "extra == 'backend'", specifier = "==3.13.0" },
{ name = "redis", marker = "extra == 'backend'", specifier = "==5.0.8" },
{ name = "release-tag", marker = "extra == 'dev'", specifier = "==0.5.2" },
{ name = "reorder-python-imports-black", marker = "extra == 'dev'", specifier = "==3.14.0" },
{ name = "requests", marker = "extra == 'backend'", specifier = "==2.33.0" },
{ name = "requests-oauthlib", marker = "extra == 'backend'", specifier = "==1.3.1" },
{ name = "retry", specifier = "==0.9.2" },
{ name = "rfc3986", marker = "extra == 'backend'", specifier = "==1.5.0" },
{ name = "ruff", marker = "extra == 'dev'", specifier = "==0.12.0" },
{ name = "safetensors", marker = "extra == 'model-server'", specifier = "==0.5.3" },
{ name = "sendgrid", marker = "extra == 'backend'", specifier = "==6.12.5" },
{ name = "sentence-transformers", marker = "extra == 'model-server'", specifier = "==4.0.2" },
{ name = "sentry-sdk", specifier = "==2.14.0" },
{ name = "sentry-sdk", extras = ["fastapi", "celery", "starlette"], marker = "extra == 'model-server'", specifier = "==2.14.0" },
{ name = "shapely", marker = "extra == 'backend'", specifier = "==2.0.6" },
{ name = "simple-salesforce", marker = "extra == 'backend'", specifier = "==1.12.6" },
{ name = "slack-sdk", marker = "extra == 'backend'", specifier = "==3.20.2" },
{ name = "sqlalchemy", extras = ["mypy"], marker = "extra == 'backend'", specifier = "==2.0.15" },
{ name = "starlette", marker = "extra == 'backend'", specifier = "==0.49.3" },
{ name = "stripe", marker = "extra == 'backend'", specifier = "==10.12.0" },
{ name = "supervisor", marker = "extra == 'backend'", specifier = "==4.3.0" },
{ name = "tiktoken", marker = "extra == 'backend'", specifier = "==0.7.0" },
{ name = "timeago", marker = "extra == 'backend'", specifier = "==1.0.16" },
{ name = "torch", marker = "extra == 'model-server'", specifier = "==2.9.1" },
{ name = "trafilatura", marker = "extra == 'backend'", specifier = "==1.12.2" },
{ name = "transformers", marker = "extra == 'model-server'", specifier = "==4.53.0" },
{ name = "types-beautifulsoup4", marker = "extra == 'dev'", specifier = "==4.12.0.3" },
{ name = "types-html5lib", marker = "extra == 'dev'", specifier = "==1.1.11.13" },
{ name = "types-oauthlib", marker = "extra == 'dev'", specifier = "==3.2.0.9" },
{ name = "types-openpyxl", marker = "extra == 'backend'", specifier = "==3.0.4.7" },
{ name = "types-passlib", marker = "extra == 'dev'", specifier = "==1.7.7.20240106" },
{ name = "types-pillow", marker = "extra == 'dev'", specifier = "==10.2.0.20240822" },
{ name = "types-psutil", marker = "extra == 'dev'", specifier = "==7.1.3.20251125" },
{ name = "types-psycopg2", marker = "extra == 'dev'", specifier = "==2.9.21.10" },
{ name = "types-python-dateutil", marker = "extra == 'dev'", specifier = "==2.8.19.13" },
{ name = "types-pytz", marker = "extra == 'dev'", specifier = "==2023.3.1.1" },
{ name = "types-pyyaml", marker = "extra == 'dev'", specifier = "==6.0.12.11" },
{ name = "types-regex", marker = "extra == 'dev'", specifier = "==2023.3.23.1" },
{ name = "types-requests", marker = "extra == 'dev'", specifier = "==2.32.0.20250328" },
{ name = "types-retry", marker = "extra == 'dev'", specifier = "==0.9.9.3" },
{ name = "types-setuptools", marker = "extra == 'dev'", specifier = "==68.0.0.3" },
{ name = "unstructured", marker = "extra == 'backend'", specifier = "==0.18.27" },
{ name = "unstructured-client", marker = "extra == 'backend'", specifier = "==0.42.6" },
{ name = "urllib3", marker = "extra == 'backend'", specifier = "==2.6.3" },
{ name = "uvicorn", specifier = "==0.35.0" },
{ name = "voyageai", specifier = "==0.2.3" },
{ name = "xmlsec", marker = "extra == 'backend'", specifier = "==1.3.14" },
{ name = "zizmor", marker = "extra == 'dev'", specifier = "==1.18.0" },
{ name = "zulip", marker = "extra == 'backend'", specifier = "==0.8.2" },
]
provides-extras = ["backend", "dev", "ee", "model-server"]
[[package]]
name = "onyx-backend"
version = "0.0.0"
source = { virtual = "backend" }
dependencies = [
{ name = "onyx", extra = ["backend", "dev", "ee"] },
]
[package.metadata.requires-dev]
backend = [
{ name = "aiohttp", specifier = "==3.13.4" },
{ name = "alembic", specifier = "==1.10.4" },
{ name = "asana", specifier = "==5.0.8" },
{ name = "asyncpg", specifier = "==0.30.0" },
{ name = "atlassian-python-api", specifier = "==3.41.16" },
{ name = "azure-cognitiveservices-speech", specifier = "==1.38.0" },
{ name = "beautifulsoup4", specifier = "==4.12.3" },
{ name = "boto3", specifier = "==1.39.11" },
{ name = "boto3-stubs", extras = ["s3"], specifier = "==1.39.11" },
{ name = "braintrust", specifier = "==0.3.9" },
{ name = "celery", specifier = "==5.5.1" },
{ name = "chardet", specifier = "==5.2.0" },
{ name = "chonkie", specifier = "==1.0.10" },
{ name = "dask", specifier = "==2026.1.1" },
{ name = "ddtrace", specifier = "==3.10.0" },
{ name = "discord-py", specifier = "==2.4.0" },
{ name = "distributed", specifier = "==2026.1.1" },
{ name = "dropbox", specifier = "==12.0.2" },
{ name = "exa-py", specifier = "==1.15.4" },
{ name = "fastapi-limiter", specifier = "==0.1.6" },
{ name = "fastapi-users", specifier = "==15.0.4" },
{ name = "fastapi-users-db-sqlalchemy", specifier = "==7.0.0" },
{ name = "fastmcp", specifier = "==3.2.0" },
{ name = "filelock", specifier = "==3.20.3" },
{ name = "google-api-python-client", specifier = "==2.86.0" },
{ name = "google-auth-httplib2", specifier = "==0.1.0" },
{ name = "google-auth-oauthlib", specifier = "==1.0.0" },
{ name = "httpcore", specifier = "==1.0.9" },
{ name = "httpx", extras = ["http2"], specifier = "==0.28.1" },
{ name = "httpx-oauth", specifier = "==0.15.1" },
{ name = "hubspot-api-client", specifier = "==11.1.0" },
{ name = "huggingface-hub", specifier = "==0.35.3" },
{ name = "inflection", specifier = "==0.5.1" },
{ name = "jira", specifier = "==3.10.5" },
{ name = "jsonref", specifier = "==1.1.0" },
{ name = "kubernetes", specifier = "==31.0.0" },
{ name = "langchain-core", specifier = "==1.2.22" },
{ name = "langfuse", specifier = "==3.10.0" },
{ name = "lazy-imports", specifier = "==1.0.1" },
{ name = "lxml", specifier = "==5.3.0" },
{ name = "mako", specifier = "==1.2.4" },
{ name = "markitdown", extras = ["pdf", "docx", "pptx", "xlsx", "xls"], specifier = "==0.1.2" },
{ name = "mcp", extras = ["cli"], specifier = "==1.26.0" },
{ name = "mistune", specifier = "==3.2.0" },
{ name = "msal", specifier = "==1.34.0" },
{ name = "msoffcrypto-tool", specifier = "==5.4.2" },
{ name = "nest-asyncio", specifier = "==1.6.0" },
{ name = "oauthlib", specifier = "==3.2.2" },
{ name = "office365-rest-python-client", specifier = "==2.6.2" },
{ name = "openinference-instrumentation", specifier = "==0.1.42" },
{ name = "openpyxl", specifier = "==3.0.10" },
{ name = "opensearch-py", specifier = "==3.0.0" },
{ name = "opentelemetry-proto", specifier = ">=1.39.0" },
{ name = "passlib", specifier = "==1.7.4" },
{ name = "playwright", specifier = "==1.55.0" },
{ name = "psutil", specifier = "==7.1.3" },
{ name = "psycopg2-binary", specifier = "==2.9.9" },
{ name = "puremagic", specifier = "==1.28" },
{ name = "pyairtable", specifier = "==3.0.1" },
{ name = "pycryptodome", specifier = "==3.19.1" },
{ name = "pygithub", specifier = "==2.5.0" },
{ name = "pympler", specifier = "==1.1" },
{ name = "pypandoc-binary", specifier = "==1.16.2" },
{ name = "pypdf", specifier = "==6.9.2" },
{ name = "pytest-mock", specifier = "==3.12.0" },
{ name = "pytest-playwright", specifier = "==0.7.0" },
{ name = "python-dateutil", specifier = "==2.8.2" },
{ name = "python-docx", specifier = "==1.1.2" },
{ name = "python-dotenv", specifier = "==1.1.1" },
{ name = "python-gitlab", specifier = "==5.6.0" },
{ name = "python-multipart", specifier = "==0.0.22" },
{ name = "python-pptx", specifier = "==0.6.23" },
{ name = "python3-saml", specifier = "==1.15.0" },
{ name = "pywikibot", specifier = "==9.0.0" },
{ name = "rapidfuzz", specifier = "==3.13.0" },
{ name = "redis", specifier = "==5.0.8" },
{ name = "requests", specifier = "==2.33.0" },
{ name = "requests-oauthlib", specifier = "==1.3.1" },
{ name = "rfc3986", specifier = "==1.5.0" },
{ name = "sendgrid", specifier = "==6.12.5" },
{ name = "shapely", specifier = "==2.0.6" },
{ name = "simple-salesforce", specifier = "==1.12.6" },
{ name = "slack-sdk", specifier = "==3.20.2" },
{ name = "sqlalchemy", extras = ["mypy"], specifier = "==2.0.15" },
{ name = "starlette", specifier = "==0.49.3" },
{ name = "stripe", specifier = "==10.12.0" },
{ name = "supervisor", specifier = "==4.3.0" },
{ name = "tiktoken", specifier = "==0.7.0" },
{ name = "timeago", specifier = "==1.0.16" },
{ name = "trafilatura", specifier = "==1.12.2" },
{ name = "types-openpyxl", specifier = "==3.0.4.7" },
{ name = "unstructured", specifier = "==0.18.27" },
{ name = "unstructured-client", specifier = "==0.42.6" },
{ name = "urllib3", specifier = "==2.6.3" },
{ name = "xmlsec", specifier = "==1.3.14" },
{ name = "zulip", specifier = "==0.8.2" },
]
dev = [
{ name = "black", specifier = "==25.1.0" },
{ name = "celery-types", specifier = "==0.19.0" },
{ name = "faker", specifier = "==40.1.2" },
{ name = "hatchling", specifier = "==1.28.0" },
{ name = "ipykernel", specifier = "==6.29.5" },
{ name = "manygo", specifier = "==0.2.0" },
{ name = "matplotlib", specifier = "==3.10.8" },
{ name = "mypy", specifier = "==1.13.0" },
{ name = "mypy-extensions", specifier = "==1.0.0" },
{ name = "onyx-devtools", specifier = "==0.7.5" },
{ name = "openapi-generator-cli", specifier = "==7.17.0" },
{ name = "pandas-stubs", specifier = "~=2.3.3" },
{ name = "pre-commit", specifier = "==3.2.2" },
{ name = "pytest", specifier = "==8.3.5" },
{ name = "pytest-alembic", specifier = "==0.12.1" },
{ name = "pytest-asyncio", specifier = "==1.3.0" },
{ name = "pytest-dotenv", specifier = "==0.5.2" },
{ name = "pytest-repeat", specifier = "==0.9.4" },
{ name = "pytest-xdist", specifier = "==3.8.0" },
{ name = "release-tag", specifier = "==0.5.2" },
{ name = "reorder-python-imports-black", specifier = "==3.14.0" },
{ name = "ruff", specifier = "==0.12.0" },
{ name = "types-beautifulsoup4", specifier = "==4.12.0.3" },
{ name = "types-html5lib", specifier = "==1.1.11.13" },
{ name = "types-oauthlib", specifier = "==3.2.0.9" },
{ name = "types-passlib", specifier = "==1.7.7.20240106" },
{ name = "types-pillow", specifier = "==10.2.0.20240822" },
{ name = "types-psutil", specifier = "==7.1.3.20251125" },
{ name = "types-psycopg2", specifier = "==2.9.21.10" },
{ name = "types-python-dateutil", specifier = "==2.8.19.13" },
{ name = "types-pytz", specifier = "==2023.3.1.1" },
{ name = "types-pyyaml", specifier = "==6.0.12.11" },
{ name = "types-regex", specifier = "==2023.3.23.1" },
{ name = "types-requests", specifier = "==2.32.0.20250328" },
{ name = "types-retry", specifier = "==0.9.9.3" },
{ name = "types-setuptools", specifier = "==68.0.0.3" },
{ name = "zizmor", specifier = "==1.18.0" },
]
ee = [{ name = "posthog", specifier = "==3.7.4" }]
model-server = [
{ name = "accelerate", specifier = "==1.6.0" },
{ name = "einops", specifier = "==0.8.1" },
{ name = "numpy", specifier = "==2.4.1" },
{ name = "safetensors", specifier = "==0.5.3" },
{ name = "sentence-transformers", specifier = "==4.0.2" },
{ name = "sentry-sdk", extras = ["fastapi", "celery", "starlette"], specifier = "==2.14.0" },
{ name = "torch", specifier = "==2.9.1" },
{ name = "transformers", specifier = "==4.53.0" },
]
[package.metadata]
requires-dist = [{ name = "onyx", extras = ["backend", "dev", "ee"], editable = "." }]
[[package]]
name = "onyx-devtools"
version = "0.7.5"
version = "0.7.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "fastapi" },
{ name = "openapi-generator-cli" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/f8/844e34f5126ae40fff0d012bba0b28f031f8871062759bb3789eae4f5e0a/onyx_devtools-0.7.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b3cd434c722ae48a1f651748a9f094711b29d1a9f37fbbadef3144f2cdb0f16d", size = 4238900, upload-time = "2026-04-10T07:02:16.382Z" },
{ url = "https://files.pythonhosted.org/packages/2d/97/d1db725f900b199fa3f7a7a7c9b51ae75d4b18755c924f00f06a7703e552/onyx_devtools-0.7.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c50e3d76d4f8cc4faa6250e758d42f0249067f0e17bc82b99c6c00dd48114393", size = 3913672, upload-time = "2026-04-10T07:02:17.46Z" },
{ url = "https://files.pythonhosted.org/packages/31/83/e11bedb0a1321b63c844a418be1990c172ed363c6ee612978c3a38df71f1/onyx_devtools-0.7.5-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:ec01aeaaa14854b0933bb85bbfc51184599d3dbf1c0097ff59c1c72db8222a5a", size = 3779585, upload-time = "2026-04-10T07:02:16.31Z" },
{ url = "https://files.pythonhosted.org/packages/b3/85/128d25cd35c1adc436dcff9ab4f2c20cf29528d09415280c1230ff0ca993/onyx_devtools-0.7.5-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:586d50ecb6dcea95611135e4cd4529ebedd8ab84a41b1adf3be1280a48dc52af", size = 4201962, upload-time = "2026-04-10T07:02:14.466Z" },
{ url = "https://files.pythonhosted.org/packages/99/5d/83c80f918b399fea998cd41bfe90bda733eda77e133ca4dc1e9ce18a9b4a/onyx_devtools-0.7.5-py3-none-win_amd64.whl", hash = "sha256:c45d80f0093ba738120b77c4c0bde13843e33d786ae8608eb10490f06183d89b", size = 4320088, upload-time = "2026-04-10T07:02:17.09Z" },
{ url = "https://files.pythonhosted.org/packages/26/bf/b9c85cc61981bd71c0f1cbb50192763b11788a7c8636b1e01f750251c92c/onyx_devtools-0.7.5-py3-none-win_arm64.whl", hash = "sha256:9852a7cc29939371e016b794f2cffdb88680280d857d24c191c5188884416a3d", size = 3858839, upload-time = "2026-04-10T07:02:20.098Z" },
{ url = "https://files.pythonhosted.org/packages/72/64/c75be8ab325896cc64bccd0e1e139a03ce305bf05598967922d380fc4694/onyx_devtools-0.7.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:675e2fdbd8d291fba4b8a6dfcf2bc94c56d22d11f395a9f0d0c3c0e5b39d7f9b", size = 4220613, upload-time = "2026-04-09T00:04:36.624Z" },
{ url = "https://files.pythonhosted.org/packages/ae/1f/589ff6bd446c4498f5bcdfd2a315709e91fc15edf5440c91ff64cbf0800f/onyx_devtools-0.7.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:bf3993de8ba02d6c2f1ab12b5b9b965e005040b37502f97db8a7d88d9b0cde4b", size = 3897867, upload-time = "2026-04-09T00:04:40.781Z" },
{ url = "https://files.pythonhosted.org/packages/10/c0/53c9173eefc13218707282c5b99753960d039684994c3b3caf90ce286094/onyx_devtools-0.7.3-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:6138a94084bed05c674ad210a0bc4006c43bc4384e8eb54d469233de85c72bd7", size = 3762408, upload-time = "2026-04-09T00:04:41.592Z" },
{ url = "https://files.pythonhosted.org/packages/d2/37/69fadb65112854a596d200f704da94b837817d4dd0f46cb4482dc0309c94/onyx_devtools-0.7.3-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:90dac91b0cdc32eb8861f6e83545009a34c439fd3c41fc7dd499acd0105b660e", size = 4184427, upload-time = "2026-04-09T00:04:41.525Z" },
{ url = "https://files.pythonhosted.org/packages/bd/45/91c829ccb45f1a15e7c9641eccc6dd154adb540e03c7dee2a8f28cea24d0/onyx_devtools-0.7.3-py3-none-win_amd64.whl", hash = "sha256:abc68d70bec06e349481beec4b212de28a1a8b7ed6ef3b41daf7093ee10b44f3", size = 4299935, upload-time = "2026-04-09T00:04:40.262Z" },
{ url = "https://files.pythonhosted.org/packages/cc/30/c5adcb8e3b46b71d8d92c3f9ee0c1d0bc5e2adc9f46e93931f21b36a3ee4/onyx_devtools-0.7.3-py3-none-win_arm64.whl", hash = "sha256:9e4411cadc5e81fabc9ed991402e3b4b40f02800681299c277b2142e5af0dcee", size = 3840228, upload-time = "2026-04-09T00:04:39.708Z" },
]
[[package]]

View File

@@ -1,6 +1,6 @@
import "@opal/components/cards/card/styles.css";
import type { PaddingVariants, RoundingVariants } from "@opal/types";
import { paddingVariants, cardRoundingVariants } from "@opal/shared";
import { cardPaddingVariants, cardRoundingVariants } from "@opal/shared";
import { cn } from "@opal/utils";
// ---------------------------------------------------------------------------
@@ -79,7 +79,7 @@ function Card({
ref,
children,
}: CardProps) {
const padding = paddingVariants[paddingProp];
const padding = cardPaddingVariants[paddingProp];
const rounding = cardRoundingVariants[roundingProp];
return (

View File

@@ -1,6 +1,6 @@
import "@opal/components/cards/select-card/styles.css";
import type { PaddingVariants, RoundingVariants } from "@opal/types";
import { paddingVariants, cardRoundingVariants } from "@opal/shared";
import { cardPaddingVariants, cardRoundingVariants } from "@opal/shared";
import { cn } from "@opal/utils";
import { Interactive, type InteractiveStatefulProps } from "@opal/core";
@@ -78,7 +78,7 @@ function SelectCard({
children,
...statefulProps
}: SelectCardProps) {
const padding = paddingVariants[paddingProp];
const padding = cardPaddingVariants[paddingProp];
const rounding = cardRoundingVariants[roundingProp];
return (

View File

@@ -1,82 +0,0 @@
import React from "react";
import type { Meta, StoryObj } from "@storybook/react";
import { Divider } from "@opal/components/divider/components";
const meta: Meta<typeof Divider> = {
title: "opal/components/Divider",
component: Divider,
tags: ["autodocs"],
};
export default meta;
type Story = StoryObj<typeof Divider>;
export const Plain: Story = {
render: () => <Divider />,
};
export const Vertical: Story = {
render: () => (
<div
style={{ display: "flex", alignItems: "stretch", height: 64, gap: 16 }}
>
<span>Left</span>
<Divider orientation="vertical" />
<span>Right</span>
</div>
),
};
export const NoPadding: Story = {
render: () => <Divider paddingParallel="fit" paddingPerpendicular="fit" />,
};
export const CustomPadding: Story = {
render: () => <Divider paddingParallel="lg" paddingPerpendicular="sm" />,
};
export const VerticalNoPadding: Story = {
render: () => (
<div
style={{ display: "flex", alignItems: "stretch", height: 64, gap: 16 }}
>
<span>Left</span>
<Divider
orientation="vertical"
paddingParallel="fit"
paddingPerpendicular="fit"
/>
<span>Right</span>
</div>
),
};
export const WithTitle: Story = {
render: () => <Divider title="Section" />,
};
export const WithDescription: Story = {
render: () => (
<Divider description="Additional configuration options for power users." />
),
};
export const Foldable: Story = {
render: () => (
<Divider title="Advanced Options" foldable defaultOpen={false}>
<div style={{ padding: "0.5rem 0" }}>
<p>This content is revealed when the divider is expanded.</p>
</div>
</Divider>
),
};
export const FoldableDefaultOpen: Story = {
render: () => (
<Divider title="Details" foldable defaultOpen>
<div style={{ padding: "0.5rem 0" }}>
<p>This starts open by default.</p>
</div>
</Divider>
),
};

View File

@@ -1,77 +0,0 @@
# Divider
**Import:** `import { Divider } from "@opal/components";`
A horizontal rule that optionally displays a title, description, or foldable content section.
## Props
The component uses a discriminated union with four variants. `title` and `description` are mutually exclusive; `foldable` requires `title`.
### Bare divider
A plain line with no title or description.
| Prop | Type | Default | Description |
|---|---|---|---|
| `orientation` | `"horizontal" \| "vertical"` | `"horizontal"` | Direction of the line |
| `paddingParallel` | `PaddingVariants` | `"sm"` | Padding along the line direction (0.5rem) |
| `paddingPerpendicular` | `PaddingVariants` | `"xs"` | Padding perpendicular to the line (0.25rem) |
### Titled divider
| Prop | Type | Default | Description |
|---|---|---|---|
| `title` | `string \| RichStr` | **(required)** | Label to the left of the line |
### Described divider
| Prop | Type | Default | Description |
|---|---|---|---|
| `description` | `string \| RichStr` | **(required)** | Text below the line |
### Foldable divider
| Prop | Type | Default | Description |
|---|---|---|---|
| `title` | `string \| RichStr` | **(required)** | Label to the left of the line |
| `foldable` | `true` | **(required)** | Enables fold/expand behavior |
| `open` | `boolean` | — | Controlled open state |
| `defaultOpen` | `boolean` | `false` | Uncontrolled initial open state |
| `onOpenChange` | `(open: boolean) => void` | — | Callback when toggled |
| `children` | `ReactNode` | — | Content revealed when open |
## Usage Examples
```tsx
import { Divider } from "@opal/components";
// Plain horizontal line
<Divider />
// Vertical line
<Divider orientation="vertical" />
// No padding
<Divider paddingParallel="fit" paddingPerpendicular="fit" />
// Custom padding
<Divider paddingParallel="lg" paddingPerpendicular="sm" />
// With title
<Divider title="Advanced" />
// With description
<Divider description="Additional configuration options." />
// Foldable
<Divider title="Advanced Options" foldable>
<p>Hidden content here</p>
</Divider>
// Controlled foldable
const [open, setOpen] = useState(false);
<Divider title="Details" foldable open={open} onOpenChange={setOpen}>
<p>Controlled content</p>
</Divider>
```

View File

@@ -1,199 +0,0 @@
"use client";
import "@opal/components/divider/styles.css";
import { useState, useCallback } from "react";
import type { PaddingVariants, RichStr } from "@opal/types";
import { Button, Text } from "@opal/components";
import { SvgChevronRight } from "@opal/icons";
import { Interactive } from "@opal/core";
import { cn } from "@opal/utils";
import { paddingXVariants, paddingYVariants } from "@opal/shared";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface DividerSharedProps {
ref?: React.Ref<HTMLDivElement>;
title?: never;
description?: never;
foldable?: false;
orientation?: never;
paddingParallel?: never;
paddingPerpendicular?: never;
open?: never;
defaultOpen?: never;
onOpenChange?: never;
children?: never;
}
/** Plain line — no title, no description. */
type DividerBareProps = Omit<
DividerSharedProps,
"orientation" | "paddingParallel" | "paddingPerpendicular"
> & {
/** Orientation of the line. Default: `"horizontal"`. */
orientation?: "horizontal" | "vertical";
/** Padding along the line direction. Default: `"sm"` (0.5rem). */
paddingParallel?: PaddingVariants;
/** Padding perpendicular to the line. Default: `"xs"` (0.25rem). */
paddingPerpendicular?: PaddingVariants;
};
/** Line with a title to the left. */
type DividerTitledProps = Omit<DividerSharedProps, "title"> & {
title: string | RichStr;
};
/** Line with a description below. */
type DividerDescribedProps = Omit<DividerSharedProps, "description"> & {
/** Description rendered below the divider line. */
description: string | RichStr;
};
/** Foldable — requires title, reveals children. */
type DividerFoldableProps = Omit<
DividerSharedProps,
"title" | "foldable" | "open" | "defaultOpen" | "onOpenChange" | "children"
> & {
/** Title is required when foldable. */
title: string | RichStr;
foldable: true;
/** Controlled open state. */
open?: boolean;
/** Uncontrolled default open state. */
defaultOpen?: boolean;
/** Callback when open state changes. */
onOpenChange?: (open: boolean) => void;
/** Content revealed when open. */
children?: React.ReactNode;
};
type DividerProps =
| DividerBareProps
| DividerTitledProps
| DividerDescribedProps
| DividerFoldableProps;
// ---------------------------------------------------------------------------
// Divider
// ---------------------------------------------------------------------------
function Divider(props: DividerProps) {
if (props.foldable) {
return <FoldableDivider {...props} />;
}
const {
ref,
title,
description,
orientation = "horizontal",
paddingParallel = "sm",
paddingPerpendicular = "xs",
} = props;
if (orientation === "vertical") {
return (
<div
ref={ref}
className={cn(
"opal-divider-vertical",
paddingXVariants[paddingPerpendicular],
paddingYVariants[paddingParallel]
)}
>
<div className="opal-divider-line-vertical" />
</div>
);
}
return (
<div
ref={ref}
className={cn(
"opal-divider",
paddingXVariants[paddingParallel],
paddingYVariants[paddingPerpendicular]
)}
>
<div className="opal-divider-row">
{title && (
<div className="opal-divider-title">
<Text font="secondary-body" color="text-03" nowrap>
{title}
</Text>
</div>
)}
<div className="opal-divider-line" />
</div>
{description && (
<div className="opal-divider-description">
<Text font="secondary-body" color="text-03">
{description}
</Text>
</div>
)}
</div>
);
}
// ---------------------------------------------------------------------------
// FoldableDivider (internal)
// ---------------------------------------------------------------------------
function FoldableDivider({
title,
open: controlledOpen,
defaultOpen = false,
onOpenChange,
children,
}: DividerFoldableProps) {
const [internalOpen, setInternalOpen] = useState(defaultOpen);
const isControlled = controlledOpen !== undefined;
const isOpen = isControlled ? controlledOpen : internalOpen;
const toggle = useCallback(() => {
const next = !isOpen;
if (!isControlled) setInternalOpen(next);
onOpenChange?.(next);
}, [isOpen, isControlled, onOpenChange]);
return (
<>
<Interactive.Stateless
variant="default"
prominence="tertiary"
interaction={isOpen ? "hover" : "rest"}
onClick={toggle}
>
<Interactive.Container
roundingVariant="sm"
heightVariant="fit"
widthVariant="full"
>
<div className="opal-divider">
<div className="opal-divider-row">
<div className="opal-divider-title">
<Text font="secondary-body" color="inherit" nowrap>
{title}
</Text>
</div>
<div className="opal-divider-line" />
<div className="opal-divider-chevron" data-open={isOpen}>
<Button
icon={SvgChevronRight}
size="sm"
prominence="tertiary"
/>
</div>
</div>
</div>
</Interactive.Container>
</Interactive.Stateless>
{isOpen && children}
</>
);
}
export { Divider, type DividerProps };

View File

@@ -1,52 +0,0 @@
/* ---------------------------------------------------------------------------
Divider
A horizontal rule with optional title, foldable chevron, or description.
Padding is controlled via Tailwind classes applied by the component.
--------------------------------------------------------------------------- */
/* ── Horizontal ─────────────────────────────────────────────────────────────── */
.opal-divider {
@apply flex flex-col w-full;
gap: 0.75rem;
}
.opal-divider-row {
@apply flex flex-row items-center w-full;
gap: 2px;
padding: 0px;
}
.opal-divider-title {
@apply flex flex-col justify-center;
padding: 0px 2px;
}
.opal-divider-line {
@apply flex-1 h-px bg-border-01;
}
.opal-divider-description {
padding: 0px 2px;
}
/* ── Vertical orientation ───────────────────────────────────────────────────── */
.opal-divider-vertical {
@apply flex flex-row h-full;
}
.opal-divider-line-vertical {
@apply flex-1 w-px bg-border-01;
}
/* ── Foldable chevron ───────────────────────────────────────────────────────── */
.opal-divider-chevron {
@apply transition-transform duration-200 ease-in-out;
}
.opal-divider-chevron[data-open="true"] {
transform: rotate(90deg);
}

View File

@@ -54,12 +54,6 @@ export {
type TagColor,
} from "@opal/components/tag/components";
/* Divider */
export {
Divider,
type DividerProps,
} from "@opal/components/divider/components";
/* Card */
export {
Card,

View File

@@ -10,7 +10,7 @@ const SvgAnthropic = ({ size, ...props }: IconProps) => (
>
<path
d="M36.1779 9.78003H29.1432L41.9653 42.2095H49L36.1779 9.78003ZM15.8221 9.78003L3 42.2095H10.1844L12.8286 35.4243H26.2495L28.8438 42.2095H36.0282L23.2061 9.78003H15.8221ZM15.1236 29.3874L19.5141 18.0121L23.9046 29.3874H15.1236Z"
fill="var(--text-05)"
fill="currentColor"
/>
</svg>
);

View File

@@ -12,7 +12,7 @@ const SvgAws = ({ size, ...props }: IconProps) => (
<title>AWS</title>
<path
d="M14.6195 23.2934C14.6195 23.9333 14.7233 24.4522 14.8443 24.8326C14.9827 25.2131 15.1556 25.6282 15.3978 26.0778C15.4842 26.2162 15.5188 26.3546 15.5188 26.4756C15.5188 26.6486 15.4151 26.8215 15.1902 26.9945L14.1007 27.7208C13.945 27.8246 13.7894 27.8765 13.651 27.8765C13.4781 27.8765 13.3051 27.79 13.1322 27.6344C12.89 27.3749 12.6825 27.0982 12.5096 26.8215C12.3366 26.5275 12.1637 26.1989 11.9734 25.8011C10.6245 27.3922 8.92958 28.1878 6.88881 28.1878C5.43606 28.1878 4.27731 27.7727 3.42988 26.9426C2.58244 26.1124 2.15007 25.0056 2.15007 23.622C2.15007 22.152 2.66891 20.9586 3.72389 20.0593C4.77886 19.16 6.17973 18.7103 7.96108 18.7103C8.54909 18.7103 9.15441 18.7622 9.79431 18.8487C10.4342 18.9352 11.0914 19.0735 11.7832 19.2292V17.9667C11.7832 16.6523 11.5065 15.7356 10.9703 15.1995C10.4169 14.6634 9.483 14.404 8.15132 14.404C7.546 14.404 6.9234 14.4731 6.28349 14.6288C5.64359 14.7844 5.02098 14.9747 4.41567 15.2168C4.13896 15.3379 3.93142 15.407 3.81036 15.4416C3.6893 15.4762 3.60282 15.4935 3.53364 15.4935C3.29152 15.4935 3.17046 15.3206 3.17046 14.9574V14.1099C3.17046 13.8332 3.20505 13.6257 3.29152 13.5046C3.37799 13.3836 3.53364 13.2625 3.77577 13.1414C4.38108 12.8301 5.10746 12.5707 5.9549 12.3632C6.80233 12.1384 7.70165 12.0346 8.65286 12.0346C10.7109 12.0346 12.2156 12.5015 13.1841 13.4355C14.1353 14.3694 14.6195 15.7875 14.6195 17.6899V23.2934ZM7.63248 25.9222C8.2032 25.9222 8.79122 25.8184 9.41383 25.6109C10.0364 25.4034 10.5899 25.0229 11.0568 24.504C11.3335 24.1754 11.5411 23.8122 11.6448 23.3972C11.7486 22.9821 11.8178 22.4806 11.8178 21.8925V21.1662C11.3162 21.0451 10.7801 20.9413 10.2267 20.8722C9.67325 20.803 9.13711 20.7684 8.60098 20.7684C7.44224 20.7684 6.5948 20.9932 6.02407 21.4602C5.45335 21.9271 5.17664 22.5843 5.17664 23.4491C5.17664 24.2619 5.38417 24.8672 5.81654 25.2823C6.23161 25.7147 6.83692 25.9222 7.63248 25.9222ZM21.5201 27.79C21.2088 27.79 21.0012 27.7381 20.8629 27.6171C20.7245 27.5133 20.6035 27.2712 20.4997 26.9426L16.4355 13.5738C16.3317 13.2279 16.2798 13.0031 16.2798 12.882C16.2798 12.6053 16.4182 12.4497 16.6949 12.4497H18.3897C18.7183 12.4497 18.9432 12.5015 19.0642 12.6226C19.2026 12.7264 19.3064 12.9685 19.4101 13.2971L22.3156 24.7462L25.0136 13.2971C25.1001 12.9512 25.2038 12.7264 25.3422 12.6226C25.4806 12.5188 25.7227 12.4497 26.034 12.4497H27.4176C27.7462 12.4497 27.971 12.5015 28.1093 12.6226C28.2477 12.7264 28.3688 12.9685 28.4379 13.2971L31.1705 24.8845L34.1625 13.2971C34.2662 12.9512 34.3873 12.7264 34.5084 12.6226C34.6467 12.5188 34.8716 12.4497 35.1829 12.4497H36.7913C37.068 12.4497 37.2236 12.588 37.2236 12.882C37.2236 12.9685 37.2063 13.055 37.189 13.1587C37.1717 13.2625 37.1372 13.4009 37.068 13.5911L32.9 26.9599C32.7962 27.3058 32.6751 27.5306 32.5368 27.6344C32.3984 27.7381 32.1736 27.8073 31.8796 27.8073H30.3922C30.0636 27.8073 29.8388 27.7554 29.7004 27.6344C29.5621 27.5133 29.441 27.2885 29.3719 26.9426L26.6912 15.7875L24.0278 26.9253C23.9413 27.2712 23.8376 27.496 23.6992 27.6171C23.5609 27.7381 23.3187 27.79 23.0074 27.79H21.5201ZM43.7437 28.257C42.8444 28.257 41.9451 28.1532 41.0803 27.9457C40.2156 27.7381 39.5411 27.5133 39.0914 27.2539C38.8147 27.0982 38.6245 26.9253 38.5553 26.7696C38.4861 26.614 38.4515 26.441 38.4515 26.2854V25.4034C38.4515 25.0402 38.5899 24.8672 38.8493 24.8672C38.9531 24.8672 39.0569 24.8845 39.1606 24.9191C39.2644 24.9537 39.42 25.0229 39.593 25.0921C40.181 25.3515 40.8209 25.559 41.4954 25.6974C42.1872 25.8357 42.8617 25.9049 43.5535 25.9049C44.643 25.9049 45.4905 25.7147 46.0785 25.3342C46.6665 24.9537 46.9778 24.4003 46.9778 23.6912C46.9778 23.2069 46.8222 22.8092 46.5109 22.4806C46.1996 22.152 45.6115 21.858 44.7641 21.5812L42.2564 20.803C40.9939 20.4052 40.0599 19.8172 39.4892 19.0389C38.9185 18.278 38.6245 17.4305 38.6245 16.5312C38.6245 15.8048 38.7801 15.1649 39.0914 14.6115C39.4027 14.0581 39.8178 13.5738 40.3367 13.1933C40.8555 12.7956 41.4435 12.5015 42.1353 12.294C42.8271 12.0865 43.5535 12 44.3144 12C44.6949 12 45.0927 12.0173 45.4732 12.0692C45.871 12.1211 46.2341 12.1902 46.5973 12.2594C46.9432 12.3459 47.2718 12.4324 47.5831 12.5361C47.8944 12.6399 48.1366 12.7437 48.3095 12.8474C48.5516 12.9858 48.7246 13.1242 48.8283 13.2798C48.9321 13.4182 48.984 13.6084 48.984 13.8505V14.6634C48.984 15.0266 48.8456 15.2168 48.5862 15.2168C48.4479 15.2168 48.223 15.1476 47.929 15.0093C46.9432 14.5596 45.8364 14.3348 44.6084 14.3348C43.6227 14.3348 42.8444 14.4904 42.3083 14.819C41.7721 15.1476 41.4954 15.6492 41.4954 16.3583C41.4954 16.8425 41.6684 17.2576 42.0142 17.5862C42.3601 17.9148 43 18.2434 43.9167 18.5374L46.3725 19.3156C47.6177 19.7134 48.517 20.2668 49.0532 20.9759C49.5893 21.685 49.8487 22.4979 49.8487 23.3972C49.8487 24.1408 49.6931 24.8153 49.3991 25.4034C49.0878 25.9914 48.6727 26.5102 48.1366 26.9253C47.6004 27.3577 46.9605 27.669 46.2168 27.8938C45.4386 28.1359 44.6257 28.257 43.7437 28.257Z"
className="fill-[#252F3E] dark:fill-text-05"
fill="#252F3E"
/>
<path
fillRule="evenodd"

View File

@@ -1,25 +0,0 @@
import type { IconProps } from "@opal/types";
const SvgCohere = ({ size, ...props }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 52 52"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M18.256 30.224C19.4293 30.224 21.776 30.1653 25.0613 28.816C28.8747 27.232 36.384 24.416 41.84 21.4827C45.6533 19.4293 47.296 16.7307 47.296 13.0933C47.296 8.10667 43.248 4 38.2027 4H17.0827C9.86667 4 4 9.86667 4 17.0827C4 24.2987 9.51467 30.224 18.256 30.224Z"
fill="#39594D"
/>
<path
d="M21.8347 39.2C21.8347 35.68 23.9467 32.4533 27.232 31.104L33.8613 28.3467C40.608 25.5893 48 30.5173 48 37.792C48 43.424 43.424 48 37.792 48H30.576C25.7653 48 21.8347 44.0693 21.8347 39.2Z"
fill="#D18EE2"
/>
<path
d="M11.568 31.9253C7.40267 31.9253 4 35.328 4 39.4933V40.4907C4 44.5973 7.40267 48 11.568 48C15.7333 48 19.136 44.5973 19.136 40.432V39.4347C19.0773 35.328 15.7333 31.9253 11.568 31.9253Z"
fill="#FF7759"
/>
</svg>
);
export default SvgCohere;

View File

@@ -3,7 +3,6 @@ export { default as SvgAws } from "@opal/logos/aws";
export { default as SvgAzure } from "@opal/logos/azure";
export { default as SvgBifrost } from "@opal/logos/bifrost";
export { default as SvgClaude } from "@opal/logos/claude";
export { default as SvgCohere } from "@opal/logos/cohere";
export { default as SvgDeepseek } from "@opal/logos/deepseek";
export { default as SvgDiscord } from "@opal/logos/discord";
export { default as SvgGemini } from "@opal/logos/gemini";
@@ -12,7 +11,6 @@ export { default as SvgLitellm } from "@opal/logos/litellm";
export { default as SvgLmStudio } from "@opal/logos/lm-studio";
export { default as SvgMicrosoft } from "@opal/logos/microsoft";
export { default as SvgMistral } from "@opal/logos/mistral";
export { default as SvgNomic } from "@opal/logos/nomic";
export { default as SvgOllama } from "@opal/logos/ollama";
export { default as SvgOnyxLogo } from "@opal/logos/onyx-logo";
export { default as SvgOnyxLogoTyped } from "@opal/logos/onyx-logo-typed";
@@ -21,4 +19,3 @@ export { default as SvgOpenai } from "@opal/logos/openai";
export { default as SvgOpenrouter } from "@opal/logos/openrouter";
export { default as SvgQwen } from "@opal/logos/qwen";
export { default as SvgSlack } from "@opal/logos/slack";
export { default as SvgVoyage } from "@opal/logos/voyage";

View File

@@ -1,21 +0,0 @@
import type { IconProps } from "@opal/types";
const SvgNomic = ({ size, ...props }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 52 52"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M35.858 6.31995H46V45.6709H35.6146C32.0852 36.8676 25.1481 27.7804 15.7363 24.8189V6.31995H25.4726C26.5274 12.7296 30.1618 18.3744 35.858 21.6546V6.31995Z"
fill="var(--text-05)"
/>
<path
d="M15.7363 24.8189V45.6709H6L6 30.0927C9.05968 27.6167 11.9635 25.8737 15.7363 24.8189Z"
fill="var(--text-05)"
/>
</svg>
);
export default SvgNomic;

File diff suppressed because one or more lines are too long

View File

@@ -1,7 +1,7 @@
import React from "react";
import type { IconProps } from "@opal/types";
const SvgOpenai = ({ size, ...props }: IconProps) => {
const SvgOpenAI = ({ size, ...props }: IconProps) => {
const clipId = React.useId();
return (
<svg
@@ -15,16 +15,16 @@ const SvgOpenai = ({ size, ...props }: IconProps) => {
<g clipPath={`url(#${clipId})`}>
<path
d="M6.27989 5.99136V4.58828C6.27989 4.4701 6.32383 4.38143 6.42625 4.32242L9.22206 2.69783C9.60266 2.4763 10.0564 2.37296 10.5247 2.37296C12.2813 2.37296 13.3937 3.74654 13.3937 5.20864C13.3937 5.31199 13.3937 5.43016 13.379 5.54833L10.4808 3.83506C10.3052 3.73172 10.1295 3.73172 9.95386 3.83506L6.27989 5.99136ZM12.8082 11.4561V8.10334C12.8082 7.89651 12.7203 7.74883 12.5447 7.64548L8.87071 5.48918L10.071 4.79498C10.1734 4.73597 10.2613 4.73597 10.3637 4.79498L13.1595 6.41959C13.9647 6.89226 14.5061 7.89651 14.5061 8.87124C14.5061 9.99365 13.8476 11.0277 12.8082 11.4561ZM5.41629 8.50218L4.21603 7.7933C4.11361 7.73429 4.06967 7.64563 4.06967 7.52745V4.27824C4.06967 2.69797 5.26993 1.50157 6.89473 1.50157C7.50955 1.50157 8.08029 1.70841 8.56345 2.07761L5.67991 3.76136C5.5043 3.86471 5.41643 4.01239 5.41643 4.21923L5.41629 8.50218ZM7.99984 10.0086L6.27988 9.03389V6.96624L7.99984 5.99151L9.71963 6.96624V9.03389L7.99984 10.0086ZM9.10494 14.4985C8.49012 14.4985 7.91938 14.2917 7.43622 13.9226L10.3197 12.2387C10.4953 12.1354 10.5832 11.9878 10.5832 11.7809V7.4978L11.7982 8.20668C11.9006 8.2657 11.9445 8.35436 11.9445 8.47254V11.7218C11.9445 13.302 10.7296 14.4985 9.10494 14.4985ZM5.63583 11.205L2.84002 9.58041C2.03489 9.10771 1.4934 8.10348 1.4934 7.12875C1.4934 5.99151 2.16672 4.97244 3.20591 4.5441V7.91148C3.20591 8.11831 3.29379 8.26599 3.46939 8.36934L7.12882 10.5108L5.92856 11.205C5.82613 11.264 5.73825 11.264 5.63583 11.205ZM5.47491 13.6272C3.82088 13.6272 2.60592 12.3717 2.60592 10.821C2.60592 10.7028 2.62061 10.5846 2.63517 10.4665L5.51871 12.1502C5.69432 12.2535 5.87006 12.2535 6.04567 12.1502L9.71964 10.0088V11.4119C9.71964 11.53 9.67571 11.6186 9.57328 11.6777L6.77746 13.3023C6.39688 13.5238 5.94323 13.6272 5.47491 13.6272ZM9.10494 15.3846C10.8761 15.3846 12.3544 14.1145 12.6912 12.4307C14.3305 12.0024 15.3845 10.4516 15.3845 8.87139C15.3845 7.8375 14.9453 6.83326 14.1549 6.10955C14.2281 5.79937 14.2721 5.48918 14.2721 5.17914C14.2721 3.06718 12.5741 1.48677 10.6126 1.48677C10.2175 1.48677 9.83689 1.54578 9.4563 1.67878C8.79753 1.02891 7.88999 0.615387 6.89473 0.615387C5.12357 0.615387 3.64528 1.88548 3.30848 3.56923C1.66914 3.99756 0.615234 5.54834 0.615234 7.1286C0.615234 8.1625 1.05431 9.16673 1.84474 9.89044C1.77155 10.2006 1.72762 10.5108 1.72762 10.8209C1.72762 12.9328 3.42558 14.5132 5.38704 14.5132C5.78218 14.5132 6.16278 14.4542 6.54336 14.3213C7.20198 14.9711 8.10953 15.3846 9.10494 15.3846Z"
fill="var(--text-05)"
fill="currentColor"
/>
</g>
<defs>
<clipPath id={clipId}>
<rect width="16" height="16" fill="var(--text-05)" />
<rect width="16" height="16" fill="white" />
</clipPath>
</defs>
</svg>
);
};
export default SvgOpenai;
export default SvgOpenAI;

View File

@@ -14,7 +14,7 @@ const SvgOpenrouter = ({ size, ...props }: IconProps) => (
fillRule="evenodd"
clipRule="evenodd"
d="M33.6 0L48 8.19239V8.36602L33.6 16.4V12.2457L31.8202 12.1858C29.7043 12.1299 28.6014 12.1898 27.2887 12.4053C25.1628 12.7546 23.2168 13.5569 21.001 15.1035L16.6733 18.1071C16.1059 18.4962 15.6843 18.7776 15.3147 19.0151L14.2857 19.6577L13.4925 20.1247L14.2617 20.5837L15.3207 21.2583C16.2717 21.8849 17.6583 22.8469 20.7173 24.9823C22.9351 26.529 24.8791 27.3312 27.005 27.6805L27.6044 27.7703C28.991 27.9519 30.7029 27.9579 33.6 27.8362V23.6L48 31.7198V31.8934L33.6 40V36.284L31.9041 36.3279C29.1349 36.4117 27.6344 36.3319 25.6344 36.0046C22.2498 35.4458 19.1209 34.1566 15.8821 31.8954L11.5704 28.9019C11.0745 28.5603 10.5715 28.2289 10.0619 27.908L9.12887 27.3492C8.62495 27.0592 8.11878 26.7731 7.61039 26.491C5.81019 25.4912 1.12488 24.2658 0 24.2658V15.836C1.12687 15.822 6.09391 14.5946 7.89011 13.5928L9.92008 12.4353L10.7952 11.8884C11.6503 11.3296 12.9371 10.4396 16.1618 8.19039C19.4006 5.92925 22.5275 4.63803 25.9141 4.08123C28.2158 3.70204 29.9237 3.65614 33.6 3.80582V0Z"
fill="var(--text-05)"
fill="currentColor"
/>
</svg>
);

View File

@@ -1,17 +0,0 @@
import type { IconProps } from "@opal/types";
const SvgVoyage = ({ size, ...props }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 52 52"
fill="none"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M14.1848 8V8.11C14.1408 8.24212 14.1139 8.37935 14.1048 8.51833C14.0865 8.70167 14.0782 8.865 14.0782 9.01C14.0782 9.575 14.1498 10.2017 14.2915 10.8933C14.4532 11.5683 14.7482 12.4133 15.1765 13.4333L27.0515 40.71L38.5248 13.65C38.7932 12.9767 39.0798 12.24 39.3832 11.4383C39.6865 10.6383 39.8382 9.82833 39.8382 9.00833C39.8444 8.70074 39.7901 8.39492 39.6782 8.10833V8H45.1732V8.11C44.8332 8.455 44.4232 9.07333 43.9398 9.96667C43.4565 10.8583 42.9298 11.9583 42.3582 13.27L26.9982 48H24.8532L10.2982 14.6083C9.95818 13.825 9.60151 13.07 9.22484 12.3417C8.86818 11.6133 8.52818 10.9583 8.20818 10.375C7.88484 9.775 7.59984 9.275 7.34984 8.875C7.19246 8.61074 7.02226 8.35432 6.83984 8.10667V8H14.1848Z"
className="fill-[#012E33] dark:fill-text-05"
/>
</svg>
);
export default SvgVoyage;

View File

@@ -100,7 +100,7 @@ const heightVariants: Record<ExtremaSizeVariants, string> = {
// - SelectCard (paddingVariant, roundingVariant)
// ---------------------------------------------------------------------------
const paddingVariants: Record<PaddingVariants, string> = {
const cardPaddingVariants: Record<PaddingVariants, string> = {
lg: "p-6",
md: "p-4",
sm: "p-2",
@@ -109,24 +109,6 @@ const paddingVariants: Record<PaddingVariants, string> = {
fit: "p-0",
};
const paddingXVariants: Record<PaddingVariants, string> = {
lg: "px-6",
md: "px-4",
sm: "px-2",
xs: "px-1",
"2xs": "px-0.5",
fit: "px-0",
};
const paddingYVariants: Record<PaddingVariants, string> = {
lg: "py-6",
md: "py-4",
sm: "py-2",
xs: "py-1",
"2xs": "py-0.5",
fit: "py-0",
};
const cardRoundingVariants: Record<RoundingVariants, string> = {
lg: "rounded-16",
md: "rounded-12",
@@ -140,9 +122,7 @@ export {
type OverridableExtremaSizeVariants,
type SizeVariants,
containerSizeVariants,
paddingVariants,
paddingXVariants,
paddingYVariants,
cardPaddingVariants,
cardRoundingVariants,
widthVariants,
heightVariants,

24
web/package-lock.json generated
View File

@@ -47,7 +47,6 @@
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"cookies-next": "^5.1.0",
"copy-to-clipboard": "^3.3.3",
"date-fns": "^3.6.0",
"docx-preview": "^0.3.7",
"favicon-fetch": "^1.0.0",
@@ -56,7 +55,7 @@
"js-cookie": "^3.0.5",
"katex": "^0.16.38",
"linguist-languages": "^9.3.1",
"lodash": "^4.18.1",
"lodash": "^4.17.23",
"lowlight": "^3.3.0",
"lucide-react": "^0.454.0",
"mdast-util-find-and-replace": "^3.0.1",
@@ -8844,15 +8843,6 @@
"react": ">= 16.8.0"
}
},
"node_modules/copy-to-clipboard": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz",
"integrity": "sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==",
"license": "MIT",
"dependencies": {
"toggle-selection": "^1.0.6"
}
},
"node_modules/core-js": {
"version": "3.46.0",
"hasInstallScript": true,
@@ -12936,9 +12926,9 @@
}
},
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"license": "MIT"
},
"node_modules/lodash-es": {
@@ -17436,12 +17426,6 @@
"node": ">=8.0"
}
},
"node_modules/toggle-selection": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz",
"integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==",
"license": "MIT"
},
"node_modules/toposort": {
"version": "2.0.2",
"license": "MIT"

View File

@@ -65,7 +65,6 @@
"clsx": "^2.1.1",
"cmdk": "^1.0.0",
"cookies-next": "^5.1.0",
"copy-to-clipboard": "^3.3.3",
"date-fns": "^3.6.0",
"docx-preview": "^0.3.7",
"favicon-fetch": "^1.0.0",
@@ -74,7 +73,7 @@
"js-cookie": "^3.0.5",
"katex": "^0.16.38",
"linguist-languages": "^9.3.1",
"lodash": "^4.18.1",
"lodash": "^4.17.23",
"lowlight": "^3.3.0",
"lucide-react": "^0.454.0",
"mdast-util-find-and-replace": "^3.0.1",

View File

@@ -17,7 +17,6 @@ import DocumentSetCard from "@/sections/cards/DocumentSetCard";
import CollapsibleSection from "@/app/admin/agents/CollapsibleSection";
import { StandardAnswerCategoryResponse } from "@/components/standardAnswers/getStandardAnswerCategoriesIfEE";
import { StandardAnswerCategoryDropdownField } from "@/components/standardAnswers/StandardAnswerCategoryDropdown";
import InputComboBox from "@/refresh-components/inputs/InputComboBox";
import { RadioGroup } from "@/components/ui/radio-group";
import { RadioGroupItemField } from "@/components/ui/RadioGroupItemField";
import { AlertCircle } from "lucide-react";
@@ -127,24 +126,6 @@ export function SlackChannelConfigFormFields({
return documentSets.filter((ds) => !documentSetContainsSync(ds));
}, [documentSets]);
const searchAgentOptions = useMemo(
() =>
availableAgents.map((persona) => ({
label: persona.name,
value: String(persona.id),
})),
[availableAgents]
);
const nonSearchAgentOptions = useMemo(
() =>
nonSearchAgents.map((persona) => ({
label: persona.name,
value: String(persona.id),
})),
[nonSearchAgents]
);
useEffect(() => {
const invalidSelected = values.document_sets.filter((dsId: number) =>
unselectableSets.some((us) => us.id === dsId)
@@ -374,14 +355,12 @@ export function SlackChannelConfigFormFields({
</>
</SubLabel>
<InputComboBox
placeholder="Search for an agent..."
value={String(values.persona_id ?? "")}
onValueChange={(val) =>
setFieldValue("persona_id", val ? Number(val) : null)
}
options={searchAgentOptions}
strict
<SelectorFormField
name="persona_id"
options={availableAgents.map((persona) => ({
name: persona.name,
value: persona.id,
}))}
/>
{viewSyncEnabledAgents && syncEnabledAgents.length > 0 && (
<div className="mt-4">
@@ -440,14 +419,12 @@ export function SlackChannelConfigFormFields({
</>
</SubLabel>
<InputComboBox
placeholder="Search for an agent..."
value={String(values.persona_id ?? "")}
onValueChange={(val) =>
setFieldValue("persona_id", val ? Number(val) : null)
}
options={nonSearchAgentOptions}
strict
<SelectorFormField
name="persona_id"
options={nonSearchAgents.map((persona) => ({
name: persona.name,
value: persona.id,
}))}
/>
</div>
)}

View File

@@ -1,5 +1,5 @@
import { defaultTailwindCSS } from "@/components/icons/icons";
import { getModelIcon } from "@/lib/llmConfig";
import { getModelIcon } from "@/lib/llmConfig/providers";
import { IconProps } from "@opal/types";
export interface ModelIconProps extends IconProps {

View File

@@ -1 +1 @@
export { default } from "@/refresh-pages/admin/LLMConfigurationPage";
export { default } from "@/refresh-pages/admin/LLMProviderConfigurationPage";

View File

@@ -1,7 +1,6 @@
import Modal from "@/refresh-components/Modal";
import { Button } from "@opal/components";
import { CloudEmbeddingModel } from "../../../../components/embedding/interfaces";
import { markdown } from "@opal/utils";
import { SvgCheck } from "@opal/icons";
export interface AlreadyPickedModalProps {
@@ -18,7 +17,7 @@ export default function AlreadyPickedModal({
<Modal.Content width="sm" height="sm">
<Modal.Header
icon={SvgCheck}
title={markdown(`*${model.model_name}* already chosen`)}
title={`${model.model_name} already chosen`}
description="You can select a different one if you want!"
onClose={onClose}
/>

View File

@@ -12,7 +12,6 @@ import {
getFormattedProviderName,
} from "@/components/embedding/interfaces";
import { EMBEDDING_PROVIDERS_ADMIN_URL } from "@/lib/llmConfig/constants";
import { markdown } from "@opal/utils";
import { mutate } from "swr";
import { SWR_KEYS } from "@/lib/swr-keys";
import { testEmbedding } from "@/app/admin/embeddings/pages/utils";
@@ -173,11 +172,9 @@ export default function ChangeCredentialsModal({
<Modal.Content>
<Modal.Header
icon={SvgSettings}
title={markdown(
`Modify your *${getFormattedProviderName(
provider.provider_type
)}* ${isProxy ? "configuration" : "key"}`
)}
title={`Modify your ${getFormattedProviderName(
provider.provider_type
)} ${isProxy ? "Configuration" : "key"}`}
onClose={onCancel}
/>
<Modal.Body>

View File

@@ -7,7 +7,6 @@ import {
getFormattedProviderName,
} from "../../../../components/embedding/interfaces";
import { SvgTrash } from "@opal/icons";
import { markdown } from "@opal/utils";
export interface DeleteCredentialsModalProps {
modelProvider: CloudEmbeddingProvider;
@@ -25,11 +24,9 @@ export default function DeleteCredentialsModal({
<Modal.Content width="sm" height="sm">
<Modal.Header
icon={SvgTrash}
title={markdown(
`Delete *${getFormattedProviderName(
modelProvider.provider_type
)}* credentials?`
)}
title={`Delete ${getFormattedProviderName(
modelProvider.provider_type
)} Credentials?`}
onClose={onCancel}
/>
<Modal.Body>

View File

@@ -12,7 +12,6 @@ import {
} from "@/components/embedding/interfaces";
import { EMBEDDING_PROVIDERS_ADMIN_URL } from "@/lib/llmConfig/constants";
import Modal from "@/refresh-components/Modal";
import { markdown } from "@opal/utils";
import { SvgSettings } from "@opal/icons";
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
export interface ProviderCreationModalProps {
@@ -186,11 +185,9 @@ export default function ProviderCreationModal({
<Modal.Content width="sm" height="sm">
<Modal.Header
icon={SvgSettings}
title={markdown(
`Configure *${getFormattedProviderName(
selectedProvider.provider_type
)}*`
)}
title={`Configure ${getFormattedProviderName(
selectedProvider.provider_type
)}`}
onClose={onCancel}
/>
<Modal.Body>

View File

@@ -2,7 +2,6 @@ import Modal from "@/refresh-components/Modal";
import { Button } from "@opal/components";
import Text from "@/refresh-components/texts/Text";
import { CloudEmbeddingModel } from "@/components/embedding/interfaces";
import { markdown } from "@opal/utils";
import { SvgServer } from "@opal/icons";
export interface SelectModelModalProps {
@@ -21,7 +20,7 @@ export default function SelectModelModal({
<Modal.Content width="sm" height="sm">
<Modal.Header
icon={SvgServer}
title={markdown(`Select *${model.model_name}*`)}
title={`Select ${model.model_name}`}
onClose={onCancel}
/>
<Modal.Body>

View File

@@ -1,7 +1,6 @@
"use client";
import { toast } from "@/hooks/useToast";
import { markdown } from "@opal/utils";
import EmbeddingModelSelection from "../EmbeddingModelSelectionForm";
import { useCallback, useEffect, useMemo, useState, useRef } from "react";
@@ -539,9 +538,7 @@ export default function EmbeddingForm() {
<Modal.Content>
<Modal.Header
icon={SvgAlertTriangle}
title={markdown(
`Are you sure you want to select *${selectedProvider.model_name}*?`
)}
title={`Are you sure you want to select ${selectedProvider.model_name}?`}
onClose={() => setShowPoorModel(false)}
/>
<Modal.Body>

View File

@@ -5,7 +5,7 @@ import { Button } from "@opal/components";
import { Text } from "@opal/components";
import { ContentAction } from "@opal/layouts";
import { SvgEyeOff, SvgX } from "@opal/icons";
import { getModelIcon } from "@/lib/llmConfig";
import { getModelIcon } from "@/lib/llmConfig/providers";
import AgentMessage, {
AgentMessageProps,
} from "@/app/app/message/messageComponents/AgentMessage";
@@ -28,8 +28,6 @@ export interface MultiModelPanelProps {
isNonPreferredInSelection: boolean;
/** Callback when user clicks this panel to select as preferred */
onSelect: () => void;
/** Callback to deselect this panel as preferred */
onDeselect?: () => void;
/** Callback to hide/show this panel */
onToggleVisibility: () => void;
/** Props to pass through to AgentMessage */
@@ -65,7 +63,6 @@ export default function MultiModelPanel({
isHidden,
isNonPreferredInSelection,
onSelect,
onDeselect,
onToggleVisibility,
agentMessageProps,
errorMessage,
@@ -96,25 +93,11 @@ export default function MultiModelPanel({
rightChildren={
<div className="flex items-center gap-1 px-2">
{isPreferred && (
<>
<span className="text-action-link-05 shrink-0">
<Text font="secondary-body" color="inherit" nowrap>
Preferred Response
</Text>
</span>
{onDeselect && (
<Button
prominence="tertiary"
icon={SvgX}
size="sm"
onClick={(e) => {
e.stopPropagation();
onDeselect();
}}
tooltip="Deselect preferred response"
/>
)}
</>
<span className="text-action-link-05 shrink-0">
<Text font="secondary-body" color="inherit" nowrap>
Preferred Response
</Text>
</span>
)}
{!isPreferred && (
<Button

View File

@@ -30,7 +30,7 @@ const SELECTION_PANEL_W = 400;
// Compact width for hidden panels in the carousel track
const HIDDEN_PANEL_W = 220;
// Generation-mode panel widths (from Figma)
const GEN_PANEL_W_2 = 720; // 2 panels side-by-side
const GEN_PANEL_W_2 = 640; // 2 panels side-by-side
const GEN_PANEL_W_3 = 436; // 3 panels side-by-side
// Gap between panels — matches CSS gap-6 (24px)
const PANEL_GAP = 24;
@@ -64,31 +64,14 @@ export default function MultiModelResponseView({
onMessageSelection,
onHiddenPanelsChange,
}: MultiModelResponseViewProps) {
// Initialize preferredIndex from the backend's preferred_response_id when
// loading an existing conversation.
const [preferredIndex, setPreferredIndex] = useState<number | null>(() => {
if (!parentMessage?.preferredResponseId) return null;
const match = responses.find(
(r) => r.messageId === parentMessage.preferredResponseId
);
return match?.modelIndex ?? null;
});
const [preferredIndex, setPreferredIndex] = useState<number | null>(null);
const [hiddenPanels, setHiddenPanels] = useState<Set<number>>(new Set());
// Controls animation: false = panels at start position, true = panels at peek position
const [selectionEntered, setSelectionEntered] = useState(
() => preferredIndex !== null
);
// Tracks the deselect animation timeout so it can be cancelled if the user
// re-selects a panel during the 450ms animation window.
const deselectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// True while the reverse animation is playing (deselect → back to equal panels)
const [selectionExiting, setSelectionExiting] = useState(false);
const [selectionEntered, setSelectionEntered] = useState(false);
// Measures the overflow-hidden carousel container for responsive preferred-panel sizing.
const [trackContainerW, setTrackContainerW] = useState(0);
const roRef = useRef<ResizeObserver | null>(null);
const trackContainerElRef = useRef<HTMLDivElement | null>(null);
const trackContainerRef = useCallback((el: HTMLDivElement | null) => {
trackContainerElRef.current = el;
if (roRef.current) {
roRef.current.disconnect();
roRef.current = null;
@@ -107,9 +90,6 @@ export default function MultiModelResponseView({
number | null
>(null);
const preferredRoRef = useRef<ResizeObserver | null>(null);
// Refs to each panel wrapper for height animation on deselect
const panelElsRef = useRef<Map<number, HTMLDivElement>>(new Map());
// Tracks which non-preferred panels overflow the preferred height cap
const [overflowingPanels, setOverflowingPanels] = useState<Set<number>>(
new Set()
@@ -172,48 +152,15 @@ export default function MultiModelResponseView({
const handleSelectPreferred = useCallback(
(modelIndex: number) => {
if (isGenerating) return;
// Cancel any pending deselect animation so it doesn't overwrite this selection
if (deselectTimeoutRef.current !== null) {
clearTimeout(deselectTimeoutRef.current);
deselectTimeoutRef.current = null;
setSelectionExiting(false);
}
// Only freeze scroll when entering selection mode for the first time.
// When switching preferred within selection mode, panels are already
// capped and the track just slides — no height changes to worry about.
const alreadyInSelection = preferredIndex !== null;
if (!alreadyInSelection) {
const scrollContainer = trackContainerElRef.current?.closest(
"[data-chat-scroll]"
) as HTMLElement | null;
const scrollTop = scrollContainer?.scrollTop ?? 0;
if (scrollContainer) scrollContainer.style.overflow = "hidden";
setTimeout(() => {
if (scrollContainer) {
scrollContainer.scrollTop = scrollTop;
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (scrollContainer) {
scrollContainer.scrollTop = scrollTop;
scrollContainer.style.overflow = "";
}
});
});
}
}, 450);
}
setPreferredIndex(modelIndex);
const response = responses.find((r) => r.modelIndex === modelIndex);
if (!response) return;
if (onMessageSelection) {
onMessageSelection(response.nodeId);
}
// Persist preferred response + sync `latestChildNodeId`. Backend's
// `set_preferred_response` updates `latest_child_message_id`; if the
// frontend chain walk disagrees, the next follow-up fails with
// "not on the latest mainline".
// Persist preferred response to backend + update local tree so the
// input bar unblocks (awaitingPreferredSelection clears).
if (parentMessage?.messageId && response.messageId && currentSessionId) {
setPreferredResponse(parentMessage.messageId, response.messageId).catch(
(err) => console.error("Failed to persist preferred response:", err)
@@ -229,7 +176,6 @@ export default function MultiModelResponseView({
updated.set(parentMessage.nodeId, {
...userMsg,
preferredResponseId: response.messageId,
latestChildNodeId: response.nodeId,
});
updateSessionMessageTree(currentSessionId, updated);
}
@@ -239,111 +185,17 @@ export default function MultiModelResponseView({
[
isGenerating,
responses,
preferredIndex,
onMessageSelection,
parentMessage,
currentSessionId,
updateSessionMessageTree,
]
);
// NOTE: Deselect only clears the local tree — no backend call to clear
// preferred_response_id. The SetPreferredResponseRequest model doesn't
// accept null. A backend endpoint for clearing preference would be needed
// if deselect should persist across reloads.
const handleDeselectPreferred = useCallback(() => {
const scrollContainer = trackContainerElRef.current?.closest(
"[data-chat-scroll]"
) as HTMLElement | null;
// Animate panels back to equal positions, then clear preferred after transition
setSelectionExiting(true);
setSelectionEntered(false);
deselectTimeoutRef.current = setTimeout(() => {
deselectTimeoutRef.current = null;
const scrollTop = scrollContainer?.scrollTop ?? 0;
if (scrollContainer) scrollContainer.style.overflow = "hidden";
// Before clearing state, animate each capped panel's height from
// its current clientHeight to its natural scrollHeight.
const animations: Animation[] = [];
panelElsRef.current.forEach((el, modelIndex) => {
if (modelIndex === preferredIndex) return;
if (hiddenPanels.has(modelIndex)) return;
const from = el.clientHeight;
const to = el.scrollHeight;
if (to <= from) return;
// Lock current height, remove maxHeight cap, then animate
el.style.maxHeight = `${from}px`;
el.style.overflow = "hidden";
const anim = el.animate(
[{ maxHeight: `${from}px` }, { maxHeight: `${to}px` }],
{
duration: 350,
easing: "cubic-bezier(0.2, 0, 0, 1)",
fill: "forwards",
}
);
animations.push(anim);
anim.onfinish = () => {
el.style.maxHeight = "";
el.style.overflow = "";
};
});
setSelectionExiting(false);
setPreferredIndex(null);
// Restore scroll after animations + React settle
const restoreScroll = () => {
requestAnimationFrame(() => {
if (scrollContainer) {
scrollContainer.scrollTop = scrollTop;
scrollContainer.style.overflow = "";
}
});
};
if (animations.length > 0) {
Promise.all(animations.map((a) => a.finished))
.then(restoreScroll)
.catch(restoreScroll);
} else {
restoreScroll();
}
// Clear preferredResponseId in the local tree so input bar re-gates
if (parentMessage && currentSessionId) {
const tree = useChatSessionStore
.getState()
.sessions.get(currentSessionId)?.messageTree;
if (tree) {
const userMsg = tree.get(parentMessage.nodeId);
if (userMsg) {
const updated = new Map(tree);
updated.set(parentMessage.nodeId, {
...userMsg,
preferredResponseId: undefined,
});
updateSessionMessageTree(currentSessionId, updated);
}
}
}
}, 450);
}, [
parentMessage,
currentSessionId,
updateSessionMessageTree,
preferredIndex,
hiddenPanels,
]);
// Clear preferred selection when generation starts
// Reset selection state when generation restarts
useEffect(() => {
if (isGenerating) {
setPreferredIndex(null);
setHasEnteredSelection(false);
setSelectionExiting(false);
}
}, [isGenerating]);
@@ -352,39 +204,22 @@ export default function MultiModelResponseView({
(r) => r.modelIndex === preferredIndex
);
// Track whether selection mode was ever entered — once it has been,
// we stay in the selection layout (even after deselect) to avoid a
// jarring DOM swap between the two layout strategies.
const [hasEnteredSelection, setHasEnteredSelection] = useState(
() => preferredIndex !== null
);
const isActivelySelected =
// Selection mode when preferred is set, found in responses, not generating, and at least 2 visible panels
const showSelectionMode =
preferredIndex !== null &&
preferredIdx !== -1 &&
!isGenerating &&
visibleResponses.length > 1;
// Trigger the slide-out animation one frame after entering selection mode
useEffect(() => {
if (isActivelySelected) setHasEnteredSelection(true);
}, [isActivelySelected]);
// Use the selection layout once a preferred response has been chosen,
// even after deselect. Only fall through to generation layout before
// the first selection or during active streaming.
const showSelectionMode = isActivelySelected || hasEnteredSelection;
// Trigger the slide-out animation one frame after a preferred panel is selected.
// Uses isActivelySelected (not showSelectionMode) so re-selecting after a
// deselect still triggers the animation.
useEffect(() => {
if (!isActivelySelected) {
// Don't reset selectionEntered here — handleDeselectPreferred manages it
if (!showSelectionMode) {
setSelectionEntered(false);
return;
}
const raf = requestAnimationFrame(() => setSelectionEntered(true));
return () => cancelAnimationFrame(raf);
}, [isActivelySelected]);
}, [showSelectionMode]);
// Build panel props — isHidden reflects actual hidden state
const buildPanelProps = useCallback(
@@ -396,7 +231,6 @@ export default function MultiModelResponseView({
isHidden: hiddenPanels.has(response.modelIndex),
isNonPreferredInSelection: isNonPreferred,
onSelect: () => handleSelectPreferred(response.modelIndex),
onDeselect: handleDeselectPreferred,
onToggleVisibility: () => toggleVisibility(response.modelIndex),
agentMessageProps: {
rawPackets: response.packets,
@@ -421,7 +255,6 @@ export default function MultiModelResponseView({
preferredIndex,
hiddenPanels,
handleSelectPreferred,
handleDeselectPreferred,
toggleVisibility,
chatState,
llmManager,
@@ -477,30 +310,25 @@ export default function MultiModelResponseView({
<div
ref={trackContainerRef}
className="w-full overflow-hidden"
style={
isActivelySelected
? {
maskImage: `linear-gradient(to right, transparent 0px, black ${PEEK_W}px, black calc(100% - ${PEEK_W}px), transparent 100%)`,
WebkitMaskImage: `linear-gradient(to right, transparent 0px, black ${PEEK_W}px, black calc(100% - ${PEEK_W}px), transparent 100%)`,
}
: undefined
}
style={{
maskImage: `linear-gradient(to right, transparent 0px, black ${PEEK_W}px, black calc(100% - ${PEEK_W}px), transparent 100%)`,
WebkitMaskImage: `linear-gradient(to right, transparent 0px, black ${PEEK_W}px, black calc(100% - ${PEEK_W}px), transparent 100%)`,
}}
>
<div
className="flex items-start"
style={{
gap: `${PANEL_GAP}px`,
transition:
selectionEntered || selectionExiting
? "transform 0.45s cubic-bezier(0.2, 0, 0, 1)"
: "none",
transition: selectionEntered
? "transform 0.45s cubic-bezier(0.2, 0, 0, 1)"
: "none",
transform: trackTransform,
}}
>
{responses.map((r, i) => {
const isHidden = hiddenPanels.has(r.modelIndex);
const isPref = r.modelIndex === preferredIndex;
const isNonPref = !isHidden && !isPref && preferredIndex !== null;
const isNonPref = !isHidden && !isPref;
const finalW = selectionWidths[i]!;
const startW = isHidden ? HIDDEN_PANEL_W : SELECTION_PANEL_W;
const capped = isNonPref && preferredPanelHeight != null;
@@ -509,11 +337,6 @@ export default function MultiModelResponseView({
<div
key={r.modelIndex}
ref={(el) => {
if (el) {
panelElsRef.current.set(r.modelIndex, el);
} else {
panelElsRef.current.delete(r.modelIndex);
}
if (isPref) preferredPanelRef(el);
if (capped && el) {
const doesOverflow = el.scrollHeight > el.clientHeight;
@@ -530,10 +353,9 @@ export default function MultiModelResponseView({
style={{
width: `${selectionEntered ? finalW : startW}px`,
flexShrink: 0,
transition:
selectionEntered || selectionExiting
? "width 0.45s cubic-bezier(0.2, 0, 0, 1)"
: "none",
transition: selectionEntered
? "width 0.45s cubic-bezier(0.2, 0, 0, 1)"
: "none",
maxHeight: capped ? preferredPanelHeight : undefined,
overflow: capped ? "hidden" : undefined,
position: capped ? "relative" : undefined,
@@ -566,7 +388,7 @@ export default function MultiModelResponseView({
return (
<div className="overflow-x-auto">
<div className="flex gap-6 items-start justify-center w-full">
<div className="flex gap-6 items-start w-full">
{responses.map((r) => {
const isHidden = hiddenPanels.has(r.modelIndex);
return (

View File

@@ -18,7 +18,7 @@ import {
isRecommendedModel,
} from "@/app/craft/onboarding/constants";
import { ToggleWarningModal } from "./ToggleWarningModal";
import { getModelIcon } from "@/lib/llmConfig";
import { getModelIcon } from "@/lib/llmConfig/providers";
import { Section } from "@/layouts/general-layouts";
import {
Accordion,

View File

@@ -48,7 +48,7 @@ import NotAllowedModal from "@/app/craft/onboarding/components/NotAllowedModal";
import { useOnboarding } from "@/app/craft/onboarding/BuildOnboardingProvider";
import { useLLMProviders } from "@/hooks/useLLMProviders";
import { useUser } from "@/providers/UserProvider";
import { getModelIcon } from "@/lib/llmConfig";
import { getModelIcon } from "@/lib/llmConfig/providers";
import {
getBuildUserPersona,
getPersonaInfo,

View File

@@ -1,5 +1,5 @@
:root {
--app-page-main-content-width: 45rem;
--app-page-main-content-width: 52.5rem;
--block-width-form-input-min: 10rem;
--container-sm: 42rem;

View File

@@ -61,7 +61,7 @@ function QueryHistoryTableRow({
key={chatSessionMinimal.id}
className="hover:bg-accent-background cursor-pointer relative select-none"
>
<TableCell className="max-w-xs">
<TableCell>
<Text className="whitespace-normal line-clamp-5">
{chatSessionMinimal.first_user_message ||
chatSessionMinimal.name ||

View File

@@ -45,9 +45,6 @@ import { personaIncludesRetrieval } from "@/app/app/services/lib";
import { useQueryController } from "@/providers/QueryControllerProvider";
import { eeGated } from "@/ce";
import EESearchUI from "@/ee/sections/SearchUI";
import useMultiModelChat from "@/hooks/useMultiModelChat";
import ModelSelector from "@/refresh-components/popovers/ModelSelector";
import { Section } from "@/layouts/general-layouts";
const SearchUI = eeGated(EESearchUI);
@@ -108,20 +105,6 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
// If no LLM provider is configured (e.g., fresh signup), the input bar is
// disabled and a "Set up an LLM" button is shown (see bottom of component).
const llmManager = useLlmManager(undefined, liveAgent ?? undefined);
const multiModel = useMultiModelChat(llmManager);
// Sync single-model selection to llmManager so the submission path
// uses the correct provider/version (mirrors AppPage behaviour).
useEffect(() => {
if (multiModel.selectedModels.length === 1) {
const model = multiModel.selectedModels[0]!;
llmManager.updateCurrentLlm({
name: model.name,
provider: model.provider,
modelName: model.modelName,
});
}
}, [multiModel.selectedModels]);
// Deep research toggle
const { deepResearchEnabled, toggleDeepResearch } = useDeepResearchToggle({
@@ -312,17 +295,12 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
// If we already have messages (chat session started), always use chat mode
// (matches AppPage behavior where existing sessions bypass classification)
const selectedModels = multiModel.isMultiModelActive
? multiModel.selectedModels
: undefined;
if (hasMessages) {
onSubmit({
message: submittedMessage,
currentMessageFiles: currentMessageFiles,
deepResearch: deepResearchEnabled,
additionalContext,
selectedModels,
});
return;
}
@@ -334,7 +312,6 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
currentMessageFiles: currentMessageFiles,
deepResearch: deepResearchEnabled,
additionalContext,
selectedModels,
});
};
@@ -351,8 +328,6 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
submitQuery,
tabReadingEnabled,
currentTabUrl,
multiModel.isMultiModelActive,
multiModel.selectedModels,
]
);
@@ -481,7 +456,6 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
onResubmit={handleResubmitLastMessage}
deepResearchEnabled={deepResearchEnabled}
anchorNodeId={anchorNodeId}
selectedModels={multiModel.selectedModels}
/>
</ChatScrollContainer>
</>
@@ -490,23 +464,7 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
{/* Welcome message - centered when no messages and not in search mode */}
{!hasMessages && !isSearch && (
<div className="relative w-full flex-1 flex flex-col items-center justify-end">
<Section
flexDirection="row"
justifyContent="between"
alignItems="end"
className="max-w-[var(--app-page-main-content-width)]"
>
<WelcomeMessage isDefaultAgent />
{liveAgent && !llmManager.isLoadingProviders && (
<ModelSelector
llmManager={llmManager}
selectedModels={multiModel.selectedModels}
onAdd={multiModel.addModel}
onRemove={multiModel.removeModel}
onReplace={multiModel.replaceModel}
/>
)}
</Section>
<WelcomeMessage isDefaultAgent />
<Spacer rem={1.5} />
</div>
)}
@@ -520,17 +478,6 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
"max-w-[var(--app-page-main-content-width)] px-4"
)}
>
{hasMessages && liveAgent && !llmManager.isLoadingProviders && (
<div className="pb-1">
<ModelSelector
llmManager={llmManager}
selectedModels={multiModel.selectedModels}
onAdd={multiModel.addModel}
onRemove={multiModel.removeModel}
onReplace={multiModel.replaceModel}
/>
</div>
)}
<AppInputBar
ref={chatInputBarRef}
deepResearchEnabled={deepResearchEnabled}

View File

@@ -3,7 +3,7 @@
import { useMemo } from "react";
import { parseLlmDescriptor, structureValue } from "@/lib/llmConfig/utils";
import { DefaultModel, LLMProviderDescriptor } from "@/interfaces/llm";
import { getModelIcon } from "@/lib/llmConfig";
import { getModelIcon } from "@/lib/llmConfig/providers";
import InputSelect from "@/refresh-components/inputs/InputSelect";
import { createIcon } from "@/components/icons/icons";

View File

@@ -137,7 +137,7 @@ function DeleteConfirmModal({ hook, onDelete }: DeleteConfirmModalProps) {
<Modal.Header
// TODO(@raunakab): replace the colour of this SVG with red.
icon={SvgTrash}
title={markdown(`Delete *${hook.name}*`)}
title={`Delete ${hook.name}`}
onClose={onClose}
/>
<Modal.Body>

View File

@@ -694,25 +694,6 @@ export function useLlmManager(
prevAgentIdRef.current = liveAgent?.id;
}, [liveAgent?.id]);
// Clear manual override when arriving at a *different* existing session
// from any previously-seen defined session. Tracks only the last
// *defined* session id so a round-trip through new-chat (A → undefined
// → B) still resets, while A → undefined (new-chat) preserves it.
const prevDefinedSessionIdRef = useRef<string | undefined>(undefined);
useEffect(() => {
const nextId = currentChatSession?.id;
if (
nextId !== undefined &&
prevDefinedSessionIdRef.current !== undefined &&
nextId !== prevDefinedSessionIdRef.current
) {
setUserHasManuallyOverriddenLLM(false);
}
if (nextId !== undefined) {
prevDefinedSessionIdRef.current = nextId;
}
}, [currentChatSession?.id]);
function getValidLlmDescriptor(
modelName: string | null | undefined
): LlmDescriptor {
@@ -734,9 +715,8 @@ export function useLlmManager(
if (llmProviders === undefined || llmProviders === null) {
resolved = manualLlm;
} else if (userHasManuallyOverriddenLLM) {
// Manual override wins over session's `current_alternate_model`.
// Cleared on cross-session navigation by the effect above.
} else if (userHasManuallyOverriddenLLM && !currentChatSession) {
// User has overridden in this session and switched to a new session
resolved = manualLlm;
} else if (currentChatSession?.current_alternate_model) {
resolved = getValidLlmDescriptorForProviders(
@@ -748,6 +728,8 @@ export function useLlmManager(
liveAgent.llm_model_version_override,
llmProviders
);
} else if (userHasManuallyOverriddenLLM) {
resolved = manualLlm;
} else if (user?.preferences?.default_model) {
resolved = getValidLlmDescriptorForProviders(
user.preferences.default_model,

View File

@@ -1,251 +0,0 @@
import type { IconFunctionComponent } from "@opal/types";
import { SvgCpu, SvgPlug, SvgServer } from "@opal/icons";
import {
SvgBifrost,
SvgOpenai,
SvgClaude,
SvgOllama,
SvgAws,
SvgOpenrouter,
SvgAzure,
SvgGemini,
SvgLitellm,
SvgLmStudio,
SvgMicrosoft,
SvgMistral,
SvgDeepseek,
SvgQwen,
SvgGoogle,
} from "@opal/logos";
import { ZAIIcon } from "@/components/icons/icons";
import { LLMProviderFormProps, LLMProviderName } from "@/interfaces/llm";
import type { LLMProviderView } from "@/interfaces/llm";
import OpenAIModal from "@/sections/modals/llmConfig/OpenAIModal";
import AnthropicModal from "@/sections/modals/llmConfig/AnthropicModal";
import OllamaModal from "@/sections/modals/llmConfig/OllamaModal";
import AzureModal from "@/sections/modals/llmConfig/AzureModal";
import BedrockModal from "@/sections/modals/llmConfig/BedrockModal";
import VertexAIModal from "@/sections/modals/llmConfig/VertexAIModal";
import OpenRouterModal from "@/sections/modals/llmConfig/OpenRouterModal";
import CustomModal from "@/sections/modals/llmConfig/CustomModal";
import LMStudioModal from "@/sections/modals/llmConfig/LMStudioModal";
import LiteLLMProxyModal from "@/sections/modals/llmConfig/LiteLLMProxyModal";
import BifrostModal from "@/sections/modals/llmConfig/BifrostModal";
import OpenAICompatibleModal from "@/sections/modals/llmConfig/OpenAICompatibleModal";
// ─── Text (LLM) providers ────────────────────────────────────────────────────
export interface ProviderEntry {
icon: IconFunctionComponent;
productName: string;
companyName: string;
Modal: React.ComponentType<LLMProviderFormProps>;
}
const PROVIDERS: Record<string, ProviderEntry> = {
[LLMProviderName.OPENAI]: {
icon: SvgOpenai,
productName: "GPT",
companyName: "OpenAI",
Modal: OpenAIModal,
},
[LLMProviderName.ANTHROPIC]: {
icon: SvgClaude,
productName: "Claude",
companyName: "Anthropic",
Modal: AnthropicModal,
},
[LLMProviderName.VERTEX_AI]: {
icon: SvgGemini,
productName: "Gemini",
companyName: "Google Cloud Vertex AI",
Modal: VertexAIModal,
},
[LLMProviderName.BEDROCK]: {
icon: SvgAws,
productName: "Amazon Bedrock",
companyName: "AWS",
Modal: BedrockModal,
},
[LLMProviderName.AZURE]: {
icon: SvgAzure,
productName: "Azure OpenAI",
companyName: "Microsoft Azure",
Modal: AzureModal,
},
[LLMProviderName.LITELLM]: {
icon: SvgLitellm,
productName: "LiteLLM",
companyName: "LiteLLM",
Modal: CustomModal,
},
[LLMProviderName.LITELLM_PROXY]: {
icon: SvgLitellm,
productName: "LiteLLM Proxy",
companyName: "LiteLLM Proxy",
Modal: LiteLLMProxyModal,
},
[LLMProviderName.OLLAMA_CHAT]: {
icon: SvgOllama,
productName: "Ollama",
companyName: "Ollama",
Modal: OllamaModal,
},
[LLMProviderName.OPENROUTER]: {
icon: SvgOpenrouter,
productName: "OpenRouter",
companyName: "OpenRouter",
Modal: OpenRouterModal,
},
[LLMProviderName.LM_STUDIO]: {
icon: SvgLmStudio,
productName: "LM Studio",
companyName: "LM Studio",
Modal: LMStudioModal,
},
[LLMProviderName.BIFROST]: {
icon: SvgBifrost,
productName: "Bifrost",
companyName: "Bifrost",
Modal: BifrostModal,
},
[LLMProviderName.OPENAI_COMPATIBLE]: {
icon: SvgPlug,
productName: "OpenAI-Compatible",
companyName: "OpenAI-Compatible",
Modal: OpenAICompatibleModal,
},
[LLMProviderName.CUSTOM]: {
icon: SvgServer,
productName: "Custom Models",
companyName: "models from other LiteLLM-compatible providers",
Modal: CustomModal,
},
};
const DEFAULT_ENTRY: ProviderEntry = {
icon: SvgCpu,
productName: "",
companyName: "",
Modal: CustomModal,
};
// Providers that don't use custom_config themselves — if custom_config is
// present it means the provider was originally created via CustomModal.
const CUSTOM_CONFIG_OVERRIDES = new Set<string>([
LLMProviderName.OPENAI,
LLMProviderName.ANTHROPIC,
LLMProviderName.AZURE,
LLMProviderName.OPENROUTER,
]);
export function getProvider(
providerName: string,
existingProvider?: LLMProviderView
): ProviderEntry {
const entry = PROVIDERS[providerName] ?? {
...DEFAULT_ENTRY,
productName: providerName,
companyName: providerName,
};
if (
existingProvider?.custom_config != null &&
CUSTOM_CONFIG_OVERRIDES.has(providerName)
) {
return { ...entry, Modal: CustomModal };
}
return entry;
}
// ─── Aggregator providers ────────────────────────────────────────────────────
// Providers that host models from multiple vendors (e.g. Bedrock hosts Claude,
// Llama, etc.) Used by the model-icon resolver to prioritise vendor icons.
export const AGGREGATOR_PROVIDERS = new Set([
LLMProviderName.BEDROCK,
"bedrock_converse",
LLMProviderName.OPENROUTER,
LLMProviderName.OLLAMA_CHAT,
LLMProviderName.LM_STUDIO,
LLMProviderName.LITELLM_PROXY,
LLMProviderName.BIFROST,
LLMProviderName.OPENAI_COMPATIBLE,
LLMProviderName.VERTEX_AI,
]);
// ─── Model-aware icon resolver ───────────────────────────────────────────────
const MODEL_ICON_MAP: Record<string, IconFunctionComponent> = {
[LLMProviderName.OPENAI]: SvgOpenai,
[LLMProviderName.ANTHROPIC]: SvgClaude,
[LLMProviderName.OLLAMA_CHAT]: SvgOllama,
[LLMProviderName.LM_STUDIO]: SvgLmStudio,
[LLMProviderName.OPENROUTER]: SvgOpenrouter,
[LLMProviderName.VERTEX_AI]: SvgGemini,
[LLMProviderName.BEDROCK]: SvgAws,
[LLMProviderName.LITELLM_PROXY]: SvgLitellm,
[LLMProviderName.BIFROST]: SvgBifrost,
[LLMProviderName.OPENAI_COMPATIBLE]: SvgPlug,
amazon: SvgAws,
phi: SvgMicrosoft,
mistral: SvgMistral,
ministral: SvgMistral,
llama: SvgCpu,
ollama: SvgOllama,
gemini: SvgGemini,
deepseek: SvgDeepseek,
claude: SvgClaude,
azure: SvgAzure,
microsoft: SvgMicrosoft,
meta: SvgCpu,
google: SvgGoogle,
qwen: SvgQwen,
qwq: SvgQwen,
zai: ZAIIcon,
bedrock_converse: SvgAws,
};
/**
* Model-aware icon resolver that checks both provider name and model name
* to pick the most specific icon (e.g. Claude icon for a Bedrock Claude model).
*/
export function getModelIcon(
providerName: string,
modelName?: string
): IconFunctionComponent {
const lowerProviderName = providerName.toLowerCase();
// For aggregator providers, prioritise showing the vendor icon based on model name
if (AGGREGATOR_PROVIDERS.has(lowerProviderName) && modelName) {
const lowerModelName = modelName.toLowerCase();
for (const [key, icon] of Object.entries(MODEL_ICON_MAP)) {
if (lowerModelName.includes(key)) {
return icon;
}
}
}
// Check if provider name directly matches an icon
if (lowerProviderName in MODEL_ICON_MAP) {
const icon = MODEL_ICON_MAP[lowerProviderName];
if (icon) {
return icon;
}
}
// For non-aggregator providers, check if model name contains any of the keys
if (modelName) {
const lowerModelName = modelName.toLowerCase();
for (const [key, icon] of Object.entries(MODEL_ICON_MAP)) {
if (lowerModelName.includes(key)) {
return icon;
}
}
}
// Fallback to CPU icon if no matches
return SvgCpu;
}

View File

@@ -0,0 +1,176 @@
import type { IconFunctionComponent } from "@opal/types";
import { SvgCpu, SvgPlug, SvgServer } from "@opal/icons";
import {
SvgBifrost,
SvgOpenai,
SvgClaude,
SvgOllama,
SvgAws,
SvgOpenrouter,
SvgAzure,
SvgGemini,
SvgLitellm,
SvgLmStudio,
SvgMicrosoft,
SvgMistral,
SvgDeepseek,
SvgQwen,
SvgGoogle,
} from "@opal/logos";
import { ZAIIcon } from "@/components/icons/icons";
import { LLMProviderName } from "@/interfaces/llm";
export const AGGREGATOR_PROVIDERS = new Set([
LLMProviderName.BEDROCK,
"bedrock_converse",
LLMProviderName.OPENROUTER,
LLMProviderName.OLLAMA_CHAT,
LLMProviderName.LM_STUDIO,
LLMProviderName.LITELLM_PROXY,
LLMProviderName.BIFROST,
LLMProviderName.OPENAI_COMPATIBLE,
LLMProviderName.VERTEX_AI,
]);
const PROVIDER_ICONS: Record<string, IconFunctionComponent> = {
[LLMProviderName.OPENAI]: SvgOpenai,
[LLMProviderName.ANTHROPIC]: SvgClaude,
[LLMProviderName.VERTEX_AI]: SvgGemini,
[LLMProviderName.BEDROCK]: SvgAws,
[LLMProviderName.AZURE]: SvgAzure,
[LLMProviderName.LITELLM]: SvgLitellm,
[LLMProviderName.LITELLM_PROXY]: SvgLitellm,
[LLMProviderName.OLLAMA_CHAT]: SvgOllama,
[LLMProviderName.OPENROUTER]: SvgOpenrouter,
[LLMProviderName.LM_STUDIO]: SvgLmStudio,
[LLMProviderName.BIFROST]: SvgBifrost,
[LLMProviderName.OPENAI_COMPATIBLE]: SvgPlug,
// fallback
[LLMProviderName.CUSTOM]: SvgServer,
};
const PROVIDER_PRODUCT_NAMES: Record<string, string> = {
[LLMProviderName.OPENAI]: "GPT",
[LLMProviderName.ANTHROPIC]: "Claude",
[LLMProviderName.VERTEX_AI]: "Gemini",
[LLMProviderName.BEDROCK]: "Amazon Bedrock",
[LLMProviderName.AZURE]: "Azure OpenAI",
[LLMProviderName.LITELLM]: "LiteLLM",
[LLMProviderName.LITELLM_PROXY]: "LiteLLM Proxy",
[LLMProviderName.OLLAMA_CHAT]: "Ollama",
[LLMProviderName.OPENROUTER]: "OpenRouter",
[LLMProviderName.LM_STUDIO]: "LM Studio",
[LLMProviderName.BIFROST]: "Bifrost",
[LLMProviderName.OPENAI_COMPATIBLE]: "OpenAI-Compatible",
// fallback
[LLMProviderName.CUSTOM]: "Custom Models",
};
const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
[LLMProviderName.OPENAI]: "OpenAI",
[LLMProviderName.ANTHROPIC]: "Anthropic",
[LLMProviderName.VERTEX_AI]: "Google Cloud Vertex AI",
[LLMProviderName.BEDROCK]: "AWS",
[LLMProviderName.AZURE]: "Microsoft Azure",
[LLMProviderName.LITELLM]: "LiteLLM",
[LLMProviderName.LITELLM_PROXY]: "LiteLLM Proxy",
[LLMProviderName.OLLAMA_CHAT]: "Ollama",
[LLMProviderName.OPENROUTER]: "OpenRouter",
[LLMProviderName.LM_STUDIO]: "LM Studio",
[LLMProviderName.BIFROST]: "Bifrost",
[LLMProviderName.OPENAI_COMPATIBLE]: "OpenAI-Compatible",
// fallback
[LLMProviderName.CUSTOM]: "models from other LiteLLM-compatible providers",
};
export function getProviderProductName(providerName: string): string {
return PROVIDER_PRODUCT_NAMES[providerName] ?? providerName;
}
export function getProviderDisplayName(providerName: string): string {
return PROVIDER_DISPLAY_NAMES[providerName] ?? providerName;
}
export function getProviderIcon(providerName: string): IconFunctionComponent {
return PROVIDER_ICONS[providerName] ?? SvgCpu;
}
// ---------------------------------------------------------------------------
// Model-aware icon resolver (legacy icon set)
// ---------------------------------------------------------------------------
const MODEL_ICON_MAP: Record<string, IconFunctionComponent> = {
[LLMProviderName.OPENAI]: SvgOpenai,
[LLMProviderName.ANTHROPIC]: SvgClaude,
[LLMProviderName.OLLAMA_CHAT]: SvgOllama,
[LLMProviderName.LM_STUDIO]: SvgLmStudio,
[LLMProviderName.OPENROUTER]: SvgOpenrouter,
[LLMProviderName.VERTEX_AI]: SvgGemini,
[LLMProviderName.BEDROCK]: SvgAws,
[LLMProviderName.LITELLM_PROXY]: SvgLitellm,
[LLMProviderName.BIFROST]: SvgBifrost,
[LLMProviderName.OPENAI_COMPATIBLE]: SvgPlug,
amazon: SvgAws,
phi: SvgMicrosoft,
mistral: SvgMistral,
ministral: SvgMistral,
llama: SvgCpu,
ollama: SvgOllama,
gemini: SvgGemini,
deepseek: SvgDeepseek,
claude: SvgClaude,
azure: SvgAzure,
microsoft: SvgMicrosoft,
meta: SvgCpu,
google: SvgGoogle,
qwen: SvgQwen,
qwq: SvgQwen,
zai: ZAIIcon,
bedrock_converse: SvgAws,
};
/**
* Model-aware icon resolver that checks both provider name and model name
* to pick the most specific icon (e.g. Claude icon for a Bedrock Claude model).
*/
export const getModelIcon = (
providerName: string,
modelName?: string
): IconFunctionComponent => {
const lowerProviderName = providerName.toLowerCase();
// For aggregator providers, prioritise showing the vendor icon based on model name
if (AGGREGATOR_PROVIDERS.has(lowerProviderName) && modelName) {
const lowerModelName = modelName.toLowerCase();
for (const [key, icon] of Object.entries(MODEL_ICON_MAP)) {
if (lowerModelName.includes(key)) {
return icon;
}
}
}
// Check if provider name directly matches an icon
if (lowerProviderName in MODEL_ICON_MAP) {
const icon = MODEL_ICON_MAP[lowerProviderName];
if (icon) {
return icon;
}
}
// For non-aggregator providers, check if model name contains any of the keys
if (modelName) {
const lowerModelName = modelName.toLowerCase();
for (const [key, icon] of Object.entries(MODEL_ICON_MAP)) {
if (lowerModelName.includes(key)) {
return icon;
}
}
}
// Fallback to CPU icon if no matches
return SvgCpu;
};

View File

@@ -44,7 +44,7 @@ export function getFinalLLM(
return [provider, model];
}
export function getProviderOverrideForPersona(
export function getLLMProviderOverrideForPersona(
liveAgent: MinimalPersonaSnapshot,
llmProviders: LLMProviderDescriptor[]
): LlmDescriptor | null {
@@ -144,7 +144,7 @@ export function getDisplayName(
agent: MinimalPersonaSnapshot,
llmProviders: LLMProviderDescriptor[]
): string | undefined {
const llmDescriptor = getProviderOverrideForPersona(
const llmDescriptor = getLLMProviderOverrideForPersona(
agent,
llmProviders ?? []
);

View File

@@ -1,7 +1,6 @@
"use client";
import { useEffect, useRef, useState } from "react";
import copy from "copy-to-clipboard";
import { Button, ButtonProps } from "@opal/components";
import { SvgAlertTriangle, SvgCheck, SvgCopy } from "@opal/icons";
@@ -41,19 +40,26 @@ export default function CopyIconButton({
}
try {
if (navigator.clipboard && getHtmlContent) {
// Check if Clipboard API is available
if (!navigator.clipboard) {
throw new Error("Clipboard API not available");
}
// If HTML content getter is provided, copy both HTML and plain text
if (getHtmlContent) {
const htmlContent = getHtmlContent();
const clipboardItem = new ClipboardItem({
"text/html": new Blob([htmlContent], { type: "text/html" }),
"text/plain": new Blob([text], { type: "text/plain" }),
});
await navigator.clipboard.write([clipboardItem]);
} else if (navigator.clipboard) {
}
// Default: plain text only
else {
await navigator.clipboard.writeText(text);
} else if (!copy(text)) {
throw new Error("copy-to-clipboard returned false");
}
// Show "copied" state
setCopyState("copied");
} catch (err) {
console.error("Failed to copy:", err);

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