Compare commits

..

4 Commits

25 changed files with 812 additions and 1661 deletions

View File

@@ -171,13 +171,6 @@ repos:
pass_filenames: false
files: ^web/package(-lock)?\.json$
- id: compose-variants-check
name: Check docker-compose variants are up to date
entry: uv run deployment/docker_compose/generate_compose_variants.py --check
language: system
pass_filenames: false
files: ^deployment/docker_compose/(docker-compose\.yml|generate_compose_variants\.py|headless/docker-compose\.yml)$
# Uses tsgo (TypeScript's native Go compiler) for ~10x faster type checking.
# This is a preview package - if it breaks:
# 1. Try updating: cd web && npm update @typescript/native-preview

View File

@@ -1,4 +1,4 @@
from typing import Any, Literal
from typing import Any
from onyx.db.engine.iam_auth import get_iam_auth_token
from onyx.configs.app_configs import USE_IAM_AUTH
from onyx.configs.app_configs import POSTGRES_HOST
@@ -19,7 +19,6 @@ from logging.config import fileConfig
from alembic import context
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy.sql.schema import SchemaItem
from onyx.configs.constants import SSL_CERT_FILE
from shared_configs.configs import (
MULTI_TENANT,
@@ -45,8 +44,6 @@ if config.config_file_name is not None and config.attributes.get(
target_metadata = [Base.metadata, ResultModelBase.metadata]
EXCLUDE_TABLES = {"kombu_queue", "kombu_message"}
logger = logging.getLogger(__name__)
ssl_context: ssl.SSLContext | None = None
@@ -56,25 +53,6 @@ if USE_IAM_AUTH:
ssl_context = ssl.create_default_context(cafile=SSL_CERT_FILE)
def include_object(
object: SchemaItem, # noqa: ARG001
name: str | None,
type_: Literal[
"schema",
"table",
"column",
"index",
"unique_constraint",
"foreign_key_constraint",
],
reflected: bool, # noqa: ARG001
compare_to: SchemaItem | None, # noqa: ARG001
) -> bool:
if type_ == "table" and name in EXCLUDE_TABLES:
return False
return True
def filter_tenants_by_range(
tenant_ids: list[str], start_range: int | None = None, end_range: int | None = None
) -> list[str]:
@@ -231,7 +209,6 @@ def do_run_migrations(
context.configure(
connection=connection,
target_metadata=target_metadata, # type: ignore
include_object=include_object,
version_table_schema=schema_name,
include_schemas=True,
compare_type=True,
@@ -405,7 +382,6 @@ def run_migrations_offline() -> None:
url=url,
target_metadata=target_metadata, # type: ignore
literal_binds=True,
include_object=include_object,
version_table_schema=schema,
include_schemas=True,
script_location=config.get_main_option("script_location"),
@@ -447,7 +423,6 @@ def run_migrations_offline() -> None:
url=url,
target_metadata=target_metadata, # type: ignore
literal_binds=True,
include_object=include_object,
version_table_schema=schema,
include_schemas=True,
script_location=config.get_main_option("script_location"),
@@ -490,7 +465,6 @@ def run_migrations_online() -> None:
context.configure(
connection=connection,
target_metadata=target_metadata, # type: ignore
include_object=include_object,
version_table_schema=schema_name,
include_schemas=True,
compare_type=True,

View File

@@ -1,11 +1,9 @@
import asyncio
from logging.config import fileConfig
from typing import Literal
from sqlalchemy import pool
from sqlalchemy.engine import Connection
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy.schema import SchemaItem
from alembic import context
from onyx.db.engine.sql_engine import build_connection_string
@@ -35,27 +33,6 @@ target_metadata = [PublicBase.metadata]
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
EXCLUDE_TABLES = {"kombu_queue", "kombu_message"}
def include_object(
object: SchemaItem, # noqa: ARG001
name: str | None,
type_: Literal[
"schema",
"table",
"column",
"index",
"unique_constraint",
"foreign_key_constraint",
],
reflected: bool, # noqa: ARG001
compare_to: SchemaItem | None, # noqa: ARG001
) -> bool:
if type_ == "table" and name in EXCLUDE_TABLES:
return False
return True
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
@@ -85,7 +62,6 @@ def do_run_migrations(connection: Connection) -> None:
context.configure(
connection=connection,
target_metadata=target_metadata, # type: ignore[arg-type]
include_object=include_object,
)
with context.begin_transaction():

View File

@@ -56,7 +56,6 @@ Then it cycles through its tasks as scheduled by Celery Beat:
| `check_for_user_file_processing` | 20s | Checks for user uploads → dispatches to `USER_FILE_PROCESSING` queue |
| `check_for_checkpoint_cleanup` | 1h | Cleans up old indexing checkpoints |
| `check_for_index_attempt_cleanup` | 30m | Cleans up old index attempts |
| `kombu_message_cleanup_task` | periodic | Cleans orphaned Kombu messages from DB (Kombu being the messaging framework used by Celery) |
| `celery_beat_heartbeat` | 1m | Heartbeat for Beat watchdog |
Watchdog is a separate Python process managed by supervisord which runs alongside celery workers. It checks the ONYX_CELERY_BEAT_HEARTBEAT_KEY in

View File

@@ -317,7 +317,6 @@ celery_app.autodiscover_tasks(
"onyx.background.celery.tasks.docprocessing",
"onyx.background.celery.tasks.evals",
"onyx.background.celery.tasks.hierarchyfetching",
"onyx.background.celery.tasks.periodic",
"onyx.background.celery.tasks.pruning",
"onyx.background.celery.tasks.shared",
"onyx.background.celery.tasks.vespa",

View File

@@ -1,138 +0,0 @@
#####
# Periodic Tasks
#####
import json
from typing import Any
from celery import shared_task
from celery.contrib.abortable import AbortableTask # type: ignore
from celery.exceptions import TaskRevokedError
from sqlalchemy import inspect
from sqlalchemy import text
from sqlalchemy.orm import Session
from onyx.background.celery.apps.app_base import task_logger
from onyx.configs.app_configs import JOB_TIMEOUT
from onyx.configs.constants import OnyxCeleryTask
from onyx.configs.constants import PostgresAdvisoryLocks
from onyx.db.engine.sql_engine import get_session_with_current_tenant
@shared_task(
name=OnyxCeleryTask.KOMBU_MESSAGE_CLEANUP_TASK,
soft_time_limit=JOB_TIMEOUT,
bind=True,
base=AbortableTask,
)
def kombu_message_cleanup_task(self: Any, tenant_id: str) -> int: # noqa: ARG001
"""Runs periodically to clean up the kombu_message table"""
# we will select messages older than this amount to clean up
KOMBU_MESSAGE_CLEANUP_AGE = 7 # days
KOMBU_MESSAGE_CLEANUP_PAGE_LIMIT = 1000
ctx = {}
ctx["last_processed_id"] = 0
ctx["deleted"] = 0
ctx["cleanup_age"] = KOMBU_MESSAGE_CLEANUP_AGE
ctx["page_limit"] = KOMBU_MESSAGE_CLEANUP_PAGE_LIMIT
with get_session_with_current_tenant() as db_session:
# Exit the task if we can't take the advisory lock
result = db_session.execute(
text("SELECT pg_try_advisory_lock(:id)"),
{"id": PostgresAdvisoryLocks.KOMBU_MESSAGE_CLEANUP_LOCK_ID.value},
).scalar()
if not result:
return 0
while True:
if self.is_aborted():
raise TaskRevokedError("kombu_message_cleanup_task was aborted.")
b = kombu_message_cleanup_task_helper(ctx, db_session)
if not b:
break
db_session.commit()
if ctx["deleted"] > 0:
task_logger.info(
f"Deleted {ctx['deleted']} orphaned messages from kombu_message."
)
return ctx["deleted"]
def kombu_message_cleanup_task_helper(ctx: dict, db_session: Session) -> bool:
"""
Helper function to clean up old messages from the `kombu_message` table that are no longer relevant.
This function retrieves messages from the `kombu_message` table that are no longer visible and
older than a specified interval. It checks if the corresponding task_id exists in the
`celery_taskmeta` table. If the task_id does not exist, the message is deleted.
Args:
ctx (dict): A context dictionary containing configuration parameters such as:
- 'cleanup_age' (int): The age in days after which messages are considered old.
- 'page_limit' (int): The maximum number of messages to process in one batch.
- 'last_processed_id' (int): The ID of the last processed message to handle pagination.
- 'deleted' (int): A counter to track the number of deleted messages.
db_session (Session): The SQLAlchemy database session for executing queries.
Returns:
bool: Returns True if there are more rows to process, False if not.
"""
inspector = inspect(db_session.bind)
if not inspector:
return False
# With the move to redis as celery's broker and backend, kombu tables may not even exist.
# We can fail silently.
if not inspector.has_table("kombu_message"):
return False
query = text(
"""
SELECT id, timestamp, payload
FROM kombu_message WHERE visible = 'false'
AND timestamp < CURRENT_TIMESTAMP - INTERVAL :interval_days
AND id > :last_processed_id
ORDER BY id
LIMIT :page_limit
"""
)
kombu_messages = db_session.execute(
query,
{
"interval_days": f"{ctx['cleanup_age']} days",
"page_limit": ctx["page_limit"],
"last_processed_id": ctx["last_processed_id"],
},
).fetchall()
if len(kombu_messages) == 0:
return False
for msg in kombu_messages:
payload = json.loads(msg[2])
task_id = payload["headers"]["id"]
# Check if task_id exists in celery_taskmeta
task_exists = db_session.execute(
text("SELECT 1 FROM celery_taskmeta WHERE task_id = :task_id"),
{"task_id": task_id},
).fetchone()
# If task_id does not exist, delete the message
if not task_exists:
result = db_session.execute(
text("DELETE FROM kombu_message WHERE id = :message_id"),
{"message_id": msg[0]},
)
if result.rowcount > 0: # type: ignore
ctx["deleted"] += 1
ctx["last_processed_id"] = msg[0]
return True

View File

@@ -12,6 +12,11 @@ SLACK_USER_TOKEN_PREFIX = "xoxp-"
SLACK_BOT_TOKEN_PREFIX = "xoxb-"
ONYX_EMAILABLE_LOGO_MAX_DIM = 512
# The mask_string() function in encryption.py uses "•" (U+2022 BULLET) to mask secrets.
MASK_CREDENTIAL_CHAR = "\u2022"
# Pattern produced by mask_string for strings >= 14 chars: "abcd...wxyz" (exactly 11 chars)
MASK_CREDENTIAL_LONG_RE = re.compile(r"^.{4}\.{3}.{4}$")
SOURCE_TYPE = "source_type"
# stored in the `metadata` of a chunk. Used to signify that this chunk should
# not be used for QA. For example, Google Drive file types which can't be parsed
@@ -391,10 +396,6 @@ class MilestoneRecordType(str, Enum):
REQUESTED_CONNECTOR = "requested_connector"
class PostgresAdvisoryLocks(Enum):
KOMBU_MESSAGE_CLEANUP_LOCK_ID = auto()
class OnyxCeleryQueues:
# "celery" is the default queue defined by celery and also the queue
# we are running in the primary worker to run system tasks
@@ -577,7 +578,6 @@ class OnyxCeleryTask:
MONITOR_PROCESS_MEMORY = "monitor_process_memory"
CELERY_BEAT_HEARTBEAT = "celery_beat_heartbeat"
KOMBU_MESSAGE_CLEANUP_TASK = "kombu_message_cleanup_task"
CONNECTOR_PERMISSION_SYNC_GENERATOR_TASK = (
"connector_permission_sync_generator_task"
)

View File

@@ -8,6 +8,8 @@ from sqlalchemy.orm import selectinload
from sqlalchemy.orm import Session
from onyx.configs.constants import FederatedConnectorSource
from onyx.configs.constants import MASK_CREDENTIAL_CHAR
from onyx.configs.constants import MASK_CREDENTIAL_LONG_RE
from onyx.db.engine.sql_engine import get_session_with_current_tenant
from onyx.db.models import DocumentSet
from onyx.db.models import FederatedConnector
@@ -45,6 +47,23 @@ def fetch_all_federated_connectors_parallel() -> list[FederatedConnector]:
return fetch_all_federated_connectors(db_session)
def _reject_masked_credentials(credentials: dict[str, Any]) -> None:
"""Raise if any credential string value contains mask placeholder characters.
mask_string() has two output formats:
- Short strings (< 14 chars): "••••••••••••" (U+2022 BULLET)
- Long strings (>= 14 chars): "abcd...wxyz" (first4 + "..." + last4)
Both must be rejected.
"""
for key, val in credentials.items():
if isinstance(val, str) and (
MASK_CREDENTIAL_CHAR in val or MASK_CREDENTIAL_LONG_RE.match(val)
):
raise ValueError(
f"Credential field '{key}' contains masked placeholder characters. Please provide the actual credential value."
)
def validate_federated_connector_credentials(
source: FederatedConnectorSource,
credentials: dict[str, Any],
@@ -66,6 +85,8 @@ def create_federated_connector(
config: dict[str, Any] | None = None,
) -> FederatedConnector:
"""Create a new federated connector with credential and config validation."""
_reject_masked_credentials(credentials)
# Validate credentials before creating
if not validate_federated_connector_credentials(source, credentials):
raise ValueError(
@@ -277,6 +298,8 @@ def update_federated_connector(
)
if credentials is not None:
_reject_masked_credentials(credentials)
# Validate credentials before updating
if not validate_federated_connector_credentials(
federated_connector.source, credentials

View File

@@ -0,0 +1,58 @@
import pytest
from onyx.configs.constants import MASK_CREDENTIAL_CHAR
from onyx.db.federated import _reject_masked_credentials
class TestRejectMaskedCredentials:
"""Verify that masked credential values are never accepted for DB writes.
mask_string() has two output formats:
- Short strings (< 14 chars): "••••••••••••" (U+2022 BULLET)
- Long strings (>= 14 chars): "abcd...wxyz" (first4 + "..." + last4)
_reject_masked_credentials must catch both.
"""
def test_rejects_fully_masked_value(self) -> None:
masked = MASK_CREDENTIAL_CHAR * 12 # "••••••••••••"
with pytest.raises(ValueError, match="masked placeholder"):
_reject_masked_credentials({"client_id": masked})
def test_rejects_long_string_masked_value(self) -> None:
"""mask_string returns 'first4...last4' for long strings — the real
format used for OAuth credentials like client_id and client_secret."""
with pytest.raises(ValueError, match="masked placeholder"):
_reject_masked_credentials({"client_id": "1234...7890"})
def test_rejects_when_any_field_is_masked(self) -> None:
"""Even if client_id is real, a masked client_secret must be caught."""
with pytest.raises(ValueError, match="client_secret"):
_reject_masked_credentials(
{
"client_id": "1234567890.1234567890",
"client_secret": MASK_CREDENTIAL_CHAR * 12,
}
)
def test_accepts_real_credentials(self) -> None:
# Should not raise
_reject_masked_credentials(
{
"client_id": "1234567890.1234567890",
"client_secret": "test_client_secret_value",
}
)
def test_accepts_empty_dict(self) -> None:
# Should not raise — empty credentials are handled elsewhere
_reject_masked_credentials({})
def test_ignores_non_string_values(self) -> None:
# Non-string values (None, bool, int) should pass through
_reject_masked_credentials(
{
"client_id": "real_value",
"redirect_uri": None,
"some_flag": True,
}
)

View File

@@ -1,482 +0,0 @@
# /// script
# requires-python = ">=3.11"
# dependencies = ["ruamel.yaml>=0.18"]
# ///
"""Generate docker-compose variant files from the main docker-compose.yml.
Each variant defines which services to remove, which to add, which
commented-out blocks to strip, and where to write the output. The main
docker-compose.yml is the single source of truth — variants are derived
from it so shared service definitions never drift.
Usage:
uv run generate_compose_variants.py # generate all variants
uv run generate_compose_variants.py headless # generate one variant
uv run generate_compose_variants.py --check # check all (CI / pre-commit)
uv run generate_compose_variants.py --check headless # check one
"""
from __future__ import annotations
import argparse
import difflib
import re
import sys
from dataclasses import dataclass
from dataclasses import field
from io import StringIO
from pathlib import Path
from ruamel.yaml import YAML
from ruamel.yaml.comments import CommentedMap
# ---------------------------------------------------------------------------
# Variant configuration
# ---------------------------------------------------------------------------
@dataclass
class Variant:
"""Describes how to derive a compose file from the main docker-compose.yml."""
# Variant name (used as CLI argument).
name: str
# Subdirectory (relative to SCRIPT_DIR) for the output file.
output_dir: str
# Header comment placed at the top of the generated file.
header: str
# Service names to remove from the base compose file.
remove_services: set[str] = field(default_factory=set)
# YAML strings defining services to add. Each string is a single-service
# YAML document (e.g. "cli_server:\n image: ...").
add_services_yaml: list[str] = field(default_factory=list)
# Volume names to add to the top-level volumes section.
add_volumes: list[str] = field(default_factory=list)
# Commented-out service block names to strip (regex-matched).
strip_commented_blocks: list[str] = field(default_factory=list)
# Environment overrides to apply to existing services.
# Maps service_name -> {env_var: value}. Added/overridden in the
# service's `environment` list.
env_overrides: dict[str, dict[str, str]] = field(default_factory=dict)
# Volume names to remove from the top-level volumes section.
remove_volumes: set[str] = field(default_factory=set)
VARIANTS: dict[str, Variant] = {}
def register(v: Variant) -> Variant:
VARIANTS[v.name] = v
return v
# -- headless ---------------------------------------------------------------
register(
Variant(
name="headless",
output_dir="headless",
header="""\
# =============================================================================
# ONYX HEADLESS DOCKER COMPOSE (AUTO-GENERATED)
# =============================================================================
# This file is generated by generate_compose_variants.py from docker-compose.yml.
# DO NOT EDIT THIS FILE DIRECTLY — your changes will be overwritten.
#
# To regenerate:
# cd deployment/docker_compose
# uv run generate_compose_variants.py headless
#
# Usage:
# cd deployment/docker_compose/headless
# docker compose up -d
#
# Connect via SSH:
# ssh localhost -p 2222
# =============================================================================
""",
remove_services={"web_server", "nginx"},
add_services_yaml=[
"""\
cli_server:
image: ${ONYX_CLI_IMAGE:-onyxdotapp/onyx-cli:${IMAGE_TAG:-latest}}
command: ["serve", "--host", "0.0.0.0", "--port", "2222"]
depends_on:
- api_server
restart: unless-stopped
ports:
- "${ONYX_SSH_PORT:-2222}:2222"
environment:
- ONYX_SERVER_URL=http://api_server:8080
volumes:
- cli_config:/home/onyx/.config
logging:
driver: json-file
options:
max-size: "50m"
max-file: "6"
"""
],
add_volumes=["cli_config"],
strip_commented_blocks=["mcp_server", "certbot"],
)
)
# -- headless-lite ----------------------------------------------------------
register(
Variant(
name="headless-lite",
output_dir="headless-lite",
header="""\
# =============================================================================
# ONYX HEADLESS-LITE DOCKER COMPOSE (AUTO-GENERATED)
# =============================================================================
# This file is generated by generate_compose_variants.py from docker-compose.yml.
# DO NOT EDIT THIS FILE DIRECTLY — your changes will be overwritten.
#
# To regenerate:
# cd deployment/docker_compose
# uv run generate_compose_variants.py headless-lite
#
# Usage:
# cd deployment/docker_compose/headless-lite
# docker compose up -d
#
# Connect via SSH:
# ssh localhost -p 2222
#
# This is a minimal headless deployment: no web UI, no Vespa, no Redis,
# no model servers, no background workers, no OpenSearch, and no MinIO.
# Only PostgreSQL is required. Connectors and RAG search are disabled,
# but core chat (LLM conversations, tools, user file uploads, Projects,
# Agent knowledge, code interpreter) still works.
# =============================================================================
""",
remove_services={
"web_server",
"nginx",
"background",
"cache",
"index",
"indexing_model_server",
"inference_model_server",
"opensearch",
"minio",
},
add_services_yaml=[
"""\
cli_server:
image: ${ONYX_CLI_IMAGE:-onyxdotapp/onyx-cli:${IMAGE_TAG:-latest}}
command: ["serve", "--host", "0.0.0.0", "--port", "2222"]
depends_on:
- api_server
restart: unless-stopped
ports:
- "${ONYX_SSH_PORT:-2222}:2222"
environment:
- ONYX_SERVER_URL=http://api_server:8080
volumes:
- cli_config:/home/onyx/.config
logging:
driver: json-file
options:
max-size: "50m"
max-file: "6"
"""
],
add_volumes=["cli_config"],
remove_volumes={
"vespa_volume",
"model_cache_huggingface",
"indexing_huggingface_model_cache",
"background_logs",
"inference_model_server_logs",
"indexing_model_server_logs",
"opensearch-data",
"minio_data",
},
strip_commented_blocks=["mcp_server", "certbot"],
env_overrides={
"api_server": {
"DISABLE_VECTOR_DB": "true",
"FILE_STORE_BACKEND": "postgres",
"CACHE_BACKEND": "postgres",
"AUTH_BACKEND": "postgres",
},
},
)
)
# ---------------------------------------------------------------------------
# YAML helpers
# ---------------------------------------------------------------------------
SCRIPT_DIR = Path(__file__).resolve().parent
SOURCE = SCRIPT_DIR / "docker-compose.yml"
def load_yaml() -> tuple[YAML, CommentedMap]:
yaml = YAML()
yaml.preserve_quotes = True
yaml.width = 4096 # avoid line wrapping
with open(SOURCE) as f:
data = yaml.load(f)
return yaml, data
# ---------------------------------------------------------------------------
# Service transforms (for standalone output files)
# ---------------------------------------------------------------------------
def strip_build_blocks(services: CommentedMap) -> None:
"""Remove ``build`` keys from all services.
Generated files are intended to be downloaded and used standalone — users
pull pre-built images rather than building from source, so ``build``
blocks are not useful and contain repo-relative paths that won't exist.
"""
for svc in services.values():
if isinstance(svc, CommentedMap) and "build" in svc:
del svc["build"]
# ---------------------------------------------------------------------------
# Text post-processing
# ---------------------------------------------------------------------------
def remove_commented_blocks(text: str, block_names: list[str]) -> str:
"""Remove commented-out service blocks matching *block_names*."""
if not block_names:
return text
pattern = re.compile(
r"^\s*#\s*(" + "|".join(re.escape(n) for n in block_names) + r"):"
)
lines = text.split("\n")
result: list[str] = []
skip = False
blank_run = 0
for line in lines:
if pattern.match(line):
skip = True
# Remove preceding comment/blank lines that introduce the block
while result and (
result[-1].strip().startswith("#") or result[-1].strip() == ""
):
result.pop()
continue
if skip:
stripped = line.strip()
if stripped.startswith("#") or stripped == "":
continue
else:
skip = False
if line.strip() == "":
blank_run += 1
if blank_run > 2:
continue
else:
blank_run = 0
result.append(line)
return "\n".join(result)
def ensure_blank_line_before(text: str, pattern: str) -> str:
"""Ensure there is a blank line before lines matching *pattern*."""
return re.sub(r"(\n)(" + pattern + r")", r"\1\n\2", text)
# ---------------------------------------------------------------------------
# Generation
# ---------------------------------------------------------------------------
def generate(variant: Variant) -> str:
"""Generate the variant's docker-compose.yml content."""
yaml_inst, data = load_yaml()
services = data["services"]
# Remove services
for name in variant.remove_services:
if name in services:
del services[name]
# Clean up depends_on entries that reference removed services
remaining_services = set(services.keys())
for svc_name in list(services):
svc = services[svc_name]
if not isinstance(svc, CommentedMap) or "depends_on" not in svc:
continue
deps = svc["depends_on"]
if isinstance(deps, list):
deps[:] = [d for d in deps if d in remaining_services]
if not deps:
del svc["depends_on"]
elif isinstance(deps, CommentedMap):
for dep_name in list(deps):
if dep_name not in remaining_services:
del deps[dep_name]
if not deps:
del svc["depends_on"]
# Collect names of services we're about to add (skip path adjustment for
# these — their paths are already relative to the output subdirectory).
added_service_names: set[str] = set()
# Add new services
for svc_yaml in variant.add_services_yaml:
helper = YAML()
helper.preserve_quotes = True
parsed = helper.load(svc_yaml)
for svc_name, svc_def in parsed.items():
services[svc_name] = svc_def
added_service_names.add(svc_name)
# Apply environment overrides
for svc_name, overrides in variant.env_overrides.items():
if svc_name not in services:
continue
svc = services[svc_name]
if not isinstance(svc, CommentedMap):
continue
env = svc.get("environment")
if env is None:
env = []
svc["environment"] = env
if isinstance(env, list):
# Remove existing entries for overridden keys, then append
for key in overrides:
env[:] = [
e
for e in env
if not (isinstance(e, str) and e.startswith(key + "="))
]
env.append(f"{key}={overrides[key]}")
# Strip build blocks — generated files are standalone downloads, users
# pull images rather than building from source.
strip_build_blocks(services)
# Add volumes
if variant.add_volumes and "volumes" in data:
for vol_name in variant.add_volumes:
data["volumes"][vol_name] = None
# Remove volumes
if variant.remove_volumes and "volumes" in data:
for vol_name in variant.remove_volumes:
if vol_name in data["volumes"]:
del data["volumes"][vol_name]
# Serialize
buf = StringIO()
yaml_inst.dump(data, buf)
body = buf.getvalue()
# Strip commented-out blocks
body = remove_commented_blocks(body, variant.strip_commented_blocks)
# Strip the original header comment (everything before "name:")
idx = body.find("name:")
if idx > 0:
body = body[idx:]
# Ensure blank lines before added services and the top-level volumes section
for svc_name in added_service_names:
body = ensure_blank_line_before(body, re.escape(f" {svc_name}:"))
body = ensure_blank_line_before(body, r"volumes:\n")
return variant.header + body
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def output_path(variant: Variant) -> Path:
return SCRIPT_DIR / variant.output_dir / "docker-compose.yml"
def run_generate(variant: Variant) -> None:
generated = generate(variant)
out = output_path(variant)
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(generated)
print(f"Generated {out.relative_to(SCRIPT_DIR)}")
def run_check(variant: Variant) -> bool:
"""Return True if up to date."""
generated = generate(variant)
out = output_path(variant)
if not out.exists():
print(
f"ERROR: {out.relative_to(SCRIPT_DIR)} does not exist. "
f"Run without --check to generate it."
)
return False
existing = out.read_text()
if existing == generated:
print(f"OK: {out.relative_to(SCRIPT_DIR)} is up to date.")
return True
diff = difflib.unified_diff(
existing.splitlines(keepends=True),
generated.splitlines(keepends=True),
fromfile=str(out.relative_to(SCRIPT_DIR)),
tofile="(generated)",
)
sys.stdout.writelines(diff)
print(
f"\nERROR: {out.relative_to(SCRIPT_DIR)} is out of date. "
f"Run 'uv run generate_compose_variants.py {variant.name}' to update."
)
return False
def main() -> None:
parser = argparse.ArgumentParser(
description="Generate docker-compose variant files from docker-compose.yml"
)
parser.add_argument(
"variant",
nargs="?",
choices=list(VARIANTS.keys()),
help="Variant to generate (default: all)",
)
parser.add_argument(
"--check",
action="store_true",
help="Check that generated files match existing ones (for CI / pre-commit)",
)
args = parser.parse_args()
targets = [VARIANTS[args.variant]] if args.variant else list(VARIANTS.values())
if args.check:
all_ok = all(run_check(v) for v in targets)
sys.exit(0 if all_ok else 1)
else:
for v in targets:
run_generate(v)
if __name__ == "__main__":
main()

View File

@@ -1,154 +0,0 @@
# =============================================================================
# ONYX HEADLESS-LITE DOCKER COMPOSE (AUTO-GENERATED)
# =============================================================================
# This file is generated by generate_compose_variants.py from docker-compose.yml.
# DO NOT EDIT THIS FILE DIRECTLY — your changes will be overwritten.
#
# To regenerate:
# cd deployment/docker_compose
# uv run generate_compose_variants.py headless-lite
#
# Usage:
# cd deployment/docker_compose/headless-lite
# docker compose up -d
#
# Connect via SSH:
# ssh localhost -p 2222
#
# This is a minimal headless deployment: no web UI, no Vespa, no Redis,
# no model servers, no background workers, no OpenSearch, and no MinIO.
# Only PostgreSQL is required. Connectors and RAG search are disabled,
# but core chat (LLM conversations, tools, user file uploads, Projects,
# Agent knowledge, code interpreter) still works.
# =============================================================================
name: onyx
services:
api_server:
image: ${ONYX_BACKEND_IMAGE:-onyxdotapp/onyx-backend:${IMAGE_TAG:-latest}}
command: >
/bin/sh -c "alembic upgrade head &&
echo \"Starting Onyx Api Server\" &&
uvicorn onyx.main:app --host 0.0.0.0 --port 8080"
# Check env.template and copy to .env for env vars
env_file:
- path: .env
required: false
depends_on:
relational_db:
condition: service_started
restart: unless-stopped
# DEV: To expose ports, either:
# 1. Use docker-compose.dev.yml: docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --wait
# 2. Uncomment the ports below
# ports:
# - "8080:8080"
environment:
# Auth Settings
- AUTH_TYPE=${AUTH_TYPE:-basic}
- POSTGRES_HOST=${POSTGRES_HOST:-relational_db}
- VESPA_HOST=${VESPA_HOST:-index}
- OPENSEARCH_HOST=${OPENSEARCH_HOST:-opensearch}
- OPENSEARCH_ADMIN_PASSWORD=${OPENSEARCH_ADMIN_PASSWORD:-StrongPassword123!}
- ENABLE_OPENSEARCH_INDEXING_FOR_ONYX=${OPENSEARCH_FOR_ONYX_ENABLED:-true}
- REDIS_HOST=${REDIS_HOST:-cache}
- MODEL_SERVER_HOST=${MODEL_SERVER_HOST:-inference_model_server}
- CODE_INTERPRETER_BASE_URL=${CODE_INTERPRETER_BASE_URL:-http://code-interpreter:8000}
- S3_ENDPOINT_URL=${S3_ENDPOINT_URL:-http://minio:9000}
- S3_AWS_ACCESS_KEY_ID=${S3_AWS_ACCESS_KEY_ID:-minioadmin}
- S3_AWS_SECRET_ACCESS_KEY=${S3_AWS_SECRET_ACCESS_KEY:-minioadmin}
- ENABLE_CRAFT=${ENABLE_CRAFT:-false}
- OUTPUTS_TEMPLATE_PATH=${OUTPUTS_TEMPLATE_PATH:-/app/onyx/server/features/build/sandbox/kubernetes/docker/templates/outputs}
- VENV_TEMPLATE_PATH=${VENV_TEMPLATE_PATH:-/app/onyx/server/features/build/sandbox/kubernetes/docker/templates/venv}
- WEB_TEMPLATE_PATH=${WEB_TEMPLATE_PATH:-/app/onyx/server/features/build/sandbox/kubernetes/docker/templates/outputs/web}
- PERSISTENT_DOCUMENT_STORAGE_PATH=${PERSISTENT_DOCUMENT_STORAGE_PATH:-/app/file-system}
- DISABLE_VECTOR_DB=true
- FILE_STORE_BACKEND=postgres
- CACHE_BACKEND=postgres
- AUTH_BACKEND=postgres
extra_hosts:
- "host.docker.internal:host-gateway"
logging:
driver: json-file
options:
max-size: "50m"
max-file: "6"
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/health')"]
interval: 30s
timeout: 20s
retries: 3
start_period: 25s
# Optional, only for debugging purposes
volumes:
- api_server_logs:/var/log/onyx
# Shared volume for persistent document storage (Craft file-system mode)
- file-system:/app/file-system
relational_db:
image: postgres:15.2-alpine
shm_size: 1g
command: -c 'max_connections=250'
env_file:
- path: .env
required: false
restart: unless-stopped
# PRODUCTION: Override the defaults by passing in the environment variables
environment:
- POSTGRES_USER=${POSTGRES_USER:-postgres}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-password}
# DEV: To expose ports, either:
# 1. Use docker-compose.dev.yml: docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --wait
# 2. Uncomment the ports below
# ports:
# - "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres}"]
interval: 10s
timeout: 5s
retries: 5
volumes:
- db_volume:/var/lib/postgresql/data
# This container name cannot have an underscore in it due to Vespa expectations of the URL
code-interpreter:
image: onyxdotapp/code-interpreter:${CODE_INTERPRETER_IMAGE_TAG:-latest}
command: ["bash", "./entrypoint.sh", "code-interpreter-api"]
restart: unless-stopped
env_file:
- path: .env
required: false
# Below is needed for the `docker-out-of-docker` execution mode
# For Linux rootless Docker, set DOCKER_SOCK_PATH=${XDG_RUNTIME_DIR}/docker.sock
user: root
volumes:
- ${DOCKER_SOCK_PATH:-/var/run/docker.sock}:/var/run/docker.sock
cli_server:
image: ${ONYX_CLI_IMAGE:-onyxdotapp/onyx-cli:${IMAGE_TAG:-latest}}
command: ["serve", "--host", "0.0.0.0", "--port", "2222"]
depends_on:
- api_server
restart: unless-stopped
ports:
- "${ONYX_SSH_PORT:-2222}:2222"
environment:
- ONYX_SERVER_URL=http://api_server:8080
volumes:
- cli_config:/home/onyx/.config
logging:
driver: json-file
options:
max-size: "50m"
max-file: "6"
volumes:
# Necessary for persisting data for use
db_volume:
# Logs preserved across container restarts
api_server_logs:
# Shared volume for persistent document storage (Craft file-system mode)
file-system:
cli_config:

View File

@@ -1,422 +0,0 @@
# =============================================================================
# ONYX HEADLESS DOCKER COMPOSE (AUTO-GENERATED)
# =============================================================================
# This file is generated by generate_compose_variants.py from docker-compose.yml.
# DO NOT EDIT THIS FILE DIRECTLY — your changes will be overwritten.
#
# To regenerate:
# cd deployment/docker_compose
# uv run generate_compose_variants.py headless
#
# Usage:
# cd deployment/docker_compose/headless
# docker compose up -d
#
# Connect via SSH:
# ssh localhost -p 2222
# =============================================================================
name: onyx
services:
api_server:
image: ${ONYX_BACKEND_IMAGE:-onyxdotapp/onyx-backend:${IMAGE_TAG:-latest}}
command: >
/bin/sh -c "alembic upgrade head &&
echo \"Starting Onyx Api Server\" &&
uvicorn onyx.main:app --host 0.0.0.0 --port 8080"
# Check env.template and copy to .env for env vars
env_file:
- path: .env
required: false
depends_on:
relational_db:
condition: service_started
index:
condition: service_started
opensearch:
condition: service_started
required: false
cache:
condition: service_started
inference_model_server:
condition: service_started
minio:
condition: service_started
required: false
restart: unless-stopped
# DEV: To expose ports, either:
# 1. Use docker-compose.dev.yml: docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --wait
# 2. Uncomment the ports below
# ports:
# - "8080:8080"
environment:
# Auth Settings
- AUTH_TYPE=${AUTH_TYPE:-basic}
- FILE_STORE_BACKEND=${FILE_STORE_BACKEND:-s3}
- POSTGRES_HOST=${POSTGRES_HOST:-relational_db}
- VESPA_HOST=${VESPA_HOST:-index}
- OPENSEARCH_HOST=${OPENSEARCH_HOST:-opensearch}
- OPENSEARCH_ADMIN_PASSWORD=${OPENSEARCH_ADMIN_PASSWORD:-StrongPassword123!}
- ENABLE_OPENSEARCH_INDEXING_FOR_ONYX=${OPENSEARCH_FOR_ONYX_ENABLED:-true}
- REDIS_HOST=${REDIS_HOST:-cache}
- MODEL_SERVER_HOST=${MODEL_SERVER_HOST:-inference_model_server}
- CODE_INTERPRETER_BASE_URL=${CODE_INTERPRETER_BASE_URL:-http://code-interpreter:8000}
- S3_ENDPOINT_URL=${S3_ENDPOINT_URL:-http://minio:9000}
- S3_AWS_ACCESS_KEY_ID=${S3_AWS_ACCESS_KEY_ID:-minioadmin}
- S3_AWS_SECRET_ACCESS_KEY=${S3_AWS_SECRET_ACCESS_KEY:-minioadmin}
# Onyx Craft configuration (disabled by default, set ENABLE_CRAFT=true in .env to enable)
# Use --include-craft with install script, or manually set in .env file
- ENABLE_CRAFT=${ENABLE_CRAFT:-false}
- OUTPUTS_TEMPLATE_PATH=${OUTPUTS_TEMPLATE_PATH:-/app/onyx/server/features/build/sandbox/kubernetes/docker/templates/outputs}
- VENV_TEMPLATE_PATH=${VENV_TEMPLATE_PATH:-/app/onyx/server/features/build/sandbox/kubernetes/docker/templates/venv}
- WEB_TEMPLATE_PATH=${WEB_TEMPLATE_PATH:-/app/onyx/server/features/build/sandbox/kubernetes/docker/templates/outputs/web}
- PERSISTENT_DOCUMENT_STORAGE_PATH=${PERSISTENT_DOCUMENT_STORAGE_PATH:-/app/file-system}
# PRODUCTION: Uncomment the line below to use if IAM_AUTH is true and you are using iam auth for postgres
# volumes:
# - ./bundle.pem:/app/bundle.pem:ro
extra_hosts:
- "host.docker.internal:host-gateway"
logging:
driver: json-file
options:
max-size: "50m"
max-file: "6"
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8080/health')"]
interval: 30s
timeout: 20s
retries: 3
start_period: 25s
# Optional, only for debugging purposes
volumes:
- api_server_logs:/var/log/onyx
# Shared volume for persistent document storage (Craft file-system mode)
- file-system:/app/file-system
background:
image: ${ONYX_BACKEND_IMAGE:-onyxdotapp/onyx-backend:${IMAGE_TAG:-latest}}
command: >
/bin/sh -c "
if [ -f /app/scripts/setup_craft_templates.sh ]; then
/app/scripts/setup_craft_templates.sh;
fi &&
if [ -f /etc/ssl/certs/custom-ca.crt ]; then
update-ca-certificates;
fi &&
/app/scripts/supervisord_entrypoint.sh"
env_file:
- path: .env
required: false
depends_on:
relational_db:
condition: service_started
index:
condition: service_started
opensearch:
condition: service_started
required: false
cache:
condition: service_started
inference_model_server:
condition: service_started
indexing_model_server:
condition: service_started
restart: unless-stopped
environment:
- FILE_STORE_BACKEND=${FILE_STORE_BACKEND:-s3}
- POSTGRES_HOST=${POSTGRES_HOST:-relational_db}
- VESPA_HOST=${VESPA_HOST:-index}
- OPENSEARCH_HOST=${OPENSEARCH_HOST:-opensearch}
- OPENSEARCH_ADMIN_PASSWORD=${OPENSEARCH_ADMIN_PASSWORD:-StrongPassword123!}
- ENABLE_OPENSEARCH_INDEXING_FOR_ONYX=${OPENSEARCH_FOR_ONYX_ENABLED:-true}
- REDIS_HOST=${REDIS_HOST:-cache}
- MODEL_SERVER_HOST=${MODEL_SERVER_HOST:-inference_model_server}
- INDEXING_MODEL_SERVER_HOST=${INDEXING_MODEL_SERVER_HOST:-indexing_model_server}
- S3_ENDPOINT_URL=${S3_ENDPOINT_URL:-http://minio:9000}
- S3_AWS_ACCESS_KEY_ID=${S3_AWS_ACCESS_KEY_ID:-minioadmin}
- S3_AWS_SECRET_ACCESS_KEY=${S3_AWS_SECRET_ACCESS_KEY:-minioadmin}
- DISCORD_BOT_TOKEN=${DISCORD_BOT_TOKEN:-}
- DISCORD_BOT_INVOKE_CHAR=${DISCORD_BOT_INVOKE_CHAR:-!}
# API Server connection for Discord bot message processing
- API_SERVER_PROTOCOL=${API_SERVER_PROTOCOL:-http}
- API_SERVER_HOST=${API_SERVER_HOST:-api_server}
# Onyx Craft configuration (set up automatically on container startup)
- ENABLE_CRAFT=${ENABLE_CRAFT:-false}
- OUTPUTS_TEMPLATE_PATH=${OUTPUTS_TEMPLATE_PATH:-/app/onyx/server/features/build/sandbox/kubernetes/docker/templates/outputs}
- VENV_TEMPLATE_PATH=${VENV_TEMPLATE_PATH:-/app/onyx/server/features/build/sandbox/kubernetes/docker/templates/venv}
- WEB_TEMPLATE_PATH=${WEB_TEMPLATE_PATH:-/app/onyx/server/features/build/sandbox/kubernetes/docker/templates/outputs/web}
- PERSISTENT_DOCUMENT_STORAGE_PATH=${PERSISTENT_DOCUMENT_STORAGE_PATH:-/app/file-system}
# PRODUCTION: Uncomment the line below to use if IAM_AUTH is true and you are using iam auth for postgres
# volumes:
# - ./bundle.pem:/app/bundle.pem:ro
extra_hosts:
- "host.docker.internal:host-gateway"
# Optional, only for debugging purposes
volumes:
- background_logs:/var/log/onyx
# Shared volume for persistent document storage (Craft file-system mode)
- file-system:/app/file-system
logging:
driver: json-file
options:
max-size: "50m"
max-file: "6"
# PRODUCTION: Uncomment the following lines if you need to include a custom CA certificate
# This section enables the use of a custom CA certificate
# If present, the custom CA certificate is mounted as a volume
# The container checks for its existence and updates the system's CA certificates
# This allows for secure communication with services using custom SSL certificates
# Optional volume mount for CA certificate
# volumes:
# # Maps to the CA_CERT_PATH environment variable in the Dockerfile
# - ${CA_CERT_PATH:-./custom-ca.crt}:/etc/ssl/certs/custom-ca.crt:ro
inference_model_server:
image: ${ONYX_MODEL_SERVER_IMAGE:-onyxdotapp/onyx-model-server:${IMAGE_TAG:-latest}}
command: >
/bin/sh -c "if [ \"${DISABLE_MODEL_SERVER:-}\" = \"True\" ] || [ \"${DISABLE_MODEL_SERVER:-}\" = \"true\" ]; then
echo 'Skipping service...';
exit 0;
else
exec uvicorn model_server.main:app --host 0.0.0.0 --port 9000;
fi"
env_file:
- path: .env
required: false
restart: unless-stopped
volumes:
# Not necessary, this is just to reduce download time during startup
- model_cache_huggingface:/app/.cache/huggingface/
# Optional, only for debugging purposes
- inference_model_server_logs:/var/log/onyx
logging:
driver: json-file
options:
max-size: "50m"
max-file: "6"
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:9000/api/health')"]
interval: 20s
timeout: 5s
retries: 3
indexing_model_server:
image: ${ONYX_MODEL_SERVER_IMAGE:-onyxdotapp/onyx-model-server:${IMAGE_TAG:-latest}}
command: >
/bin/sh -c "if [ \"${DISABLE_MODEL_SERVER:-}\" = \"True\" ] || [ \"${DISABLE_MODEL_SERVER:-}\" = \"true\" ]; then
echo 'Skipping service...';
exit 0;
else
exec uvicorn model_server.main:app --host 0.0.0.0 --port 9000;
fi"
env_file:
- path: .env
required: false
restart: unless-stopped
environment:
- INDEXING_ONLY=True
volumes:
# Not necessary, this is just to reduce download time during startup
- indexing_huggingface_model_cache:/app/.cache/huggingface/
# Optional, only for debugging purposes
- indexing_model_server_logs:/var/log/onyx
logging:
driver: json-file
options:
max-size: "50m"
max-file: "6"
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:9000/api/health')"]
interval: 20s
timeout: 5s
retries: 3
relational_db:
image: postgres:15.2-alpine
shm_size: 1g
command: -c 'max_connections=250'
env_file:
- path: .env
required: false
restart: unless-stopped
# PRODUCTION: Override the defaults by passing in the environment variables
environment:
- POSTGRES_USER=${POSTGRES_USER:-postgres}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-password}
# DEV: To expose ports, either:
# 1. Use docker-compose.dev.yml: docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --wait
# 2. Uncomment the ports below
# ports:
# - "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres}"]
interval: 10s
timeout: 5s
retries: 5
volumes:
- db_volume:/var/lib/postgresql/data
# This container name cannot have an underscore in it due to Vespa expectations of the URL
index:
image: vespaengine/vespa:8.609.39
restart: unless-stopped
env_file:
- path: .env
required: false
environment:
- VESPA_SKIP_UPGRADE_CHECK=${VESPA_SKIP_UPGRADE_CHECK:-true}
# DEV: To expose ports, either:
# 1. Use docker-compose.dev.yml: docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --wait
# 2. Uncomment the ports below
# ports:
# - "19071:19071"
# - "8081:8081"
volumes:
- vespa_volume:/opt/vespa/var
logging:
driver: json-file
options:
max-size: "50m"
max-file: "6"
opensearch:
image: opensearchproject/opensearch:3.4.0
restart: unless-stopped
# Controls whether this service runs. In order to enable it, add
# opensearch-enabled to COMPOSE_PROFILES in the environment for this
# docker-compose.
# NOTE: Now enabled on by default. To explicitly disable this service,
# uncomment this profile and ensure COMPOSE_PROFILES in your env does not
# list the profile, or when running docker compose, include all desired
# service names but this one. Additionally set
# OPENSEARCH_FOR_ONYX_ENABLED=false in your env.
# profiles: ["opensearch-enabled"]
environment:
# We need discovery.type=single-node so that OpenSearch doesn't try
# forming a cluster and waiting for other nodes to become live.
- discovery.type=single-node
- OPENSEARCH_INITIAL_ADMIN_PASSWORD=${OPENSEARCH_ADMIN_PASSWORD:-StrongPassword123!}
# This and the JVM config below come from the example in https://docs.opensearch.org/latest/install-and-configure/install-opensearch/docker/
# We do this to avoid unstable performance from page swaps.
- bootstrap.memory_lock=true # Disable JVM heap memory swapping.
# Java heap should be ~50% of memory limit. For now we assume a limit of
# 4g although in practice the container can request more than this.
# See https://opster.com/guides/opensearch/opensearch-basics/opensearch-heap-size-usage-and-jvm-garbage-collection/
# Xms is the starting size, Xmx is the maximum size. These should be the
# same.
- "OPENSEARCH_JAVA_OPTS=-Xms2g -Xmx2g"
volumes:
- opensearch-data:/usr/share/opensearch/data
# These come from the example in https://docs.opensearch.org/latest/install-and-configure/install-opensearch/docker/
ulimits:
# Similarly to bootstrap.memory_lock, we don't want to impose limits on
# how much memory a process can lock from being swapped.
memlock:
soft: -1 # Set memlock to unlimited (no soft or hard limit).
hard: -1
nofile:
soft: 65536 # Maximum number of open files for the opensearch user - set to at least 65536.
hard: 65536
logging:
driver: json-file
options:
max-size: "50m"
max-file: "6"
cache:
image: redis:7.4-alpine
restart: unless-stopped
# DEV: To expose ports, either:
# 1. Use docker-compose.dev.yml: docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --wait
# 2. Uncomment the ports below
# ports:
# - "6379:6379"
# docker silently mounts /data even without an explicit volume mount, which enables
# persistence. explicitly setting save and appendonly forces ephemeral behavior.
command: redis-server --save "" --appendonly no
env_file:
- path: .env
required: false
# Use tmpfs to prevent creation of anonymous volumes for /data
tmpfs:
- /data
minio:
image: minio/minio:RELEASE.2025-07-23T15-54-02Z-cpuv1
profiles: ["s3-filestore"]
restart: unless-stopped
# DEV: To expose ports, either:
# 1. Use docker-compose.dev.yml: docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --wait
# 2. Uncomment the ports below
# ports:
# - "9004:9000"
# - "9005:9001"
env_file:
- path: .env
required: false
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
# Note: we've seen the default bucket creation logic not work in some cases
MINIO_DEFAULT_BUCKETS: ${S3_FILE_STORE_BUCKET_NAME:-onyx-file-store-bucket}
volumes:
- minio_data:/data
command: server /data --console-address ":9001"
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 30s
timeout: 20s
retries: 3
code-interpreter:
image: onyxdotapp/code-interpreter:${CODE_INTERPRETER_IMAGE_TAG:-latest}
command: ["bash", "./entrypoint.sh", "code-interpreter-api"]
restart: unless-stopped
env_file:
- path: .env
required: false
# Below is needed for the `docker-out-of-docker` execution mode
# For Linux rootless Docker, set DOCKER_SOCK_PATH=${XDG_RUNTIME_DIR}/docker.sock
user: root
volumes:
- ${DOCKER_SOCK_PATH:-/var/run/docker.sock}:/var/run/docker.sock
cli_server:
image: ${ONYX_CLI_IMAGE:-onyxdotapp/onyx-cli:${IMAGE_TAG:-latest}}
command: ["serve", "--host", "0.0.0.0", "--port", "2222"]
depends_on:
- api_server
restart: unless-stopped
ports:
- "${ONYX_SSH_PORT:-2222}:2222"
environment:
- ONYX_SERVER_URL=http://api_server:8080
volumes:
- cli_config:/home/onyx/.config
logging:
driver: json-file
options:
max-size: "50m"
max-file: "6"
volumes:
# Necessary for persisting data for use
db_volume:
vespa_volume: # Created by the container itself
minio_data:
# Caches to prevent re-downloading models, not strictly necessary
model_cache_huggingface:
indexing_huggingface_model_cache:
# Logs preserved across container restarts
api_server_logs:
background_logs:
# mcp_server_logs:
inference_model_server_logs:
indexing_model_server_logs:
# Shared volume for persistent document storage (Craft file-system mode)
file-system:
# Persistent data for OpenSearch.
opensearch-data:
cli_config:

View File

@@ -133,7 +133,7 @@ async function createFederatedConnector(
async function updateFederatedConnector(
id: number,
credentials: CredentialForm,
credentials: CredentialForm | null,
config?: ConfigForm
): Promise<{ success: boolean; message: string }> {
try {
@@ -143,7 +143,7 @@ async function updateFederatedConnector(
"Content-Type": "application/json",
},
body: JSON.stringify({
credentials,
credentials: credentials ?? undefined,
config: config || {},
}),
});
@@ -201,7 +201,9 @@ export function FederatedConnectorForm({
const isEditMode = connectorId !== undefined;
const [formState, setFormState] = useState<FormState>({
credentials: preloadedConnectorData?.credentials || {},
// In edit mode, don't populate credentials with masked values from the API.
// Masked values (e.g. "••••••••••••") would be saved back and corrupt the real credentials.
credentials: isEditMode ? {} : preloadedConnectorData?.credentials || {},
config: preloadedConnectorData?.config || {},
schema: preloadedCredentialSchema?.credentials || null,
configurationSchema: null,
@@ -209,6 +211,7 @@ export function FederatedConnectorForm({
configurationSchemaError: null,
connectorError: null,
});
const [credentialsModified, setCredentialsModified] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitMessage, setSubmitMessage] = useState<string | null>(null);
const [submitSuccess, setSubmitSuccess] = useState<boolean | null>(null);
@@ -333,6 +336,7 @@ export function FederatedConnectorForm({
}
const handleCredentialChange = (key: string, value: string) => {
setCredentialsModified(true);
setFormState((prev) => ({
...prev,
credentials: {
@@ -354,6 +358,11 @@ export function FederatedConnectorForm({
const handleValidateCredentials = async () => {
if (!formState.schema) return;
if (isEditMode && !credentialsModified) {
setSubmitMessage("Enter new credential values before validating.");
setSubmitSuccess(false);
return;
}
setIsValidating(true);
setSubmitMessage(null);
@@ -411,8 +420,10 @@ export function FederatedConnectorForm({
setSubmitSuccess(null);
try {
// Validate required fields
if (formState.schema) {
const shouldValidateCredentials = !isEditMode || credentialsModified;
// Validate required fields (skip for credentials in edit mode when unchanged)
if (formState.schema && shouldValidateCredentials) {
const missingRequired = Object.entries(formState.schema)
.filter(
([key, field]) => field.required && !formState.credentials[key]
@@ -442,16 +453,20 @@ export function FederatedConnectorForm({
}
setConfigValidationErrors({});
// Validate credentials before creating/updating
const validation = await validateCredentials(
connector,
formState.credentials
);
if (!validation.success) {
setSubmitMessage(`Credential validation failed: ${validation.message}`);
setSubmitSuccess(false);
setIsSubmitting(false);
return;
// Validate credentials before creating/updating (skip in edit mode when unchanged)
if (shouldValidateCredentials) {
const validation = await validateCredentials(
connector,
formState.credentials
);
if (!validation.success) {
setSubmitMessage(
`Credential validation failed: ${validation.message}`
);
setSubmitSuccess(false);
setIsSubmitting(false);
return;
}
}
// Create or update the connector
@@ -459,7 +474,7 @@ export function FederatedConnectorForm({
isEditMode && connectorId
? await updateFederatedConnector(
connectorId,
formState.credentials,
credentialsModified ? formState.credentials : null,
formState.config
)
: await createFederatedConnector(
@@ -538,14 +553,16 @@ export function FederatedConnectorForm({
id={fieldKey}
type={fieldSpec.secret ? "password" : "text"}
placeholder={
fieldSpec.example
? String(fieldSpec.example)
: fieldSpec.description
isEditMode && !credentialsModified
? "•••••••• (leave blank to keep current value)"
: fieldSpec.example
? String(fieldSpec.example)
: fieldSpec.description
}
value={formState.credentials[fieldKey] || ""}
onChange={(e) => handleCredentialChange(fieldKey, e.target.value)}
className="w-96"
required={fieldSpec.required}
required={!isEditMode && fieldSpec.required}
/>
</div>
))}

View File

@@ -1,25 +1,10 @@
"use client";
import {
WellKnownLLMProviderDescriptor,
LLMProviderDescriptor,
} from "@/interfaces/llm";
import React, {
createContext,
useContext,
useState,
useEffect,
useCallback,
} from "react";
import { useUser } from "@/providers/UserProvider";
import { LLMProviderDescriptor } from "@/interfaces/llm";
import React, { createContext, useContext, useCallback } from "react";
import { useLLMProviders } from "@/hooks/useLLMProviders";
import { useLLMProviderOptions } from "@/lib/hooks/useLLMProviderOptions";
import { testDefaultProvider as testDefaultProviderSvc } from "@/lib/llmConfig/svc";
interface ProviderContextType {
shouldShowConfigurationNeeded: boolean;
providerOptions: WellKnownLLMProviderDescriptor[];
refreshProviderInfo: () => Promise<void>;
// Expose configured provider instances for components that need it (e.g., onboarding)
llmProviders: LLMProviderDescriptor[] | undefined;
isLoadingProviders: boolean;
hasProviders: boolean;
@@ -29,79 +14,26 @@ const ProviderContext = createContext<ProviderContextType | undefined>(
undefined
);
const DEFAULT_LLM_PROVIDER_TEST_COMPLETE_KEY = "defaultLlmProviderTestComplete";
function checkDefaultLLMProviderTestComplete() {
if (typeof window === "undefined") return true;
return (
localStorage.getItem(DEFAULT_LLM_PROVIDER_TEST_COMPLETE_KEY) === "true"
);
}
function setDefaultLLMProviderTestComplete() {
if (typeof window === "undefined") return;
localStorage.setItem(DEFAULT_LLM_PROVIDER_TEST_COMPLETE_KEY, "true");
}
export function ProviderContextProvider({
children,
}: {
children: React.ReactNode;
}) {
const { user } = useUser();
// Use SWR hooks instead of raw fetch
const {
llmProviders,
isLoading: isLoadingProviders,
refetch: refetchProviders,
} = useLLMProviders();
const { llmProviderOptions: providerOptions, refetch: refetchOptions } =
useLLMProviderOptions();
const [defaultCheckSuccessful, setDefaultCheckSuccessful] =
useState<boolean>(true);
// Test the default provider - only runs if test hasn't passed yet
const testDefaultProvider = useCallback(async () => {
const shouldCheck =
!checkDefaultLLMProviderTestComplete() &&
(!user || user.role === "admin");
if (shouldCheck) {
const success = await testDefaultProviderSvc();
setDefaultCheckSuccessful(success);
if (success) {
setDefaultLLMProviderTestComplete();
}
}
}, [user]);
// Test default provider on mount
useEffect(() => {
testDefaultProvider();
}, [testDefaultProvider]);
const hasProviders = (llmProviders?.length ?? 0) > 0;
const validProviderExists = hasProviders && defaultCheckSuccessful;
const shouldShowConfigurationNeeded =
!validProviderExists && (providerOptions?.length ?? 0) > 0;
const refreshProviderInfo = useCallback(async () => {
// Refetch provider lists and re-test default provider if needed
await Promise.all([
refetchProviders(),
refetchOptions(),
testDefaultProvider(),
]);
}, [refetchProviders, refetchOptions, testDefaultProvider]);
await refetchProviders();
}, [refetchProviders]);
return (
<ProviderContext.Provider
value={{
shouldShowConfigurationNeeded,
providerOptions: providerOptions ?? [],
refreshProviderInfo,
llmProviders,
isLoadingProviders,

View File

@@ -17,7 +17,6 @@ const mockProviderStatus = {
llmProviders: [] as unknown[],
isLoadingProviders: false,
hasProviders: false,
providerOptions: [],
refreshProviderInfo: jest.fn(),
};
@@ -71,7 +70,6 @@ describe("useShowOnboarding", () => {
mockProviderStatus.llmProviders = [];
mockProviderStatus.isLoadingProviders = false;
mockProviderStatus.hasProviders = false;
mockProviderStatus.providerOptions = [];
});
it("returns showOnboarding=false while providers are loading", () => {
@@ -198,7 +196,6 @@ describe("useShowOnboarding", () => {
OnboardingStep.Welcome
);
expect(result.current.onboardingActions).toBeDefined();
expect(result.current.llmDescriptors).toEqual([]);
});
describe("localStorage persistence", () => {

View File

@@ -0,0 +1,192 @@
"use client";
import { useState, useCallback, useEffect, useMemo } from "react";
import {
MAX_MODELS,
SelectedModel,
} from "@/refresh-components/popovers/ModelSelector";
import { LLMOverride } from "@/app/app/services/lib";
import { LlmManager } from "@/lib/hooks";
import { buildLlmOptions } from "@/refresh-components/popovers/LLMPopover";
export interface UseMultiModelChatReturn {
/** Currently selected models for multi-model comparison. */
selectedModels: SelectedModel[];
/** Whether multi-model mode is active (>1 model selected). */
isMultiModelActive: boolean;
/** Add a model to the selection. */
addModel: (model: SelectedModel) => void;
/** Remove a model by index. */
removeModel: (index: number) => void;
/** Replace a model at a specific index with a new one. */
replaceModel: (index: number, model: SelectedModel) => void;
/** Clear all selected models. */
clearModels: () => void;
/** Build the LLMOverride[] array from selectedModels. */
buildLlmOverrides: () => LLMOverride[];
/**
* Restore multi-model selection from model version strings (e.g. from chat history).
* Matches against available llmOptions to reconstruct full SelectedModel objects.
*/
restoreFromModelNames: (modelNames: string[]) => void;
/**
* Switch to a single model by name (after user picks a preferred response).
* Matches against llmOptions to find the full SelectedModel.
*/
selectSingleModel: (modelName: string) => void;
}
export default function useMultiModelChat(
llmManager: LlmManager
): UseMultiModelChatReturn {
const [selectedModels, setSelectedModels] = useState<SelectedModel[]>([]);
const [defaultInitialized, setDefaultInitialized] = useState(false);
// Initialize with the default model from llmManager once providers load
const llmOptions = useMemo(
() =>
llmManager.llmProviders ? buildLlmOptions(llmManager.llmProviders) : [],
[llmManager.llmProviders]
);
useEffect(() => {
if (defaultInitialized) return;
if (llmOptions.length === 0) return;
const { currentLlm } = llmManager;
// Don't initialize if currentLlm hasn't loaded yet
if (!currentLlm.modelName) return;
const match = llmOptions.find(
(opt) =>
opt.provider === currentLlm.provider &&
opt.modelName === currentLlm.modelName
);
if (match) {
setSelectedModels([
{
name: match.name,
provider: match.provider,
modelName: match.modelName,
displayName: match.displayName,
},
]);
setDefaultInitialized(true);
}
}, [llmOptions, llmManager.currentLlm, defaultInitialized]);
const isMultiModelActive = selectedModels.length > 1;
const addModel = useCallback((model: SelectedModel) => {
setSelectedModels((prev) => {
if (prev.length >= MAX_MODELS) return prev;
if (
prev.some(
(m) =>
m.provider === model.provider && m.modelName === model.modelName
)
) {
return prev;
}
return [...prev, model];
});
}, []);
const removeModel = useCallback((index: number) => {
setSelectedModels((prev) => prev.filter((_, i) => i !== index));
}, []);
const replaceModel = useCallback((index: number, model: SelectedModel) => {
setSelectedModels((prev) => {
// Don't replace with a model that's already selected elsewhere
if (
prev.some(
(m, i) =>
i !== index &&
m.provider === model.provider &&
m.modelName === model.modelName
)
) {
return prev;
}
const next = [...prev];
next[index] = model;
return next;
});
}, []);
const clearModels = useCallback(() => {
setSelectedModels([]);
}, []);
const restoreFromModelNames = useCallback(
(modelNames: string[]) => {
if (modelNames.length < 2 || llmOptions.length === 0) return;
const restored: SelectedModel[] = [];
for (const name of modelNames) {
// Try matching by modelName (raw version string like "claude-opus-4-6")
// or by displayName (friendly name like "Claude Opus 4.6")
const match = llmOptions.find(
(opt) =>
opt.modelName === name ||
opt.displayName === name ||
opt.name === name
);
if (match) {
restored.push({
name: match.name,
provider: match.provider,
modelName: match.modelName,
displayName: match.displayName,
});
}
}
if (restored.length >= 2) {
setSelectedModels(restored.slice(0, MAX_MODELS));
setDefaultInitialized(true);
}
},
[llmOptions]
);
const selectSingleModel = useCallback(
(modelName: string) => {
if (llmOptions.length === 0) return;
const match = llmOptions.find(
(opt) =>
opt.modelName === modelName ||
opt.displayName === modelName ||
opt.name === modelName
);
if (match) {
setSelectedModels([
{
name: match.name,
provider: match.provider,
modelName: match.modelName,
displayName: match.displayName,
},
]);
}
},
[llmOptions]
);
const buildLlmOverrides = useCallback((): LLMOverride[] => {
return selectedModels.map((m) => ({
model_provider: m.provider,
model_version: m.modelName,
display_name: m.displayName,
}));
}, [selectedModels]);
return {
selectedModels,
isMultiModelActive,
addModel,
removeModel,
replaceModel,
clearModels,
buildLlmOverrides,
restoreFromModelNames,
selectSingleModel,
};
}

View File

@@ -9,7 +9,6 @@ import {
OnboardingState,
OnboardingStep,
} from "@/interfaces/onboarding";
import { WellKnownLLMProviderDescriptor } from "@/interfaces/llm";
import { updateUserPersonalization } from "@/lib/userSettings";
import { useUser } from "@/providers/UserProvider";
import { MinimalPersonaSnapshot } from "@/app/admin/agents/interfaces";
@@ -22,7 +21,6 @@ function getOnboardingCompletedKey(userId: string): string {
function useOnboardingState(liveAgent?: MinimalPersonaSnapshot): {
state: OnboardingState;
llmDescriptors: WellKnownLLMProviderDescriptor[];
actions: OnboardingActions;
isLoading: boolean;
hasProviders: boolean;
@@ -35,7 +33,6 @@ function useOnboardingState(liveAgent?: MinimalPersonaSnapshot): {
llmProviders,
isLoadingProviders,
hasProviders: hasLlmProviders,
providerOptions,
refreshProviderInfo,
} = useProviderStatus();
@@ -43,7 +40,6 @@ function useOnboardingState(liveAgent?: MinimalPersonaSnapshot): {
const { refetch: refreshPersonaProviders } = useLLMProviders(liveAgent?.id);
const userName = user?.personalization?.name;
const llmDescriptors = providerOptions;
const nameUpdateTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(
null
@@ -235,7 +231,6 @@ function useOnboardingState(liveAgent?: MinimalPersonaSnapshot): {
return {
state,
llmDescriptors,
actions: {
nextStep,
prevStep,
@@ -280,7 +275,6 @@ export function useShowOnboarding({
const {
state: onboardingState,
actions: onboardingActions,
llmDescriptors,
isLoading: isLoadingOnboarding,
hasProviders: hasAnyProvider,
} = useOnboardingState(liveAgent);
@@ -350,7 +344,6 @@ export function useShowOnboarding({
onboardingDismissed,
onboardingState,
onboardingActions,
llmDescriptors,
isLoadingOnboarding,
hideOnboarding,
finishOnboarding,

View File

@@ -1,7 +1,7 @@
"use client";
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import Popover, { PopoverMenu } from "@/refresh-components/Popover";
import Popover from "@/refresh-components/Popover";
import { LlmDescriptor, LlmManager } from "@/lib/hooks";
import { structureValue } from "@/lib/llmConfig/utils";
import {
@@ -11,25 +11,11 @@ import {
import { LLMProviderDescriptor } from "@/interfaces/llm";
import { Slider } from "@/components/ui/slider";
import { useUser } from "@/providers/UserProvider";
import LineItem from "@/refresh-components/buttons/LineItem";
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
import Text from "@/refresh-components/texts/Text";
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import {
SvgCheck,
SvgChevronDown,
SvgChevronRight,
SvgRefreshCw,
} from "@opal/icons";
import { Section } from "@/layouts/general-layouts";
import { SvgRefreshCw } from "@opal/icons";
import { OpenButton } from "@opal/components";
import { LLMOption, LLMOptionGroup } from "./interfaces";
import ModelListContent from "./ModelListContent";
export interface LLMPopoverProps {
llmManager: LlmManager;
@@ -150,7 +136,6 @@ export default function LLMPopover({
const isLoadingProviders = llmManager.isLoadingProviders;
const [open, setOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const { user } = useUser();
const [localTemperature, setLocalTemperature] = useState(
@@ -161,9 +146,7 @@ export default function LLMPopover({
setLocalTemperature(llmManager.temperature ?? 0.5);
}, [llmManager.temperature]);
const searchInputRef = useRef<HTMLInputElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const selectedItemRef = useRef<HTMLDivElement>(null);
const handleGlobalTemperatureChange = useCallback((value: number[]) => {
const value_0 = value[0];
@@ -182,39 +165,28 @@ export default function LLMPopover({
[llmManager]
);
const llmOptions = useMemo(
() => buildLlmOptions(llmProviders, currentModelName),
[llmProviders, currentModelName]
const isSelected = useCallback(
(option: LLMOption) =>
option.modelName === llmManager.currentLlm.modelName &&
option.provider === llmManager.currentLlm.provider,
[llmManager.currentLlm.modelName, llmManager.currentLlm.provider]
);
// Filter options by vision capability (when images are uploaded) and search query
const filteredOptions = useMemo(() => {
let result = llmOptions;
if (requiresImageInput) {
result = result.filter((opt) => opt.supportsImageInput);
}
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
result = result.filter(
(opt) =>
opt.displayName.toLowerCase().includes(query) ||
opt.modelName.toLowerCase().includes(query) ||
(opt.vendor && opt.vendor.toLowerCase().includes(query))
const handleSelectModel = useCallback(
(option: LLMOption) => {
llmManager.updateCurrentLlm({
modelName: option.modelName,
provider: option.provider,
name: option.name,
} as LlmDescriptor);
onSelect?.(
structureValue(option.name, option.provider, option.modelName)
);
}
return result;
}, [llmOptions, searchQuery, requiresImageInput]);
// Group options by provider using backend-provided display names and ordering
// For aggregator providers (bedrock, openrouter, vertex_ai), flatten to "Provider/Vendor" format
const groupedOptions = useMemo(
() => groupLlmOptions(filteredOptions),
[filteredOptions]
setOpen(false);
},
[llmManager, onSelect]
);
// Get display name for the model to show in the button
// Use currentModelName prop if provided (e.g., for regenerate showing the model used),
// otherwise fall back to the globally selected model
const currentLlmDisplayName = useMemo(() => {
// Only use currentModelName if it's a non-empty string
const currentModel =
@@ -234,122 +206,30 @@ export default function LLMPopover({
return currentModel;
}, [llmProviders, currentModelName, llmManager.currentLlm.modelName]);
// Determine which group the current model belongs to (for auto-expand)
const currentGroupKey = useMemo(() => {
const currentModel = llmManager.currentLlm.modelName;
const currentProvider = llmManager.currentLlm.provider;
// Match by both modelName AND provider to handle same model name across providers
const option = llmOptions.find(
(o) => o.modelName === currentModel && o.provider === currentProvider
);
if (!option) return "openai";
const provider = option.provider.toLowerCase();
const isAggregator = AGGREGATOR_PROVIDERS.has(provider);
if (isAggregator && option.vendor) {
return `${provider}/${option.vendor.toLowerCase()}`;
}
return provider;
}, [
llmOptions,
llmManager.currentLlm.modelName,
llmManager.currentLlm.provider,
]);
// Track expanded groups - initialize with current model's group
const [expandedGroups, setExpandedGroups] = useState<string[]>([
currentGroupKey,
]);
// Reset state when popover closes/opens
useEffect(() => {
if (!open) {
setSearchQuery("");
} else {
// Reset expanded groups to only show the selected model's group
setExpandedGroups([currentGroupKey]);
}
}, [open, currentGroupKey]);
// Auto-scroll to selected model when popover opens
useEffect(() => {
if (open) {
// Small delay to let accordion content render
const timer = setTimeout(() => {
selectedItemRef.current?.scrollIntoView({
behavior: "instant",
block: "center",
});
}, 50);
return () => clearTimeout(timer);
}
}, [open]);
const isSearching = searchQuery.trim().length > 0;
// Compute final expanded groups
const effectiveExpandedGroups = useMemo(() => {
if (isSearching) {
// Force expand all when searching
return groupedOptions.map((g) => g.key);
}
return expandedGroups;
}, [isSearching, groupedOptions, expandedGroups]);
// Handler for accordion changes
const handleAccordionChange = (value: string[]) => {
// Only update state when not searching (force-expanding)
if (!isSearching) {
setExpandedGroups(value);
}
};
const handleSelectModel = (option: LLMOption) => {
llmManager.updateCurrentLlm({
modelName: option.modelName,
provider: option.provider,
name: option.name,
} as LlmDescriptor);
onSelect?.(structureValue(option.name, option.provider, option.modelName));
setOpen(false);
};
const renderModelItem = (option: LLMOption) => {
const isSelected =
option.modelName === llmManager.currentLlm.modelName &&
option.provider === llmManager.currentLlm.provider;
const capabilities: string[] = [];
if (option.supportsReasoning) {
capabilities.push("Reasoning");
}
if (option.supportsImageInput) {
capabilities.push("Vision");
}
const description =
capabilities.length > 0 ? capabilities.join(", ") : undefined;
return (
<div
key={`${option.name}-${option.modelName}`}
ref={isSelected ? selectedItemRef : undefined}
>
<LineItem
selected={isSelected}
description={description}
onClick={() => handleSelectModel(option)}
rightChildren={
isSelected ? (
<SvgCheck className="h-4 w-4 stroke-action-link-05 shrink-0" />
) : null
}
>
{option.displayName}
</LineItem>
const temperatureFooter = user?.preferences?.temperature_override_enabled ? (
<>
<div className="border-t border-border-02 mx-2" />
<div className="flex flex-col w-full py-2 gap-2">
<Slider
value={[localTemperature]}
max={llmManager.maxTemperature}
min={0}
step={0.01}
onValueChange={handleGlobalTemperatureChange}
onValueCommit={handleGlobalTemperatureCommit}
className="w-full"
/>
<div className="flex flex-row items-center justify-between">
<Text secondaryBody text03>
Temperature (creativity)
</Text>
<Text secondaryBody text03>
{localTemperature.toFixed(1)}
</Text>
</div>
</div>
);
};
</>
) : undefined;
return (
<Popover open={open} onOpenChange={setOpen}>
@@ -373,129 +253,16 @@ export default function LLMPopover({
</div>
<Popover.Content side="top" align="end" width="xl">
<Section gap={0.5}>
{/* Search Input */}
<InputTypeIn
ref={searchInputRef}
leftSearchIcon
variant="internal"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search models..."
/>
{/* Model List with Vendor Groups */}
<PopoverMenu scrollContainerRef={scrollContainerRef}>
{isLoadingProviders
? [
<div key="loading" className="flex items-center gap-2 py-3">
<SimpleLoader />
<Text secondaryBody text03>
Loading models...
</Text>
</div>,
]
: groupedOptions.length === 0
? [
<div key="empty" className="py-3">
<Text secondaryBody text03>
No models found
</Text>
</div>,
]
: groupedOptions.length === 1
? // Single provider - show models directly without accordion
[
<div
key="single-provider"
className="flex flex-col gap-1"
>
{groupedOptions[0]!.options.map(renderModelItem)}
</div>,
]
: // Multiple providers - show accordion with groups
[
<Accordion
key="accordion"
type="multiple"
value={effectiveExpandedGroups}
onValueChange={handleAccordionChange}
className="w-full flex flex-col"
>
{groupedOptions.map((group) => {
const isExpanded = effectiveExpandedGroups.includes(
group.key
);
return (
<AccordionItem
key={group.key}
value={group.key}
className="border-none pt-1"
>
{/* Group Header */}
<AccordionTrigger className="flex items-center rounded-08 hover:no-underline hover:bg-background-tint-02 group [&>svg]:hidden w-full py-1">
<div className="flex items-center gap-1 shrink-0">
<div className="flex items-center justify-center size-5 shrink-0">
<group.Icon size={16} />
</div>
<Text
secondaryBody
text03
nowrap
className="px-0.5"
>
{group.displayName}
</Text>
</div>
<div className="flex-1" />
<div className="flex items-center justify-center size-6 shrink-0">
{isExpanded ? (
<SvgChevronDown className="h-4 w-4 stroke-text-04 shrink-0" />
) : (
<SvgChevronRight className="h-4 w-4 stroke-text-04 shrink-0" />
)}
</div>
</AccordionTrigger>
{/* Model Items - full width highlight */}
<AccordionContent className="pb-0 pt-0">
<div className="flex flex-col gap-1">
{group.options.map(renderModelItem)}
</div>
</AccordionContent>
</AccordionItem>
);
})}
</Accordion>,
]}
</PopoverMenu>
{/* Global Temperature Slider (shown if enabled in user prefs) */}
{user?.preferences?.temperature_override_enabled && (
<>
<div className="border-t border-border-02 mx-2" />
<div className="flex flex-col w-full py-2 gap-2">
<Slider
value={[localTemperature]}
max={llmManager.maxTemperature}
min={0}
step={0.01}
onValueChange={handleGlobalTemperatureChange}
onValueCommit={handleGlobalTemperatureCommit}
className="w-full"
/>
<div className="flex flex-row items-center justify-between">
<Text secondaryBody text03>
Temperature (creativity)
</Text>
<Text secondaryBody text03>
{localTemperature.toFixed(1)}
</Text>
</div>
</div>
</>
)}
</Section>
<ModelListContent
llmProviders={llmProviders}
currentModelName={currentModelName}
requiresImageInput={requiresImageInput}
isLoading={isLoadingProviders}
onSelect={handleSelectModel}
isSelected={isSelected}
scrollContainerRef={scrollContainerRef}
footer={temperatureFooter}
/>
</Popover.Content>
</Popover>
);

View File

@@ -0,0 +1,200 @@
"use client";
import { useState, useMemo, useRef, useEffect } from "react";
import { PopoverMenu } from "@/refresh-components/Popover";
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
import { Text } from "@opal/components";
import { SvgCheck, SvgChevronDown, SvgChevronRight } from "@opal/icons";
import { Section } from "@/layouts/general-layouts";
import { LLMOption } from "./interfaces";
import { buildLlmOptions, groupLlmOptions } from "./LLMPopover";
import LineItem from "@/refresh-components/buttons/LineItem";
import { LLMProviderDescriptor } from "@/interfaces/llm";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/refresh-components/Collapsible";
export interface ModelListContentProps {
llmProviders: LLMProviderDescriptor[] | undefined;
currentModelName?: string;
requiresImageInput?: boolean;
onSelect: (option: LLMOption) => void;
isSelected: (option: LLMOption) => boolean;
isDisabled?: (option: LLMOption) => boolean;
scrollContainerRef?: React.RefObject<HTMLDivElement | null>;
isLoading?: boolean;
footer?: React.ReactNode;
}
export default function ModelListContent({
llmProviders,
currentModelName,
requiresImageInput,
onSelect,
isSelected,
isDisabled,
scrollContainerRef: externalScrollRef,
isLoading,
footer,
}: ModelListContentProps) {
const [searchQuery, setSearchQuery] = useState("");
const internalScrollRef = useRef<HTMLDivElement>(null);
const scrollContainerRef = externalScrollRef ?? internalScrollRef;
const llmOptions = useMemo(
() => buildLlmOptions(llmProviders, currentModelName),
[llmProviders, currentModelName]
);
const filteredOptions = useMemo(() => {
let result = llmOptions;
if (requiresImageInput) {
result = result.filter((opt) => opt.supportsImageInput);
}
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
result = result.filter(
(opt) =>
opt.displayName.toLowerCase().includes(query) ||
opt.modelName.toLowerCase().includes(query) ||
(opt.vendor && opt.vendor.toLowerCase().includes(query))
);
}
return result;
}, [llmOptions, searchQuery, requiresImageInput]);
const groupedOptions = useMemo(
() => groupLlmOptions(filteredOptions),
[filteredOptions]
);
// Find which group contains a currently-selected model (for auto-expand)
const defaultGroupKey = useMemo(() => {
for (const group of groupedOptions) {
if (group.options.some((opt) => isSelected(opt))) {
return group.key;
}
}
return groupedOptions[0]?.key ?? "";
}, [groupedOptions, isSelected]);
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(
new Set([defaultGroupKey])
);
// Reset expanded groups when default changes (e.g. popover re-opens)
useEffect(() => {
setExpandedGroups(new Set([defaultGroupKey]));
}, [defaultGroupKey]);
const isSearching = searchQuery.trim().length > 0;
const toggleGroup = (key: string) => {
if (isSearching) return;
setExpandedGroups((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
const isGroupOpen = (key: string) => isSearching || expandedGroups.has(key);
const renderModelItem = (option: LLMOption) => {
const selected = isSelected(option);
const disabled = isDisabled?.(option) ?? false;
const capabilities: string[] = [];
if (option.supportsReasoning) capabilities.push("Reasoning");
if (option.supportsImageInput) capabilities.push("Vision");
const description =
capabilities.length > 0 ? capabilities.join(", ") : undefined;
return (
<LineItem
key={`${option.provider}:${option.modelName}`}
selected={selected}
disabled={disabled}
description={description}
onClick={() => onSelect(option)}
rightChildren={
selected ? (
<SvgCheck className="h-4 w-4 stroke-action-link-05 shrink-0" />
) : null
}
>
{option.displayName}
</LineItem>
);
};
return (
<Section gap={0.5}>
<InputTypeIn
leftSearchIcon
variant="internal"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search models..."
/>
<PopoverMenu scrollContainerRef={scrollContainerRef}>
{isLoading
? [
<Text key="loading" font="secondary-body" color="text-03">
Loading models...
</Text>,
]
: groupedOptions.length === 0
? [
<Text key="empty" font="secondary-body" color="text-03">
No models found
</Text>,
]
: groupedOptions.length === 1
? [
<Section key="single-provider" gap={0.25}>
{groupedOptions[0]!.options.map(renderModelItem)}
</Section>,
]
: groupedOptions.map((group) => {
const open = isGroupOpen(group.key);
return (
<Collapsible
key={group.key}
open={open}
onOpenChange={() => toggleGroup(group.key)}
>
<CollapsibleTrigger asChild>
<LineItem
muted
icon={group.Icon}
rightChildren={
open ? (
<SvgChevronDown className="h-4 w-4 stroke-text-04 shrink-0" />
) : (
<SvgChevronRight className="h-4 w-4 stroke-text-04 shrink-0" />
)
}
>
{group.displayName}
</LineItem>
</CollapsibleTrigger>
<CollapsibleContent>
<Section gap={0.25}>
{group.options.map(renderModelItem)}
</Section>
</CollapsibleContent>
</Collapsible>
);
})}
</PopoverMenu>
{footer}
</Section>
);
}

View File

@@ -0,0 +1,230 @@
"use client";
import { useState, useMemo, useRef } from "react";
import Popover from "@/refresh-components/Popover";
import { LlmManager } from "@/lib/hooks";
import { getProviderIcon } from "@/app/admin/configuration/llm/utils";
import { Button, SelectButton, OpenButton } from "@opal/components";
import { SvgPlusCircle, SvgX } from "@opal/icons";
import { LLMOption } from "@/refresh-components/popovers/interfaces";
import ModelListContent from "@/refresh-components/popovers/ModelListContent";
import Separator from "@/refresh-components/Separator";
export const MAX_MODELS = 3;
export interface SelectedModel {
name: string;
provider: string;
modelName: string;
displayName: string;
}
export interface ModelSelectorProps {
llmManager: LlmManager;
selectedModels: SelectedModel[];
onAdd: (model: SelectedModel) => void;
onRemove: (index: number) => void;
onReplace: (index: number, model: SelectedModel) => void;
}
function modelKey(provider: string, modelName: string): string {
return `${provider}:${modelName}`;
}
export default function ModelSelector({
llmManager,
selectedModels,
onAdd,
onRemove,
onReplace,
}: ModelSelectorProps) {
const [open, setOpen] = useState(false);
// null = add mode (via + button), number = replace mode (via pill click)
const [replacingIndex, setReplacingIndex] = useState<number | null>(null);
// Virtual anchor ref — points to the clicked pill so the popover positions above it
const anchorRef = useRef<HTMLElement | null>(null);
const isMultiModel = selectedModels.length > 1;
const atMax = selectedModels.length >= MAX_MODELS;
const selectedKeys = useMemo(
() => new Set(selectedModels.map((m) => modelKey(m.provider, m.modelName))),
[selectedModels]
);
const otherSelectedKeys = useMemo(() => {
if (replacingIndex === null) return new Set<string>();
return new Set(
selectedModels
.filter((_, i) => i !== replacingIndex)
.map((m) => modelKey(m.provider, m.modelName))
);
}, [selectedModels, replacingIndex]);
const replacingKey =
replacingIndex !== null
? (() => {
const m = selectedModels[replacingIndex];
return m ? modelKey(m.provider, m.modelName) : null;
})()
: null;
const isSelected = (option: LLMOption) => {
const key = modelKey(option.provider, option.modelName);
if (replacingIndex !== null) return key === replacingKey;
return selectedKeys.has(key);
};
const isDisabled = (option: LLMOption) => {
const key = modelKey(option.provider, option.modelName);
if (replacingIndex !== null) return otherSelectedKeys.has(key);
return !selectedKeys.has(key) && atMax;
};
const handleSelect = (option: LLMOption) => {
const model: SelectedModel = {
name: option.name,
provider: option.provider,
modelName: option.modelName,
displayName: option.displayName,
};
if (replacingIndex !== null) {
onReplace(replacingIndex, model);
setOpen(false);
setReplacingIndex(null);
return;
}
const key = modelKey(option.provider, option.modelName);
const existingIndex = selectedModels.findIndex(
(m) => modelKey(m.provider, m.modelName) === key
);
if (existingIndex >= 0) {
onRemove(existingIndex);
} else if (!atMax) {
onAdd(model);
}
};
const handleOpenChange = (nextOpen: boolean) => {
setOpen(nextOpen);
if (!nextOpen) setReplacingIndex(null);
};
const handlePillClick = (index: number, element: HTMLElement) => {
anchorRef.current = element;
setReplacingIndex(index);
setOpen(true);
};
return (
<Popover open={open} onOpenChange={handleOpenChange}>
<div className="flex items-center justify-end gap-1 p-1">
{!atMax && (
<Button
prominence="tertiary"
icon={SvgPlusCircle}
size="sm"
tooltip="Add Model"
onClick={(e: React.MouseEvent) => {
anchorRef.current = e.currentTarget as HTMLElement;
setReplacingIndex(null);
setOpen(true);
}}
/>
)}
<Popover.Anchor
virtualRef={anchorRef as React.RefObject<HTMLElement>}
/>
{selectedModels.length > 0 && (
<>
{!atMax && (
<Separator
orientation="vertical"
paddingXRem={0.5}
paddingYRem={0.5}
/>
)}
<div className="flex items-center">
{selectedModels.map((model, index) => {
const ProviderIcon = getProviderIcon(
model.provider,
model.modelName
);
if (!isMultiModel) {
return (
<OpenButton
key={modelKey(model.provider, model.modelName)}
icon={ProviderIcon}
onClick={(e: React.MouseEvent) =>
handlePillClick(index, e.currentTarget as HTMLElement)
}
>
{model.displayName}
</OpenButton>
);
}
return (
<div
key={modelKey(model.provider, model.modelName)}
className="flex items-center"
>
{index > 0 && (
<Separator
orientation="vertical"
paddingXRem={0.5}
className="h-5"
/>
)}
<SelectButton
icon={ProviderIcon}
rightIcon={SvgX}
state="empty"
variant="select-tinted"
interaction="hover"
size="lg"
onClick={(e: React.MouseEvent) => {
const target = e.target as HTMLElement;
const btn = e.currentTarget as HTMLElement;
const icons = btn.querySelectorAll(
".interactive-foreground-icon"
);
const lastIcon = icons[icons.length - 1];
if (lastIcon && lastIcon.contains(target)) {
onRemove(index);
} else {
handlePillClick(index, btn);
}
}}
>
{model.displayName}
</SelectButton>
</div>
);
})}
</div>
</>
)}
</div>
<Popover.Content
side="top"
align="start"
width="lg"
avoidCollisions={false}
>
<ModelListContent
llmProviders={llmManager.llmProviders}
isLoading={llmManager.isLoadingProviders}
onSelect={handleSelect}
isSelected={isSelected}
isDisabled={isDisabled}
/>
</Popover.Content>
</Popover>
);
}

View File

@@ -232,7 +232,6 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
onboardingDismissed,
onboardingState,
onboardingActions,
llmDescriptors,
isLoadingOnboarding,
finishOnboarding,
hideOnboarding,
@@ -812,7 +811,6 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
handleFinishOnboarding={finishOnboarding}
state={onboardingState}
actions={onboardingActions}
llmDescriptors={llmDescriptors}
/>
)}

View File

@@ -10,7 +10,6 @@ import {
OnboardingState,
OnboardingStep,
} from "@/interfaces/onboarding";
import { WellKnownLLMProviderDescriptor } from "@/interfaces/llm";
import { useUser } from "@/providers/UserProvider";
import { UserRole } from "@/lib/types";
import NonAdminStep from "./components/NonAdminStep";
@@ -21,7 +20,6 @@ type OnboardingFlowProps = {
handleFinishOnboarding: () => void;
state: OnboardingState;
actions: OnboardingActions;
llmDescriptors: WellKnownLLMProviderDescriptor[];
};
const OnboardingFlowInner = ({
@@ -30,7 +28,6 @@ const OnboardingFlowInner = ({
handleFinishOnboarding,
state: onboardingState,
actions: onboardingActions,
llmDescriptors,
}: OnboardingFlowProps) => {
const { user } = useUser();
@@ -57,7 +54,6 @@ const OnboardingFlowInner = ({
<LLMStep
state={onboardingState}
actions={onboardingActions}
llmDescriptors={llmDescriptors}
disabled={
onboardingState.currentStep !== OnboardingStep.LlmSetup
}

View File

@@ -19,11 +19,11 @@ import { Disabled } from "@opal/core";
import { ProviderIcon } from "@/app/admin/configuration/llm/ProviderIcon";
import { SvgCheckCircle, SvgCpu, SvgExternalLink } from "@opal/icons";
import { ContentAction } from "@opal/layouts";
import { useLLMProviderOptions } from "@/lib/hooks/useLLMProviderOptions";
type LLMStepProps = {
state: OnboardingState;
actions: OnboardingActions;
llmDescriptors: WellKnownLLMProviderDescriptor[];
disabled?: boolean;
};
@@ -92,10 +92,10 @@ const StackedProviderIcons = ({ providers }: StackedProviderIconsProps) => {
const LLMStepInner = ({
state: onboardingState,
actions: onboardingActions,
llmDescriptors,
disabled,
}: LLMStepProps) => {
const isLoading = !llmDescriptors || llmDescriptors.length === 0;
const { llmProviderOptions, isLoading } = useLLMProviderOptions();
const llmDescriptors = llmProviderOptions ?? [];
const [selectedProvider, setSelectedProvider] =
useState<SelectedProvider | null>(null);

View File

@@ -411,7 +411,8 @@ test.describe("LLM Runtime Selection", () => {
const sharedModelOptions = dialog.locator("[data-selected]");
await expect(sharedModelOptions).toHaveCount(2);
const openAiModelOption = dialog
.getByRole("region", { name: /openai/i })
.getByRole("button", { name: /openai/i })
.locator("..")
.locator("[data-selected]")
.first();
await expect(openAiModelOption).toBeVisible();
@@ -436,7 +437,8 @@ test.describe("LLM Runtime Selection", () => {
const secondSharedModelOptions = secondDialog.locator("[data-selected]");
await expect(secondSharedModelOptions).toHaveCount(2);
const anthropicModelOption = secondDialog
.getByRole("region", { name: /anthropic/i })
.getByRole("button", { name: /anthropic/i })
.locator("..")
.locator("[data-selected]")
.first();
await expect(anthropicModelOption).toBeVisible();
@@ -447,7 +449,8 @@ test.describe("LLM Runtime Selection", () => {
await page.waitForSelector('[role="dialog"]', { state: "visible" });
const verifyDialog = page.locator('[role="dialog"]');
const selectedAnthropicOption = verifyDialog
.getByRole("region", { name: /anthropic/i })
.getByRole("button", { name: /anthropic/i })
.locator("..")
.locator('[data-selected="true"]');
await expect(selectedAnthropicOption).toHaveCount(1);
await page.keyboard.press("Escape");