Compare commits

..

27 Commits

Author SHA1 Message Date
Raunak Bhagat
e43b0640cb fix(e2e): update LLM test selectors from data-selected to data-interactive-state
LineItemButton uses Interactive.Stateful which sets
data-interactive-state instead of data-selected. Update:
- data-selected → data-interactive-state
- data-selected="true" → data-interactive-state="selected"
- data-selected="false" → data-interactive-state="empty"
2026-04-17 17:58:10 -07:00
Raunak Bhagat
f554219079 Updates 2026-04-17 17:31:50 -07:00
Raunak Bhagat
66d441f5e7 Update spacing 2026-04-17 17:24:03 -07:00
Raunak Bhagat
9da8b05f09 Delete dead code 2026-04-17 17:24:03 -07:00
Raunak Bhagat
ed0131ce53 refactor: update ModelListContent to use nonInteractive LineItemButton 2026-04-17 17:24:03 -07:00
Raunak Bhagat
bbfa37d85d chore: retrigger CI 2026-04-17 17:24:03 -07:00
Raunak Bhagat
75ef2b014d refactor(opal): remove -Variant suffix from component props (#10348) 2026-04-18 00:16:51 +00:00
Justin Tahara
37c56feabf chore(metrics): Adding in calles for metrics (#10343) 2026-04-17 23:55:55 +00:00
Raunak Bhagat
123d02151d feat(opal): Update API for LineItemButton (#10345) 2026-04-17 23:32:00 +00:00
Raunak Bhagat
146d8522df refactor: replace raw Card+Content message patterns with @opal/components.MessageCard (#10342) 2026-04-17 22:48:45 +00:00
acaprau
ac5bae3631 feat(opensearch): Allow optional disabling of SSL to OpenSearch via env var (#10339) 2026-04-17 22:45:52 +00:00
Jamison Lahman
b5434b2391 chore(lint): run shellcheck in pre-commit (#10043) 2026-04-17 21:21:14 +00:00
aserafin-mtt
28e13b503b Fix: mcp langfuse tracing and Jira adf parsing (#10314) 2026-04-17 14:24:16 -07:00
Jamison Lahman
99a90ec196 chore(devtools): ods web installs node_modules on init (#10046) 2026-04-17 21:03:26 +00:00
Jamison Lahman
8ffd7fbb56 chore(gha): skip ty pre-commit in CI (#10337) 2026-04-17 20:56:09 +00:00
Justin Tahara
f9e88e3c72 feat(mt): Tenant work-gating gate + metrics (3/3) (#10281) 2026-04-17 20:44:58 +00:00
Bo-Onyx
97efdbbbc3 fix(pruning): Fix SharePoint sp_tenant_domain not resolved for client secret auth with site pages (#10326) 2026-04-17 20:39:22 +00:00
Justin Tahara
b91a3aed53 fix(xlsx): Additional fixes for mime types (#10331) 2026-04-17 20:26:14 +00:00
Justin Tahara
51480e1099 fix(metrics): Adding in hostname (#10335) 2026-04-17 20:22:20 +00:00
acaprau
70efbef95e feat(opensearch): Add option to disable Vespa and run Onyx with only OpenSearch (#10330) 2026-04-17 13:27:03 -07:00
Wenxi
f3936e2669 fix: s3 test assertion (#10329) 2026-04-17 20:17:16 +00:00
Wenxi
c933c71b59 feat: add document sets to mcp server options (#10322) 2026-04-17 19:52:35 +00:00
Raunak Bhagat
e0d9e109b5 refactor: Rename /admin/configuration/llm to /admin/configuration/language-models (#10327) 2026-04-17 19:47:50 +00:00
Jamison Lahman
66c361bd37 fix(deps): install transitive vertexai dependency (#10328) 2026-04-17 12:14:46 -07:00
Wenxi
01cbea8c4b fix: zuplip temp dir init (#10324) 2026-04-17 18:37:30 +00:00
Raunak Bhagat
2dc2b0da84 refactor: Update toast notifications look and feel (#10320) 2026-04-17 17:54:56 +00:00
Raunak Bhagat
4b58c9cda6 fix: chat preferences page spacing and layout cleanup (#10317) 2026-04-17 09:42:36 -07:00
138 changed files with 2176 additions and 678 deletions

View File

@@ -45,7 +45,7 @@ if [ "$ACTIVE_HOME" != "$MOUNT_HOME" ]; then
[ -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"
rm -rf "${ACTIVE_HOME:?}/$item"
fi
ln -sfn "$MOUNT_HOME/$item" "$ACTIVE_HOME/$item"
done

View File

@@ -39,6 +39,8 @@ jobs:
working-directory: ./web
run: npm ci
- uses: j178/prek-action@cbc2f23eb5539cf20d82d1aabd0d0ecbcc56f4e3
env:
SKIP: ty
with:
prek-version: '0.3.4'
extra-args: ${{ github.event_name == 'pull_request' && format('--from-ref {0} --to-ref {1}', github.event.pull_request.base.sha, github.event.pull_request.head.sha) || github.event_name == 'merge_group' && format('--from-ref {0} --to-ref {1}', github.event.merge_group.base_sha, github.event.merge_group.head_sha) || github.ref_name == 'main' && '--all-files' || '' }}

View File

@@ -68,6 +68,7 @@ repos:
pass_filenames: true
files: ^backend/(?!\.venv/|scripts/).*\.py$
- id: uv-run
alias: ty
name: ty
args: ["ty", "check"]
pass_filenames: true
@@ -85,6 +86,17 @@ repos:
hooks:
- id: actionlint
- repo: https://github.com/shellcheck-py/shellcheck-py
rev: 745eface02aef23e168a8afb6b5737818efbea95 # frozen: v0.11.0.1
hooks:
- id: shellcheck
exclude: >-
(?x)^(
backend/scripts/setup_craft_templates\.sh|
deployment/docker_compose/init-letsencrypt\.sh|
deployment/docker_compose/install\.sh
)$
- repo: https://github.com/psf/black
rev: 8a737e727ac5ab2f1d4cf5876720ed276dc8dc4b # frozen: 25.1.0
hooks:

View File

@@ -1,8 +1,10 @@
import time
from typing import cast
from celery import shared_task
from celery import Task
from celery.exceptions import SoftTimeLimitExceeded
from redis.client import Redis
from redis.lock import Lock as RedisLock
from ee.onyx.server.tenants.product_gating import get_gated_tenants
@@ -16,9 +18,56 @@ from onyx.configs.constants import OnyxRedisLocks
from onyx.db.engine.tenant_utils import get_all_tenant_ids
from onyx.redis.redis_pool import get_redis_client
from onyx.redis.redis_pool import redis_lock_dump
from onyx.redis.redis_tenant_work_gating import cleanup_expired
from onyx.redis.redis_tenant_work_gating import get_active_tenants
from onyx.redis.redis_tenant_work_gating import observe_active_set_size
from onyx.redis.redis_tenant_work_gating import record_full_fanout_cycle
from onyx.redis.redis_tenant_work_gating import record_gate_decision
from onyx.server.runtime.onyx_runtime import OnyxRuntime
from shared_configs.configs import IGNORED_SYNCING_TENANT_LIST
_FULL_FANOUT_TIMESTAMP_KEY_PREFIX = "tenant_work_gating_last_full_fanout_ms"
def _should_bypass_gate_for_full_fanout(
redis_client: Redis, task_name: str, interval_seconds: int
) -> bool:
"""True if at least `interval_seconds` have elapsed since the last
full-fanout bypass for this task. On True, updates the stored timestamp
atomically-enough (it's a best-effort counter, not a lock)."""
key = f"{_FULL_FANOUT_TIMESTAMP_KEY_PREFIX}:{task_name}"
now_ms = int(time.time() * 1000)
threshold_ms = now_ms - (interval_seconds * 1000)
try:
raw = cast(bytes | None, redis_client.get(key))
except Exception:
task_logger.exception(f"full-fanout timestamp read failed: task={task_name}")
# Fail open: treat as "interval elapsed" so we don't skip every
# tenant during a Redis hiccup.
return True
if raw is None:
# First invocation — bypass so the set seeds cleanly.
elapsed = True
else:
try:
last_ms = int(raw.decode())
elapsed = last_ms <= threshold_ms
except ValueError:
elapsed = True
if elapsed:
try:
redis_client.set(key, str(now_ms))
except Exception:
task_logger.exception(
f"full-fanout timestamp write failed: task={task_name}"
)
return elapsed
@shared_task(
name=OnyxCeleryTask.CLOUD_BEAT_TASK_GENERATOR,
ignore_result=True,
@@ -32,6 +81,7 @@ def cloud_beat_task_generator(
priority: int = OnyxCeleryPriority.MEDIUM,
expires: int = BEAT_EXPIRES_DEFAULT,
skip_gated: bool = True,
work_gated: bool = False,
) -> bool | None:
"""a lightweight task used to kick off individual beat tasks per tenant."""
time_start = time.monotonic()
@@ -51,8 +101,56 @@ def cloud_beat_task_generator(
tenant_ids: list[str] = []
num_processed_tenants = 0
num_skipped_gated = 0
num_would_skip_work_gate = 0
num_skipped_work_gate = 0
# Tenant-work-gating read path. Resolve once per invocation.
gate_enabled = False
gate_enforce = False
full_fanout_cycle = False
active_tenants: set[str] | None = None
try:
# Gating setup is inside the try block so any exception still
# reaches the finally that releases the beat lock.
if work_gated:
try:
gate_enabled = OnyxRuntime.get_tenant_work_gating_enabled()
gate_enforce = OnyxRuntime.get_tenant_work_gating_enforce()
except Exception:
task_logger.exception("tenant work gating: runtime flag read failed")
gate_enabled = False
if gate_enabled:
redis_failed = False
interval_s = (
OnyxRuntime.get_tenant_work_gating_full_fanout_interval_seconds()
)
full_fanout_cycle = _should_bypass_gate_for_full_fanout(
redis_client, task_name, interval_s
)
if full_fanout_cycle:
record_full_fanout_cycle(task_name)
try:
ttl_s = OnyxRuntime.get_tenant_work_gating_ttl_seconds()
cleanup_expired(ttl_s)
except Exception:
task_logger.exception(
"tenant work gating: cleanup_expired failed"
)
else:
ttl_s = OnyxRuntime.get_tenant_work_gating_ttl_seconds()
active_tenants = get_active_tenants(ttl_s)
if active_tenants is None:
full_fanout_cycle = True
record_full_fanout_cycle(task_name)
redis_failed = True
# Only refresh the gauge when Redis is known-reachable —
# skip the ZCARD if we just failed open due to a Redis error.
if not redis_failed:
observe_active_set_size()
tenant_ids = get_all_tenant_ids()
# Per-task control over whether gated tenants are included. Most periodic tasks
@@ -76,6 +174,21 @@ def cloud_beat_task_generator(
if IGNORED_SYNCING_TENANT_LIST and tenant_id in IGNORED_SYNCING_TENANT_LIST:
continue
# Tenant work gate: if the feature is on, check membership. Skip
# unmarked tenants when enforce=True AND we're not in a full-
# fanout cycle. Always log/emit the shadow counter.
if work_gated and gate_enabled and not full_fanout_cycle:
would_skip = (
active_tenants is not None and tenant_id not in active_tenants
)
if would_skip:
num_would_skip_work_gate += 1
if gate_enforce:
num_skipped_work_gate += 1
record_gate_decision(task_name, skipped=True)
continue
record_gate_decision(task_name, skipped=False)
self.app.send_task(
task_name,
kwargs=dict(
@@ -109,6 +222,12 @@ def cloud_beat_task_generator(
f"task={task_name} "
f"num_processed_tenants={num_processed_tenants} "
f"num_skipped_gated={num_skipped_gated} "
f"num_would_skip_work_gate={num_would_skip_work_gate} "
f"num_skipped_work_gate={num_skipped_work_gate} "
f"full_fanout_cycle={full_fanout_cycle} "
f"work_gated={work_gated} "
f"gate_enabled={gate_enabled} "
f"gate_enforce={gate_enforce} "
f"num_tenants={len(tenant_ids)} "
f"elapsed={time_elapsed:.2f}"
)

View File

@@ -212,7 +212,7 @@ def check_for_doc_permissions_sync(self: Task, *, tenant_id: str) -> bool | None
# Tenant-work-gating hook: refresh this tenant's active-set membership
# whenever doc-permission sync has any due cc_pairs to dispatch.
if cc_pair_ids_to_sync:
maybe_mark_tenant_active(tenant_id)
maybe_mark_tenant_active(tenant_id, caller="doc_permission_sync")
lock_beat.reacquire()
for cc_pair_id in cc_pair_ids_to_sync:

View File

@@ -206,7 +206,7 @@ def check_for_external_group_sync(self: Task, *, tenant_id: str) -> bool | None:
# Tenant-work-gating hook: refresh this tenant's active-set membership
# whenever external-group sync has any due cc_pairs to dispatch.
if cc_pair_ids_to_sync:
maybe_mark_tenant_active(tenant_id)
maybe_mark_tenant_active(tenant_id, caller="external_group_sync")
lock_beat.reacquire()
for cc_pair_id in cc_pair_ids_to_sync:

View File

@@ -30,6 +30,7 @@ from onyx.background.celery.tasks.vespa.document_sync import DOCUMENT_SYNC_PREFI
from onyx.background.celery.tasks.vespa.document_sync import DOCUMENT_SYNC_TASKSET_KEY
from onyx.configs.app_configs import DISABLE_VECTOR_DB
from onyx.configs.app_configs import ENABLE_OPENSEARCH_INDEXING_FOR_ONYX
from onyx.configs.app_configs import ONYX_DISABLE_VESPA
from onyx.configs.constants import ONYX_CLOUD_CELERY_TASK_PREFIX
from onyx.configs.constants import OnyxRedisLocks
from onyx.db.engine.sql_engine import get_sqlalchemy_engine
@@ -531,23 +532,26 @@ def reset_tenant_id(
CURRENT_TENANT_ID_CONTEXTVAR.set(POSTGRES_DEFAULT_SCHEMA)
def wait_for_vespa_or_shutdown(
sender: Any, # noqa: ARG001
**kwargs: Any, # noqa: ARG001
) -> None: # noqa: ARG001
"""Waits for Vespa to become ready subject to a timeout.
Raises WorkerShutdown if the timeout is reached."""
def wait_for_document_index_or_shutdown() -> None:
"""
Waits for all configured document indices to become ready subject to a
timeout.
Raises WorkerShutdown if the timeout is reached.
"""
if DISABLE_VECTOR_DB:
logger.info(
"DISABLE_VECTOR_DB is set — skipping Vespa/OpenSearch readiness check."
)
return
if not wait_for_vespa_with_timeout():
msg = "[Vespa] Readiness probe did not succeed within the timeout. Exiting..."
logger.error(msg)
raise WorkerShutdown(msg)
if not ONYX_DISABLE_VESPA:
if not wait_for_vespa_with_timeout():
msg = (
"[Vespa] Readiness probe did not succeed within the timeout. Exiting..."
)
logger.error(msg)
raise WorkerShutdown(msg)
if ENABLE_OPENSEARCH_INDEXING_FOR_ONYX:
if not wait_for_opensearch_with_timeout():

View File

@@ -105,7 +105,7 @@ def on_worker_init(sender: Worker, **kwargs: Any) -> None:
app_base.wait_for_redis(sender, **kwargs)
app_base.wait_for_db(sender, **kwargs)
app_base.wait_for_vespa_or_shutdown(sender, **kwargs)
app_base.wait_for_document_index_or_shutdown()
# Less startup checks in multi-tenant case
if MULTI_TENANT:

View File

@@ -111,7 +111,7 @@ def on_worker_init(sender: Worker, **kwargs: Any) -> None:
app_base.wait_for_redis(sender, **kwargs)
app_base.wait_for_db(sender, **kwargs)
app_base.wait_for_vespa_or_shutdown(sender, **kwargs)
app_base.wait_for_document_index_or_shutdown()
# Less startup checks in multi-tenant case
if MULTI_TENANT:

View File

@@ -97,7 +97,7 @@ def on_worker_init(sender: Worker, **kwargs: Any) -> None:
app_base.wait_for_redis(sender, **kwargs)
app_base.wait_for_db(sender, **kwargs)
app_base.wait_for_vespa_or_shutdown(sender, **kwargs)
app_base.wait_for_document_index_or_shutdown()
# Less startup checks in multi-tenant case
if MULTI_TENANT:

View File

@@ -118,7 +118,7 @@ def on_worker_init(sender: Worker, **kwargs: Any) -> None:
app_base.wait_for_redis(sender, **kwargs)
app_base.wait_for_db(sender, **kwargs)
app_base.wait_for_vespa_or_shutdown(sender, **kwargs)
app_base.wait_for_document_index_or_shutdown()
# Less startup checks in multi-tenant case
if MULTI_TENANT:

View File

@@ -124,7 +124,7 @@ def on_worker_init(sender: Worker, **kwargs: Any) -> None:
app_base.wait_for_redis(sender, **kwargs)
app_base.wait_for_db(sender, **kwargs)
app_base.wait_for_vespa_or_shutdown(sender, **kwargs)
app_base.wait_for_document_index_or_shutdown()
logger.info(f"Running as the primary celery worker: pid={os.getpid()}")

View File

@@ -71,7 +71,7 @@ def on_worker_init(sender: Worker, **kwargs: Any) -> None:
app_base.wait_for_redis(sender, **kwargs)
app_base.wait_for_db(sender, **kwargs)
app_base.wait_for_vespa_or_shutdown(sender, **kwargs)
app_base.wait_for_document_index_or_shutdown()
# Less startup checks in multi-tenant case
if MULTI_TENANT:

View File

@@ -10,6 +10,7 @@ from onyx.configs.app_configs import DISABLE_OPENSEARCH_MIGRATION_TASK
from onyx.configs.app_configs import DISABLE_VECTOR_DB
from onyx.configs.app_configs import ENABLE_OPENSEARCH_INDEXING_FOR_ONYX
from onyx.configs.app_configs import ENTERPRISE_EDITION_ENABLED
from onyx.configs.app_configs import ONYX_DISABLE_VESPA
from onyx.configs.app_configs import SCHEDULED_EVAL_DATASET_NAMES
from onyx.configs.constants import ONYX_CLOUD_CELERY_TASK_PREFIX
from onyx.configs.constants import OnyxCeleryPriority
@@ -67,6 +68,7 @@ beat_task_templates: list[dict] = [
"options": {
"priority": OnyxCeleryPriority.MEDIUM,
"expires": BEAT_EXPIRES_DEFAULT,
"work_gated": True,
},
},
{
@@ -100,6 +102,7 @@ beat_task_templates: list[dict] = [
"expires": BEAT_EXPIRES_DEFAULT,
# Gated tenants may still have connectors awaiting deletion.
"skip_gated": False,
"work_gated": True,
},
},
{
@@ -109,6 +112,7 @@ beat_task_templates: list[dict] = [
"options": {
"priority": OnyxCeleryPriority.MEDIUM,
"expires": BEAT_EXPIRES_DEFAULT,
"work_gated": True,
},
},
{
@@ -118,6 +122,7 @@ beat_task_templates: list[dict] = [
"options": {
"priority": OnyxCeleryPriority.MEDIUM,
"expires": BEAT_EXPIRES_DEFAULT,
"work_gated": True,
},
},
{
@@ -155,6 +160,7 @@ beat_task_templates: list[dict] = [
"priority": OnyxCeleryPriority.LOW,
"expires": BEAT_EXPIRES_DEFAULT,
"queue": OnyxCeleryQueues.SANDBOX,
"work_gated": True,
},
},
{
@@ -179,6 +185,7 @@ if ENTERPRISE_EDITION_ENABLED:
"options": {
"priority": OnyxCeleryPriority.MEDIUM,
"expires": BEAT_EXPIRES_DEFAULT,
"work_gated": True,
},
},
{
@@ -188,6 +195,7 @@ if ENTERPRISE_EDITION_ENABLED:
"options": {
"priority": OnyxCeleryPriority.MEDIUM,
"expires": BEAT_EXPIRES_DEFAULT,
"work_gated": True,
},
},
]
@@ -227,7 +235,11 @@ if SCHEDULED_EVAL_DATASET_NAMES:
)
# Add OpenSearch migration task if enabled.
if ENABLE_OPENSEARCH_INDEXING_FOR_ONYX and not DISABLE_OPENSEARCH_MIGRATION_TASK:
if (
ENABLE_OPENSEARCH_INDEXING_FOR_ONYX
and not DISABLE_OPENSEARCH_MIGRATION_TASK
and not ONYX_DISABLE_VESPA
):
beat_task_templates.append(
{
"name": "migrate-chunks-from-vespa-to-opensearch",
@@ -280,7 +292,7 @@ def make_cloud_generator_task(task: dict[str, Any]) -> dict[str, Any]:
cloud_task["kwargs"] = {}
cloud_task["kwargs"]["task_name"] = task["task"]
optional_fields = ["queue", "priority", "expires", "skip_gated"]
optional_fields = ["queue", "priority", "expires", "skip_gated", "work_gated"]
for field in optional_fields:
if field in task["options"]:
cloud_task["kwargs"][field] = task["options"][field]
@@ -373,12 +385,14 @@ if not MULTI_TENANT:
]
)
# `skip_gated` is a cloud-only hint consumed by `cloud_beat_task_generator`. Strip
# it before extending the self-hosted schedule so it doesn't leak into apply_async
# as an unrecognised option on every fired task message.
# `skip_gated` and `work_gated` are cloud-only hints consumed by
# `cloud_beat_task_generator`. Strip them before extending the self-hosted
# schedule so they don't leak into apply_async as unrecognised options on
# every fired task message.
for _template in beat_task_templates:
_self_hosted_template = copy.deepcopy(_template)
_self_hosted_template["options"].pop("skip_gated", None)
_self_hosted_template["options"].pop("work_gated", None)
tasks_to_schedule.append(_self_hosted_template)

View File

@@ -181,7 +181,7 @@ def check_for_connector_deletion_task(self: Task, *, tenant_id: str) -> bool | N
# nearly every tenant in the active set since most have cc_pairs
# but almost none are actively being deleted on any given cycle.
if has_deleting_cc_pair:
maybe_mark_tenant_active(tenant_id)
maybe_mark_tenant_active(tenant_id, caller="connector_deletion")
# try running cleanup on the cc_pair_ids
for cc_pair_id in cc_pair_ids:

View File

@@ -1020,7 +1020,7 @@ def check_for_indexing(self: Task, *, tenant_id: str) -> int | None:
# `tasks_created > 0` here gives us a "real work was done" signal
# rather than just "tenant has a cc_pair somewhere."
if tasks_created > 0:
maybe_mark_tenant_active(tenant_id)
maybe_mark_tenant_active(tenant_id, caller="check_for_indexing")
# 2/3: VALIDATE
# Check for inconsistent index attempts - active attempts without task IDs

View File

@@ -263,7 +263,7 @@ def check_for_pruning(self: Task, *, tenant_id: str) -> bool | None:
# since most tenants have cc_pairs but almost none are due on
# any given cycle.
if prune_dispatched:
maybe_mark_tenant_active(tenant_id)
maybe_mark_tenant_active(tenant_id, caller="check_for_pruning")
r.set(OnyxRedisSignals.BLOCK_PRUNING, 1, ex=_get_pruning_block_expiration())
# we want to run this less frequently than the overall task

View File

@@ -153,7 +153,7 @@ def try_generate_stale_document_sync_tasks(
# Tenant-work-gating hook: refresh this tenant's active-set membership
# whenever vespa sync actually has stale docs to dispatch.
maybe_mark_tenant_active(tenant_id)
maybe_mark_tenant_active(tenant_id, caller="vespa_sync")
logger.info(
f"Stale documents found (at least {stale_doc_count}). Generating sync tasks in one batch."

View File

@@ -282,6 +282,7 @@ OPENSEARCH_ADMIN_USERNAME = os.environ.get("OPENSEARCH_ADMIN_USERNAME", "admin")
OPENSEARCH_ADMIN_PASSWORD = os.environ.get(
"OPENSEARCH_ADMIN_PASSWORD", "StrongPassword123!"
)
OPENSEARCH_USE_SSL = os.environ.get("OPENSEARCH_USE_SSL", "true").lower() == "true"
USING_AWS_MANAGED_OPENSEARCH = (
os.environ.get("USING_AWS_MANAGED_OPENSEARCH", "").lower() == "true"
)
@@ -327,6 +328,7 @@ ENABLE_OPENSEARCH_RETRIEVAL_FOR_ONYX = (
DISABLE_OPENSEARCH_MIGRATION_TASK = (
os.environ.get("DISABLE_OPENSEARCH_MIGRATION_TASK", "").lower() == "true"
)
ONYX_DISABLE_VESPA = os.environ.get("ONYX_DISABLE_VESPA", "").lower() == "true"
# Whether we should check for and create an index if necessary every time we
# instantiate an OpenSearchDocumentIndex on multitenant cloud. Defaults to True.
VERIFY_CREATE_OPENSEARCH_INDEX_ON_INIT_MT = (

View File

@@ -379,10 +379,20 @@ def _download_and_extract_sections_basic(
mime_type == "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
or is_tabular_file(file_name)
):
# Google Drive doesn't enforce file extensions, so the filename may not
# end in .xlsx even when the mime type says it's one. Synthesize the
# extension so tabular_file_to_sections dispatches correctly.
tabular_file_name = file_name
if (
mime_type
== "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
and not is_tabular_file(file_name)
):
tabular_file_name = f"{file_name}.xlsx"
return list(
tabular_file_to_sections(
io.BytesIO(response_call()),
file_name=file_name,
file_name=tabular_file_name,
link=link,
)
)

View File

@@ -62,17 +62,19 @@ def best_effort_get_field_from_issue(jira_issue: Issue, field: str) -> Any:
def extract_text_from_adf(adf: dict | None) -> str:
"""Extracts plain text from Atlassian Document Format:
https://developer.atlassian.com/cloud/jira/platform/apis/document/structure/
WARNING: This function is incomplete and will e.g. skip lists!
"""
# TODO: complete this function
texts = []
if adf is not None and "content" in adf:
for block in adf["content"]:
if "content" in block:
for item in block["content"]:
if item["type"] == "text":
texts.append(item["text"])
texts: list[str] = []
def _extract(node: dict) -> None:
if node.get("type") == "text":
text = node.get("text", "")
if text:
texts.append(text)
for child in node.get("content", []):
_extract(child)
if adf is not None:
_extract(adf)
return " ".join(texts)

View File

@@ -1958,8 +1958,7 @@ class SharepointConnector(
self._graph_client = GraphClient(
_acquire_token_for_graph, environment=self._azure_environment
)
if auth_method == SharepointAuthMethod.CERTIFICATE.value:
self.sp_tenant_domain = self._resolve_tenant_domain()
self.sp_tenant_domain = self._resolve_tenant_domain()
return None
def _get_drive_names_for_site(self, site_url: str) -> list[str]:

View File

@@ -81,9 +81,7 @@ class ZulipConnector(LoadConnector, PollConnector):
# zuliprc file. This reverts them back to newlines.
contents_spaces_to_newlines = contents.replace(" ", "\n")
# create a temporary zuliprc file
tempdir = tempfile.tempdir
if tempdir is None:
raise Exception("Could not determine tempfile directory")
tempdir = tempfile.gettempdir()
config_file = os.path.join(tempdir, f"zuliprc-{self.realm_name}")
with open(config_file, "w") as f:
f.write(contents_spaces_to_newlines)

View File

@@ -244,13 +244,21 @@ def fetch_latest_index_attempts_by_status(
return query.all()
_INTERNAL_ONLY_SOURCES = {
# Used by the ingestion API, not a user-created connector.
DocumentSource.INGESTION_API,
# Backs the user library / build feature, not a connector users filter by.
DocumentSource.CRAFT_FILE,
}
def fetch_unique_document_sources(db_session: Session) -> list[DocumentSource]:
distinct_sources = db_session.query(Connector.source).distinct().all()
sources = [
source[0]
for source in distinct_sources
if source[0] != DocumentSource.INGESTION_API
if source[0] not in _INTERNAL_ONLY_SOURCES
]
return sources

View File

@@ -20,6 +20,7 @@ from onyx.background.celery.tasks.opensearch_migration.constants import (
TOTAL_ALLOWABLE_DOC_MIGRATION_ATTEMPTS_BEFORE_PERMANENT_FAILURE,
)
from onyx.configs.app_configs import ENABLE_OPENSEARCH_RETRIEVAL_FOR_ONYX
from onyx.configs.app_configs import ONYX_DISABLE_VESPA
from onyx.db.enums import OpenSearchDocumentMigrationStatus
from onyx.db.models import Document
from onyx.db.models import OpenSearchDocumentMigrationRecord
@@ -412,7 +413,11 @@ def get_opensearch_retrieval_state(
If the tenant migration record is not found, defaults to
ENABLE_OPENSEARCH_RETRIEVAL_FOR_ONYX.
If ONYX_DISABLE_VESPA is True, always returns True.
"""
if ONYX_DISABLE_VESPA:
return True
record = db_session.query(OpenSearchTenantMigrationRecord).first()
if record is None:
return ENABLE_OPENSEARCH_RETRIEVAL_FOR_ONYX

View File

@@ -3,6 +3,7 @@ from sqlalchemy.orm import Session
from onyx.configs.app_configs import DISABLE_VECTOR_DB
from onyx.configs.app_configs import ENABLE_OPENSEARCH_INDEXING_FOR_ONYX
from onyx.configs.app_configs import ONYX_DISABLE_VESPA
from onyx.db.models import SearchSettings
from onyx.db.opensearch_migration import get_opensearch_retrieval_state
from onyx.document_index.disabled import DisabledDocumentIndex
@@ -48,6 +49,11 @@ def get_default_document_index(
secondary_large_chunks_enabled = secondary_search_settings.large_chunks_enabled
opensearch_retrieval_enabled = get_opensearch_retrieval_state(db_session)
if ONYX_DISABLE_VESPA:
if not opensearch_retrieval_enabled:
raise ValueError(
"BUG: ONYX_DISABLE_VESPA is set but opensearch_retrieval_enabled is not set."
)
if opensearch_retrieval_enabled:
indexing_setting = IndexingSetting.from_db_model(search_settings)
secondary_indexing_setting = (
@@ -119,21 +125,32 @@ def get_all_document_indices(
)
]
vespa_document_index = VespaIndex(
index_name=search_settings.index_name,
secondary_index_name=(
secondary_search_settings.index_name if secondary_search_settings else None
),
large_chunks_enabled=search_settings.large_chunks_enabled,
secondary_large_chunks_enabled=(
secondary_search_settings.large_chunks_enabled
if secondary_search_settings
else None
),
multitenant=MULTI_TENANT,
httpx_client=httpx_client,
)
opensearch_document_index: OpenSearchOldDocumentIndex | None = None
result: list[DocumentIndex] = []
if ONYX_DISABLE_VESPA:
if not ENABLE_OPENSEARCH_INDEXING_FOR_ONYX:
raise ValueError(
"ONYX_DISABLE_VESPA is set but ENABLE_OPENSEARCH_INDEXING_FOR_ONYX is not set."
)
else:
vespa_document_index = VespaIndex(
index_name=search_settings.index_name,
secondary_index_name=(
secondary_search_settings.index_name
if secondary_search_settings
else None
),
large_chunks_enabled=search_settings.large_chunks_enabled,
secondary_large_chunks_enabled=(
secondary_search_settings.large_chunks_enabled
if secondary_search_settings
else None
),
multitenant=MULTI_TENANT,
httpx_client=httpx_client,
)
result.append(vespa_document_index)
if ENABLE_OPENSEARCH_INDEXING_FOR_ONYX:
indexing_setting = IndexingSetting.from_db_model(search_settings)
secondary_indexing_setting = (
@@ -169,7 +186,6 @@ def get_all_document_indices(
multitenant=MULTI_TENANT,
httpx_client=httpx_client,
)
result: list[DocumentIndex] = [vespa_document_index]
if opensearch_document_index:
result.append(opensearch_document_index)
return result

View File

@@ -17,6 +17,7 @@ from onyx.configs.app_configs import OPENSEARCH_ADMIN_PASSWORD
from onyx.configs.app_configs import OPENSEARCH_ADMIN_USERNAME
from onyx.configs.app_configs import OPENSEARCH_HOST
from onyx.configs.app_configs import OPENSEARCH_REST_API_PORT
from onyx.configs.app_configs import OPENSEARCH_USE_SSL
from onyx.document_index.interfaces_new import TenantState
from onyx.document_index.opensearch.constants import OpenSearchSearchType
from onyx.document_index.opensearch.schema import DocumentChunk
@@ -132,7 +133,7 @@ class OpenSearchClient(AbstractContextManager):
host: str = OPENSEARCH_HOST,
port: int = OPENSEARCH_REST_API_PORT,
auth: tuple[str, str] = (OPENSEARCH_ADMIN_USERNAME, OPENSEARCH_ADMIN_PASSWORD),
use_ssl: bool = True,
use_ssl: bool = OPENSEARCH_USE_SSL,
verify_certs: bool = False,
ssl_show_warn: bool = False,
timeout: int = DEFAULT_OPENSEARCH_CLIENT_TIMEOUT_S,
@@ -302,7 +303,7 @@ class OpenSearchIndexClient(OpenSearchClient):
host: str = OPENSEARCH_HOST,
port: int = OPENSEARCH_REST_API_PORT,
auth: tuple[str, str] = (OPENSEARCH_ADMIN_USERNAME, OPENSEARCH_ADMIN_PASSWORD),
use_ssl: bool = True,
use_ssl: bool = OPENSEARCH_USE_SSL,
verify_certs: bool = False,
ssl_show_warn: bool = False,
timeout: int = DEFAULT_OPENSEARCH_CLIENT_TIMEOUT_S,

View File

@@ -48,6 +48,7 @@ KNOWN_OPENPYXL_BUGS = [
"File contains no valid workbook part",
"Unable to read workbook: could not read stylesheet from None",
"Colors must be aRGB hex values",
"Max value is",
]

View File

@@ -19,9 +19,14 @@ from onyx.configs.app_configs import MCP_SERVER_CORS_ORIGINS
from onyx.mcp_server.auth import OnyxTokenVerifier
from onyx.mcp_server.utils import shutdown_http_client
from onyx.utils.logger import setup_logger
from onyx.utils.variable_functionality import set_is_ee_based_on_env_variable
logger = setup_logger()
# Initialize EE flag at module import so it's set regardless of the entry point
# (python -m onyx.mcp_server_main, uvicorn onyx.mcp_server.api:mcp_app, etc.).
set_is_ee_based_on_env_variable()
logger.info("Creating Onyx MCP Server...")
mcp_server = FastMCP(

View File

@@ -1,4 +1,5 @@
"""Resource registrations for the Onyx MCP server."""
# Import resource modules so decorators execute when the package loads.
from onyx.mcp_server.resources import document_sets # noqa: F401
from onyx.mcp_server.resources import indexed_sources # noqa: F401

View File

@@ -0,0 +1,41 @@
"""Resource exposing document sets available to the current user."""
from __future__ import annotations
import json
from onyx.mcp_server.api import mcp_server
from onyx.mcp_server.utils import get_accessible_document_sets
from onyx.mcp_server.utils import require_access_token
from onyx.utils.logger import setup_logger
logger = setup_logger()
@mcp_server.resource(
"resource://document_sets",
name="document_sets",
description=(
"Enumerate the Document Sets accessible to the current user. Use the "
"returned `name` values with the `document_set_names` filter of the "
"`search_indexed_documents` tool to scope searches to a specific set."
),
mime_type="application/json",
)
async def document_sets_resource() -> str:
"""Return the list of document sets the user can filter searches by."""
access_token = require_access_token()
document_sets = sorted(
await get_accessible_document_sets(access_token), key=lambda entry: entry.name
)
logger.info(
"Onyx MCP Server: document_sets resource returning %s entries",
len(document_sets),
)
# FastMCP 3.2+ requires str/bytes/list[ResourceContent] — it no longer
# auto-serializes; serialize to JSON ourselves.
return json.dumps([entry.model_dump(mode="json") for entry in document_sets])

View File

@@ -2,7 +2,7 @@
from __future__ import annotations
from typing import Any
import json
from onyx.mcp_server.api import mcp_server
from onyx.mcp_server.utils import get_indexed_sources
@@ -21,7 +21,7 @@ logger = setup_logger()
),
mime_type="application/json",
)
async def indexed_sources_resource() -> dict[str, Any]:
async def indexed_sources_resource() -> str:
"""Return the list of indexed source types for search filtering."""
access_token = require_access_token()
@@ -33,6 +33,6 @@ async def indexed_sources_resource() -> dict[str, Any]:
len(sources),
)
return {
"indexed_sources": sorted(sources),
}
# FastMCP 3.2+ requires str/bytes/list[ResourceContent] — it no longer
# auto-serializes; serialize to JSON ourselves.
return json.dumps(sorted(sources))

View File

@@ -4,12 +4,23 @@ from datetime import datetime
from typing import Any
import httpx
from fastmcp.server.auth.auth import AccessToken
from pydantic import BaseModel
from onyx.chat.models import ChatFullResponse
from onyx.configs.constants import DocumentSource
from onyx.context.search.models import BaseFilters
from onyx.context.search.models import SearchDoc
from onyx.mcp_server.api import mcp_server
from onyx.mcp_server.utils import get_http_client
from onyx.mcp_server.utils import get_indexed_sources
from onyx.mcp_server.utils import require_access_token
from onyx.server.features.web_search.models import OpenUrlsToolRequest
from onyx.server.features.web_search.models import OpenUrlsToolResponse
from onyx.server.features.web_search.models import WebSearchToolRequest
from onyx.server.features.web_search.models import WebSearchToolResponse
from onyx.server.query_and_chat.models import ChatSessionCreationRequest
from onyx.server.query_and_chat.models import SendMessageRequest
from onyx.utils.logger import setup_logger
from onyx.utils.variable_functionality import build_api_server_url_for_http_requests
from onyx.utils.variable_functionality import global_version
@@ -17,6 +28,43 @@ from onyx.utils.variable_functionality import global_version
logger = setup_logger()
# CE search falls through to the chat endpoint, which invokes an LLM — the
# default 60s client timeout is not enough for a real RAG-backed response.
_CE_SEARCH_TIMEOUT_SECONDS = 300.0
async def _post_model(
url: str,
body: BaseModel,
access_token: AccessToken,
timeout: float | None = None,
) -> httpx.Response:
"""POST a Pydantic model as JSON to the Onyx backend."""
return await get_http_client().post(
url,
content=body.model_dump_json(exclude_unset=True),
headers={
"Authorization": f"Bearer {access_token.token}",
"Content-Type": "application/json",
},
timeout=timeout if timeout is not None else httpx.USE_CLIENT_DEFAULT,
)
def _project_doc(doc: SearchDoc, content: str | None) -> dict[str, Any]:
"""Project a backend search doc into the MCP wire shape.
Accepts SearchDocWithContent (EE) too since it extends SearchDoc.
"""
return {
"semantic_identifier": doc.semantic_identifier,
"content": content,
"source_type": doc.source_type.value,
"link": doc.link,
"score": doc.score,
}
def _extract_error_detail(response: httpx.Response) -> str:
"""Extract a human-readable error message from a failed backend response.
@@ -36,6 +84,7 @@ def _extract_error_detail(response: httpx.Response) -> str:
async def search_indexed_documents(
query: str,
source_types: list[str] | None = None,
document_set_names: list[str] | None = None,
time_cutoff: str | None = None,
limit: int = 10,
) -> dict[str, Any]:
@@ -53,6 +102,10 @@ async def search_indexed_documents(
In EE mode, the dedicated search endpoint is used instead.
To find a list of available sources, use the `indexed_sources` resource.
`document_set_names` restricts results to documents belonging to the named
Document Sets — useful for scoping queries to a curated subset of the
knowledge base (e.g. to isolate knowledge between agents). Use the
`document_sets` resource to discover accessible set names.
Returns chunks of text as search results with snippets, scores, and metadata.
Example usage:
@@ -60,15 +113,23 @@ async def search_indexed_documents(
{
"query": "What is the latest status of PROJ-1234 and what is the next development item?",
"source_types": ["jira", "google_drive", "github"],
"document_set_names": ["Engineering Wiki"],
"time_cutoff": "2025-11-24T00:00:00Z",
"limit": 10,
}
```
"""
logger.info(
f"Onyx MCP Server: document search: query='{query}', sources={source_types}, limit={limit}"
f"Onyx MCP Server: document search: query='{query}', sources={source_types}, "
f"document_sets={document_set_names}, limit={limit}"
)
# Normalize empty list inputs to None so downstream filter construction is
# consistent — BaseFilters treats [] as "match zero" which differs from
# "no filter" (None).
source_types = source_types or None
document_set_names = document_set_names or None
# Parse time_cutoff string to datetime if provided
time_cutoff_dt: datetime | None = None
if time_cutoff:
@@ -81,9 +142,6 @@ async def search_indexed_documents(
# Continue with no time_cutoff instead of returning an error
time_cutoff_dt = None
# Initialize source_type_enums early to avoid UnboundLocalError
source_type_enums: list[DocumentSource] | None = None
# Get authenticated user from FastMCP's access token
access_token = require_access_token()
@@ -117,6 +175,7 @@ async def search_indexed_documents(
# Convert source_types strings to DocumentSource enums if provided
# Invalid values will be handled by the API server
source_type_enums: list[DocumentSource] | None = None
if source_types is not None:
source_type_enums = []
for src in source_types:
@@ -127,83 +186,83 @@ async def search_indexed_documents(
f"Onyx MCP Server: Invalid source type '{src}' - will be ignored by server"
)
# Build filters dict only with non-None values
filters: dict[str, Any] | None = None
if source_type_enums or time_cutoff_dt:
filters = {}
if source_type_enums:
filters["source_type"] = [src.value for src in source_type_enums]
if time_cutoff_dt:
filters["time_cutoff"] = time_cutoff_dt.isoformat()
filters: BaseFilters | None = None
if source_type_enums or document_set_names or time_cutoff_dt:
filters = BaseFilters(
source_type=source_type_enums,
document_set=document_set_names,
time_cutoff=time_cutoff_dt,
)
is_ee = global_version.is_ee_version()
base_url = build_api_server_url_for_http_requests(respect_env_override_if_set=True)
auth_headers = {"Authorization": f"Bearer {access_token.token}"}
is_ee = global_version.is_ee_version()
search_request: dict[str, Any]
request: BaseModel
if is_ee:
# EE: use the dedicated search endpoint (no LLM invocation)
search_request = {
"search_query": query,
"filters": filters,
"num_docs_fed_to_llm_selection": limit,
"run_query_expansion": False,
"include_content": True,
"stream": False,
}
# EE: use the dedicated search endpoint (no LLM invocation).
# Lazy import so CE deployments that strip ee/ never load this module.
from ee.onyx.server.query_and_chat.models import SendSearchQueryRequest
request = SendSearchQueryRequest(
search_query=query,
filters=filters,
num_docs_fed_to_llm_selection=limit,
run_query_expansion=False,
include_content=True,
stream=False,
)
endpoint = f"{base_url}/search/send-search-message"
error_key = "error"
docs_key = "search_docs"
content_field = "content"
else:
# CE: fall back to the chat endpoint (invokes LLM, consumes tokens)
search_request = {
"message": query,
"stream": False,
"chat_session_info": {},
}
if filters:
search_request["internal_search_filters"] = filters
request = SendMessageRequest(
message=query,
stream=False,
chat_session_info=ChatSessionCreationRequest(),
internal_search_filters=filters,
)
endpoint = f"{base_url}/chat/send-chat-message"
error_key = "error_msg"
docs_key = "top_documents"
content_field = "blurb"
try:
response = await get_http_client().post(
response = await _post_model(
endpoint,
json=search_request,
headers=auth_headers,
request,
access_token,
timeout=None if is_ee else _CE_SEARCH_TIMEOUT_SECONDS,
)
if not response.is_success:
error_detail = _extract_error_detail(response)
return {
"documents": [],
"total_results": 0,
"query": query,
"error": error_detail,
}
result = response.json()
# Check for error in response
if result.get(error_key):
return {
"documents": [],
"total_results": 0,
"query": query,
"error": result.get(error_key),
"error": _extract_error_detail(response),
}
documents = [
{
"semantic_identifier": doc.get("semantic_identifier"),
"content": doc.get(content_field),
"source_type": doc.get("source_type"),
"link": doc.get("link"),
"score": doc.get("score"),
}
for doc in result.get(docs_key, [])
]
if is_ee:
from ee.onyx.server.query_and_chat.models import SearchFullResponse
ee_payload = SearchFullResponse.model_validate_json(response.content)
if ee_payload.error:
return {
"documents": [],
"total_results": 0,
"query": query,
"error": ee_payload.error,
}
documents = [
_project_doc(doc, doc.content) for doc in ee_payload.search_docs
]
else:
ce_payload = ChatFullResponse.model_validate_json(response.content)
if ce_payload.error_msg:
return {
"documents": [],
"total_results": 0,
"query": query,
"error": ce_payload.error_msg,
}
documents = [
_project_doc(doc, doc.blurb) for doc in ce_payload.top_documents
]
# NOTE: search depth is controlled by the backend persona defaults, not `limit`.
# `limit` only caps the returned list; fewer results may be returned if the
@@ -252,23 +311,20 @@ async def search_web(
access_token = require_access_token()
try:
request_payload = {"queries": [query], "max_results": limit}
response = await get_http_client().post(
response = await _post_model(
f"{build_api_server_url_for_http_requests(respect_env_override_if_set=True)}/web-search/search-lite",
json=request_payload,
headers={"Authorization": f"Bearer {access_token.token}"},
WebSearchToolRequest(queries=[query], max_results=limit),
access_token,
)
if not response.is_success:
error_detail = _extract_error_detail(response)
return {
"error": error_detail,
"error": _extract_error_detail(response),
"results": [],
"query": query,
}
response_payload = response.json()
results = response_payload.get("results", [])
payload = WebSearchToolResponse.model_validate_json(response.content)
return {
"results": results,
"results": [result.model_dump(mode="json") for result in payload.results],
"query": query,
}
except Exception as e:
@@ -305,21 +361,19 @@ async def open_urls(
access_token = require_access_token()
try:
response = await get_http_client().post(
response = await _post_model(
f"{build_api_server_url_for_http_requests(respect_env_override_if_set=True)}/web-search/open-urls",
json={"urls": urls},
headers={"Authorization": f"Bearer {access_token.token}"},
OpenUrlsToolRequest(urls=urls),
access_token,
)
if not response.is_success:
error_detail = _extract_error_detail(response)
return {
"error": error_detail,
"error": _extract_error_detail(response),
"results": [],
}
response_payload = response.json()
results = response_payload.get("results", [])
payload = OpenUrlsToolResponse.model_validate_json(response.content)
return {
"results": results,
"results": [result.model_dump(mode="json") for result in payload.results],
}
except Exception as e:
logger.error(f"Onyx MCP Server: URL fetch error: {e}", exc_info=True)

View File

@@ -5,10 +5,24 @@ from __future__ import annotations
import httpx
from fastmcp.server.auth.auth import AccessToken
from fastmcp.server.dependencies import get_access_token
from pydantic import BaseModel
from pydantic import TypeAdapter
from onyx.utils.logger import setup_logger
from onyx.utils.variable_functionality import build_api_server_url_for_http_requests
class DocumentSetEntry(BaseModel):
"""Minimal document-set shape surfaced to MCP clients.
Projected from the backend's DocumentSetSummary to avoid coupling MCP to
admin-only fields (cc-pair summaries, federated connectors, etc.).
"""
name: str
description: str | None = None
logger = setup_logger()
# Shared HTTP client reused across requests
@@ -84,3 +98,32 @@ async def get_indexed_sources(
exc_info=True,
)
raise RuntimeError(f"Failed to fetch indexed sources: {exc}") from exc
_DOCUMENT_SET_ENTRIES_ADAPTER = TypeAdapter(list[DocumentSetEntry])
async def get_accessible_document_sets(
access_token: AccessToken,
) -> list[DocumentSetEntry]:
"""Fetch document sets accessible to the current user."""
headers = {"Authorization": f"Bearer {access_token.token}"}
try:
response = await get_http_client().get(
f"{build_api_server_url_for_http_requests(respect_env_override_if_set=True)}/manage/document-set",
headers=headers,
)
response.raise_for_status()
return _DOCUMENT_SET_ENTRIES_ADAPTER.validate_json(response.content)
except (httpx.HTTPStatusError, httpx.RequestError, ValueError):
logger.error(
"Onyx MCP Server: Failed to fetch document sets",
exc_info=True,
)
raise
except Exception as exc:
logger.error(
"Onyx MCP Server: Unexpected error fetching document sets",
exc_info=True,
)
raise RuntimeError(f"Failed to fetch document sets: {exc}") from exc

View File

@@ -5,6 +5,7 @@ import uvicorn
from onyx.configs.app_configs import MCP_SERVER_ENABLED
from onyx.configs.app_configs import MCP_SERVER_HOST
from onyx.configs.app_configs import MCP_SERVER_PORT
from onyx.tracing.setup import setup_tracing
from onyx.utils.logger import setup_logger
from onyx.utils.variable_functionality import set_is_ee_based_on_env_variable
@@ -18,6 +19,7 @@ def main() -> None:
return
set_is_ee_based_on_env_variable()
setup_tracing()
logger.info(f"Starting MCP server on {MCP_SERVER_HOST}:{MCP_SERVER_PORT}")
from onyx.mcp_server.api import mcp_app

View File

@@ -11,6 +11,8 @@ All public functions no-op in single-tenant mode (`MULTI_TENANT=False`).
import time
from typing import cast
from prometheus_client import Counter
from prometheus_client import Gauge
from redis.client import Redis
from onyx.configs.constants import ONYX_CLOUD_TENANT_ID
@@ -26,6 +28,40 @@ logger = setup_logger()
_SET_KEY = "active_tenants"
# --- Prometheus metrics ---
_active_set_size = Gauge(
"onyx_tenant_work_gating_active_set_size",
"Current cardinality of the active_tenants sorted set (updated once per "
"generator invocation when the gate reads it).",
)
_marked_total = Counter(
"onyx_tenant_work_gating_marked_total",
"Writes into active_tenants, labelled by caller.",
["caller"],
)
_skipped_total = Counter(
"onyx_tenant_work_gating_skipped_total",
"Per-tenant fanouts skipped by the gate (enforce mode only), by task.",
["task"],
)
_would_skip_total = Counter(
"onyx_tenant_work_gating_would_skip_total",
"Per-tenant fanouts that would have been skipped if enforce were on "
"(shadow counter), by task.",
["task"],
)
_full_fanout_total = Counter(
"onyx_tenant_work_gating_full_fanout_total",
"Generator invocations that bypassed the gate for a full fanout cycle, by task.",
["task"],
)
def _now_ms() -> int:
return int(time.time() * 1000)
@@ -54,10 +90,14 @@ def mark_tenant_active(tenant_id: str) -> None:
logger.exception(f"mark_tenant_active failed: tenant_id={tenant_id}")
def maybe_mark_tenant_active(tenant_id: str) -> None:
def maybe_mark_tenant_active(tenant_id: str, caller: str = "unknown") -> None:
"""Convenience wrapper for writer call sites: records the tenant only
when the feature flag is on. Fully defensive — never raises, so a Redis
outage or flag-read failure can't abort the calling task."""
outage or flag-read failure can't abort the calling task.
`caller` labels the Prometheus counter so a dashboard can show which
consumer is firing the hook most.
"""
try:
# Local import to avoid a module-load cycle: OnyxRuntime imports
# onyx.redis.redis_pool, so a top-level import here would wedge on
@@ -67,10 +107,44 @@ def maybe_mark_tenant_active(tenant_id: str) -> None:
if not OnyxRuntime.get_tenant_work_gating_enabled():
return
mark_tenant_active(tenant_id)
_marked_total.labels(caller=caller).inc()
except Exception:
logger.exception(f"maybe_mark_tenant_active failed: tenant_id={tenant_id}")
def observe_active_set_size() -> int | None:
"""Return `ZCARD active_tenants` and update the Prometheus gauge. Call
from the gate generator once per invocation so the dashboard has a
live reading.
Returns `None` on Redis error or in single-tenant mode; callers can
tolerate that (gauge simply doesn't update)."""
if not MULTI_TENANT:
return None
try:
size = cast(int, _client().zcard(_SET_KEY))
_active_set_size.set(size)
return size
except Exception:
logger.exception("observe_active_set_size failed")
return None
def record_gate_decision(task_name: str, skipped: bool) -> None:
"""Increment skip counters from the gate generator. Called once per
tenant that the gate would skip. Always increments the shadow counter;
increments the enforced counter only when `skipped=True`."""
_would_skip_total.labels(task=task_name).inc()
if skipped:
_skipped_total.labels(task=task_name).inc()
def record_full_fanout_cycle(task_name: str) -> None:
"""Increment the full-fanout counter. Called once per generator
invocation where the gate is bypassed (interval elapsed OR fail-open)."""
_full_fanout_total.labels(task=task_name).inc()
def get_active_tenants(ttl_seconds: int) -> set[str] | None:
"""Return tenants whose last-seen timestamp is within `ttl_seconds` of
now.

View File

@@ -584,7 +584,7 @@ def associate_credential_to_connector(
# Tenant-work-gating lifecycle hook: keep new-tenant latency to
# seconds instead of one full-fanout interval.
maybe_mark_tenant_active(tenant_id)
maybe_mark_tenant_active(tenant_id, caller="cc_pair_lifecycle")
# trigger indexing immediately
client_app.send_task(

View File

@@ -1643,7 +1643,7 @@ def create_connector_with_mock_credential(
# Tenant-work-gating lifecycle hook: keep new-tenant latency to
# seconds instead of one full-fanout interval.
maybe_mark_tenant_active(tenant_id)
maybe_mark_tenant_active(tenant_id, caller="cc_pair_lifecycle")
# trigger indexing immediately
client_app.send_task(

View File

@@ -113,7 +113,7 @@ def cleanup_idle_sandboxes_task(self: Task, *, tenant_id: str) -> None: # noqa:
# Tenant-work-gating hook: refresh this tenant's active-set
# membership whenever sandbox cleanup has work to do.
maybe_mark_tenant_active(tenant_id)
maybe_mark_tenant_active(tenant_id, caller="sandbox_cleanup")
task_logger.info(
f"Found {len(idle_sandboxes)} idle sandboxes to put to sleep"

View File

@@ -3,6 +3,7 @@ from fastapi import Depends
from sqlalchemy.orm import Session
from onyx.auth.permissions import require_permission
from onyx.configs.app_configs import ONYX_DISABLE_VESPA
from onyx.db.engine.sql_engine import get_session
from onyx.db.enums import Permission
from onyx.db.models import User
@@ -49,6 +50,7 @@ def get_opensearch_retrieval_status(
enable_opensearch_retrieval = get_opensearch_retrieval_state(db_session)
return OpenSearchRetrievalStatusResponse(
enable_opensearch_retrieval=enable_opensearch_retrieval,
toggling_retrieval_is_disabled=ONYX_DISABLE_VESPA,
)
@@ -63,4 +65,5 @@ def set_opensearch_retrieval_status(
)
return OpenSearchRetrievalStatusResponse(
enable_opensearch_retrieval=request.enable_opensearch_retrieval,
toggling_retrieval_is_disabled=ONYX_DISABLE_VESPA,
)

View File

@@ -19,3 +19,4 @@ class OpenSearchRetrievalStatusRequest(BaseModel):
class OpenSearchRetrievalStatusResponse(BaseModel):
model_config = {"frozen": True}
enable_opensearch_retrieval: bool
toggling_retrieval_is_disabled: bool = False

View File

@@ -395,6 +395,15 @@ class WorkerHealthCollector(_CachedCollector):
Reads worker status from ``WorkerHeartbeatMonitor`` which listens
to the Celery event stream via a single persistent connection.
TODO: every monitoring pod subscribes to the cluster-wide Celery event
stream, so each replica reports health for *all* workers in the cluster,
not just itself. Prometheus distinguishes the replicas via the ``instance``
label, so this doesn't break scraping, but it means N monitoring replicas
do N× the work and may emit slightly inconsistent snapshots of the same
cluster. The proper fix is to have each worker expose its own health (or
to elect a single monitoring replica as the reporter) rather than
broadcasting the full cluster view from every monitoring pod.
"""
def __init__(self, cache_ttl: float = 30.0) -> None:
@@ -413,10 +422,16 @@ class WorkerHealthCollector(_CachedCollector):
"onyx_celery_active_worker_count",
"Number of active Celery workers with recent heartbeats",
)
# Celery hostnames are ``{worker_type}@{nodename}`` (see supervisord.conf).
# Emitting only the worker_type as a label causes N replicas of the same
# type to collapse into identical timeseries within a single scrape,
# which Prometheus rejects as "duplicate sample for timestamp". Split
# the pieces into separate labels so each replica is distinct; callers
# can still ``sum by (worker_type)`` to recover the old aggregated view.
worker_up = GaugeMetricFamily(
"onyx_celery_worker_up",
"Whether a specific Celery worker is alive (1=up, 0=down)",
labels=["worker"],
labels=["worker_type", "hostname"],
)
try:
@@ -424,11 +439,15 @@ class WorkerHealthCollector(_CachedCollector):
alive_count = sum(1 for alive in status.values() if alive)
active_workers.add_metric([], alive_count)
for hostname in sorted(status):
# Use short name (before @) for single-host deployments,
# full hostname when multiple hosts share a worker type.
label = hostname.split("@")[0]
worker_up.add_metric([label], 1 if status[hostname] else 0)
for full_hostname in sorted(status):
worker_type, sep, host = full_hostname.partition("@")
if not sep:
# Hostname didn't contain "@" — fall back to using the
# whole string as the hostname with an empty type.
worker_type, host = "", full_hostname
worker_up.add_metric(
[worker_type, host], 1 if status[full_hostname] else 0
)
except Exception:
logger.debug("Failed to collect worker health metrics", exc_info=True)

View File

@@ -7,6 +7,7 @@ from onyx.configs.app_configs import DISABLE_VECTOR_DB
from onyx.configs.app_configs import ENABLE_OPENSEARCH_INDEXING_FOR_ONYX
from onyx.configs.app_configs import INTEGRATION_TESTS_MODE
from onyx.configs.app_configs import MANAGED_VESPA
from onyx.configs.app_configs import ONYX_DISABLE_VESPA
from onyx.configs.app_configs import VESPA_NUM_ATTEMPTS_ON_STARTUP
from onyx.configs.constants import KV_REINDEX_KEY
from onyx.configs.embedding_configs import SUPPORTED_EMBEDDING_MODELS
@@ -126,10 +127,11 @@ def setup_onyx(
"DISABLE_VECTOR_DB is set — skipping document index setup and embedding model warm-up."
)
else:
# Ensure Vespa is setup correctly, this step is relatively near the end
# because Vespa takes a bit of time to start up
# Ensure the document indices are setup correctly. This step is
# relatively near the end because Vespa takes a bit of time to start up.
logger.notice("Verifying Document Index(s) is/are available.")
# This flow is for setting up the document index so we get all indices here.
# This flow is for setting up the document index so we get all indices
# here.
document_indices = get_all_document_indices(
search_settings,
secondary_search_settings,
@@ -335,7 +337,7 @@ def setup_multitenant_onyx() -> None:
# For Managed Vespa, the schema is sent over via the Vespa Console manually.
# NOTE: Pretty sure this code is never hit in any production environment.
if not MANAGED_VESPA:
if not MANAGED_VESPA and not ONYX_DISABLE_VESPA:
setup_vespa_multitenant(SUPPORTED_EMBEDDING_MODELS)

View File

@@ -214,7 +214,9 @@ distro==1.9.0
dnspython==2.8.0
# via email-validator
docstring-parser==0.17.0
# via cyclopts
# via
# cyclopts
# google-cloud-aiplatform
docutils==0.22.3
# via rich-rst
dropbox==12.0.2
@@ -270,7 +272,13 @@ gitdb==4.0.12
gitpython==3.1.45
# via braintrust
google-api-core==2.28.1
# via google-api-python-client
# via
# google-api-python-client
# google-cloud-aiplatform
# google-cloud-bigquery
# google-cloud-core
# google-cloud-resource-manager
# google-cloud-storage
google-api-python-client==2.86.0
google-auth==2.48.0
# via
@@ -278,21 +286,61 @@ google-auth==2.48.0
# google-api-python-client
# google-auth-httplib2
# google-auth-oauthlib
# google-cloud-aiplatform
# google-cloud-bigquery
# google-cloud-core
# google-cloud-resource-manager
# google-cloud-storage
# google-genai
# kubernetes
google-auth-httplib2==0.1.0
# via google-api-python-client
google-auth-oauthlib==1.0.0
google-cloud-aiplatform==1.133.0
# via litellm
google-cloud-bigquery==3.41.0
# via google-cloud-aiplatform
google-cloud-core==2.5.1
# via
# google-cloud-bigquery
# google-cloud-storage
google-cloud-resource-manager==1.17.0
# via google-cloud-aiplatform
google-cloud-storage==3.10.1
# via google-cloud-aiplatform
google-crc32c==1.8.0
# via
# google-cloud-storage
# google-resumable-media
google-genai==1.52.0
# via onyx
# via
# google-cloud-aiplatform
# onyx
google-resumable-media==2.8.2
# via
# google-cloud-bigquery
# google-cloud-storage
googleapis-common-protos==1.72.0
# via
# google-api-core
# grpc-google-iam-v1
# grpcio-status
# opentelemetry-exporter-otlp-proto-http
greenlet==3.2.4
# via
# playwright
# sqlalchemy
grpc-google-iam-v1==0.14.4
# via google-cloud-resource-manager
grpcio==1.80.0
# via
# google-api-core
# google-cloud-resource-manager
# googleapis-common-protos
# grpc-google-iam-v1
# grpcio-status
grpcio-status==1.80.0
# via google-api-core
h11==0.16.0
# via
# httpcore
@@ -559,6 +607,8 @@ packaging==24.2
# dask
# distributed
# fastmcp
# google-cloud-aiplatform
# google-cloud-bigquery
# huggingface-hub
# jira
# kombu
@@ -605,12 +655,19 @@ propcache==0.4.1
# aiohttp
# yarl
proto-plus==1.26.1
# via google-api-core
# via
# google-api-core
# google-cloud-aiplatform
# google-cloud-resource-manager
protobuf==6.33.5
# via
# ddtrace
# google-api-core
# google-cloud-aiplatform
# google-cloud-resource-manager
# googleapis-common-protos
# grpc-google-iam-v1
# grpcio-status
# onnxruntime
# opentelemetry-proto
# proto-plus
@@ -643,6 +700,7 @@ pydantic==2.11.7
# exa-py
# fastapi
# fastmcp
# google-cloud-aiplatform
# google-genai
# langchain-core
# langfuse
@@ -701,6 +759,7 @@ python-dateutil==2.8.2
# botocore
# celery
# dateparser
# google-cloud-bigquery
# htmldate
# hubspot-api-client
# kubernetes
@@ -779,6 +838,8 @@ requests==2.33.0
# dropbox
# exa-py
# google-api-core
# google-cloud-bigquery
# google-cloud-storage
# google-genai
# hubspot-api-client
# jira
@@ -951,7 +1012,9 @@ typing-extensions==4.15.0
# exa-py
# exceptiongroup
# fastapi
# google-cloud-aiplatform
# google-genai
# grpcio
# huggingface-hub
# jira
# langchain-core

View File

@@ -114,6 +114,8 @@ distlib==0.4.0
# via virtualenv
distro==1.9.0
# via openai
docstring-parser==0.17.0
# via google-cloud-aiplatform
durationpy==0.10
# via kubernetes
execnet==2.1.2
@@ -141,14 +143,65 @@ frozenlist==1.8.0
# aiosignal
fsspec==2025.10.0
# via huggingface-hub
google-api-core==2.28.1
# via
# google-cloud-aiplatform
# google-cloud-bigquery
# google-cloud-core
# google-cloud-resource-manager
# google-cloud-storage
google-auth==2.48.0
# via
# google-api-core
# google-cloud-aiplatform
# google-cloud-bigquery
# google-cloud-core
# google-cloud-resource-manager
# google-cloud-storage
# google-genai
# kubernetes
google-cloud-aiplatform==1.133.0
# via litellm
google-cloud-bigquery==3.41.0
# via google-cloud-aiplatform
google-cloud-core==2.5.1
# via
# google-cloud-bigquery
# google-cloud-storage
google-cloud-resource-manager==1.17.0
# via google-cloud-aiplatform
google-cloud-storage==3.10.1
# via google-cloud-aiplatform
google-crc32c==1.8.0
# via
# google-cloud-storage
# google-resumable-media
google-genai==1.52.0
# via onyx
# via
# google-cloud-aiplatform
# onyx
google-resumable-media==2.8.2
# via
# google-cloud-bigquery
# google-cloud-storage
googleapis-common-protos==1.72.0
# via
# google-api-core
# grpc-google-iam-v1
# grpcio-status
greenlet==3.2.4 ; platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'
# via sqlalchemy
grpc-google-iam-v1==0.14.4
# via google-cloud-resource-manager
grpcio==1.80.0
# via
# google-api-core
# google-cloud-resource-manager
# googleapis-common-protos
# grpc-google-iam-v1
# grpcio-status
grpcio-status==1.80.0
# via google-api-core
h11==0.16.0
# via
# httpcore
@@ -267,6 +320,8 @@ openapi-generator-cli==7.17.0
packaging==24.2
# via
# black
# google-cloud-aiplatform
# google-cloud-bigquery
# hatchling
# huggingface-hub
# ipykernel
@@ -307,6 +362,20 @@ propcache==0.4.1
# via
# aiohttp
# yarl
proto-plus==1.26.1
# via
# google-api-core
# google-cloud-aiplatform
# google-cloud-resource-manager
protobuf==6.33.5
# via
# google-api-core
# google-cloud-aiplatform
# google-cloud-resource-manager
# googleapis-common-protos
# grpc-google-iam-v1
# grpcio-status
# proto-plus
psutil==7.1.3
# via ipykernel
ptyprocess==0.7.0 ; sys_platform != 'emscripten' and sys_platform != 'win32'
@@ -328,6 +397,7 @@ pydantic==2.11.7
# agent-client-protocol
# cohere
# fastapi
# google-cloud-aiplatform
# google-genai
# litellm
# mcp
@@ -364,6 +434,7 @@ python-dateutil==2.8.2
# via
# aiobotocore
# botocore
# google-cloud-bigquery
# jupyter-client
# kubernetes
# matplotlib
@@ -398,6 +469,9 @@ reorder-python-imports-black==3.14.0
requests==2.33.0
# via
# cohere
# google-api-core
# google-cloud-bigquery
# google-cloud-storage
# google-genai
# kubernetes
# requests-oauthlib
@@ -498,7 +572,9 @@ typing-extensions==4.15.0
# celery-types
# cohere
# fastapi
# google-cloud-aiplatform
# google-genai
# grpcio
# huggingface-hub
# ipython
# mcp

View File

@@ -87,6 +87,8 @@ discord-py==2.4.0
# via onyx
distro==1.9.0
# via openai
docstring-parser==0.17.0
# via google-cloud-aiplatform
durationpy==0.10
# via kubernetes
fastapi==0.133.1
@@ -103,12 +105,63 @@ frozenlist==1.8.0
# aiosignal
fsspec==2025.10.0
# via huggingface-hub
google-api-core==2.28.1
# via
# google-cloud-aiplatform
# google-cloud-bigquery
# google-cloud-core
# google-cloud-resource-manager
# google-cloud-storage
google-auth==2.48.0
# via
# google-api-core
# google-cloud-aiplatform
# google-cloud-bigquery
# google-cloud-core
# google-cloud-resource-manager
# google-cloud-storage
# google-genai
# kubernetes
google-cloud-aiplatform==1.133.0
# via litellm
google-cloud-bigquery==3.41.0
# via google-cloud-aiplatform
google-cloud-core==2.5.1
# via
# google-cloud-bigquery
# google-cloud-storage
google-cloud-resource-manager==1.17.0
# via google-cloud-aiplatform
google-cloud-storage==3.10.1
# via google-cloud-aiplatform
google-crc32c==1.8.0
# via
# google-cloud-storage
# google-resumable-media
google-genai==1.52.0
# via onyx
# via
# google-cloud-aiplatform
# onyx
google-resumable-media==2.8.2
# via
# google-cloud-bigquery
# google-cloud-storage
googleapis-common-protos==1.72.0
# via
# google-api-core
# grpc-google-iam-v1
# grpcio-status
grpc-google-iam-v1==0.14.4
# via google-cloud-resource-manager
grpcio==1.80.0
# via
# google-api-core
# google-cloud-resource-manager
# googleapis-common-protos
# grpc-google-iam-v1
# grpcio-status
grpcio-status==1.80.0
# via google-api-core
h11==0.16.0
# via
# httpcore
@@ -184,7 +237,10 @@ openai==2.14.0
# litellm
# onyx
packaging==24.2
# via huggingface-hub
# via
# google-cloud-aiplatform
# google-cloud-bigquery
# huggingface-hub
parameterized==0.9.0
# via cohere
posthog==3.7.4
@@ -198,6 +254,20 @@ propcache==0.4.1
# via
# aiohttp
# yarl
proto-plus==1.26.1
# via
# google-api-core
# google-cloud-aiplatform
# google-cloud-resource-manager
protobuf==6.33.5
# via
# google-api-core
# google-cloud-aiplatform
# google-cloud-resource-manager
# googleapis-common-protos
# grpc-google-iam-v1
# grpcio-status
# proto-plus
py==1.11.0
# via retry
pyasn1==0.6.3
@@ -213,6 +283,7 @@ pydantic==2.11.7
# agent-client-protocol
# cohere
# fastapi
# google-cloud-aiplatform
# google-genai
# litellm
# mcp
@@ -231,6 +302,7 @@ python-dateutil==2.8.2
# via
# aiobotocore
# botocore
# google-cloud-bigquery
# kubernetes
# posthog
python-dotenv==1.1.1
@@ -254,6 +326,9 @@ regex==2025.11.3
requests==2.33.0
# via
# cohere
# google-api-core
# google-cloud-bigquery
# google-cloud-storage
# google-genai
# kubernetes
# posthog
@@ -318,7 +393,9 @@ typing-extensions==4.15.0
# anyio
# cohere
# fastapi
# google-cloud-aiplatform
# google-genai
# grpcio
# huggingface-hub
# mcp
# openai

View File

@@ -102,6 +102,8 @@ discord-py==2.4.0
# via onyx
distro==1.9.0
# via openai
docstring-parser==0.17.0
# via google-cloud-aiplatform
durationpy==0.10
# via kubernetes
einops==0.8.1
@@ -125,12 +127,63 @@ fsspec==2025.10.0
# via
# huggingface-hub
# torch
google-api-core==2.28.1
# via
# google-cloud-aiplatform
# google-cloud-bigquery
# google-cloud-core
# google-cloud-resource-manager
# google-cloud-storage
google-auth==2.48.0
# via
# google-api-core
# google-cloud-aiplatform
# google-cloud-bigquery
# google-cloud-core
# google-cloud-resource-manager
# google-cloud-storage
# google-genai
# kubernetes
google-cloud-aiplatform==1.133.0
# via litellm
google-cloud-bigquery==3.41.0
# via google-cloud-aiplatform
google-cloud-core==2.5.1
# via
# google-cloud-bigquery
# google-cloud-storage
google-cloud-resource-manager==1.17.0
# via google-cloud-aiplatform
google-cloud-storage==3.10.1
# via google-cloud-aiplatform
google-crc32c==1.8.0
# via
# google-cloud-storage
# google-resumable-media
google-genai==1.52.0
# via onyx
# via
# google-cloud-aiplatform
# onyx
google-resumable-media==2.8.2
# via
# google-cloud-bigquery
# google-cloud-storage
googleapis-common-protos==1.72.0
# via
# google-api-core
# grpc-google-iam-v1
# grpcio-status
grpc-google-iam-v1==0.14.4
# via google-cloud-resource-manager
grpcio==1.80.0
# via
# google-api-core
# google-cloud-resource-manager
# googleapis-common-protos
# grpc-google-iam-v1
# grpcio-status
grpcio-status==1.80.0
# via google-api-core
h11==0.16.0
# via
# httpcore
@@ -265,6 +318,8 @@ openai==2.14.0
packaging==24.2
# via
# accelerate
# google-cloud-aiplatform
# google-cloud-bigquery
# huggingface-hub
# kombu
# transformers
@@ -282,6 +337,20 @@ propcache==0.4.1
# via
# aiohttp
# yarl
proto-plus==1.26.1
# via
# google-api-core
# google-cloud-aiplatform
# google-cloud-resource-manager
protobuf==6.33.5
# via
# google-api-core
# google-cloud-aiplatform
# google-cloud-resource-manager
# googleapis-common-protos
# grpc-google-iam-v1
# grpcio-status
# proto-plus
psutil==7.1.3
# via accelerate
py==1.11.0
@@ -299,6 +368,7 @@ pydantic==2.11.7
# agent-client-protocol
# cohere
# fastapi
# google-cloud-aiplatform
# google-genai
# litellm
# mcp
@@ -318,6 +388,7 @@ python-dateutil==2.8.2
# aiobotocore
# botocore
# celery
# google-cloud-bigquery
# kubernetes
python-dotenv==1.1.1
# via
@@ -344,6 +415,9 @@ regex==2025.11.3
requests==2.33.0
# via
# cohere
# google-api-core
# google-cloud-bigquery
# google-cloud-storage
# google-genai
# kubernetes
# requests-oauthlib
@@ -437,7 +511,9 @@ typing-extensions==4.15.0
# anyio
# cohere
# fastapi
# google-cloud-aiplatform
# google-genai
# grpcio
# huggingface-hub
# mcp
# openai

View File

@@ -46,7 +46,7 @@ stop_and_remove_containers
# Start the PostgreSQL container with optional volume
echo "Starting PostgreSQL container..."
if [[ -n "$POSTGRES_VOLUME" ]]; then
docker run -p 5432:5432 --name onyx_postgres -e POSTGRES_PASSWORD=password -d -v $POSTGRES_VOLUME:/var/lib/postgresql/data postgres -c max_connections=250
docker run -p 5432:5432 --name onyx_postgres -e POSTGRES_PASSWORD=password -d -v "$POSTGRES_VOLUME":/var/lib/postgresql/data postgres -c max_connections=250
else
docker run -p 5432:5432 --name onyx_postgres -e POSTGRES_PASSWORD=password -d postgres -c max_connections=250
fi
@@ -54,7 +54,7 @@ fi
# Start the Vespa container with optional volume
echo "Starting Vespa container..."
if [[ -n "$VESPA_VOLUME" ]]; then
docker run --detach --name onyx_vespa --hostname vespa-container --publish 8081:8081 --publish 19071:19071 -v $VESPA_VOLUME:/opt/vespa/var vespaengine/vespa:8
docker run --detach --name onyx_vespa --hostname vespa-container --publish 8081:8081 --publish 19071:19071 -v "$VESPA_VOLUME":/opt/vespa/var vespaengine/vespa:8
else
docker run --detach --name onyx_vespa --hostname vespa-container --publish 8081:8081 --publish 19071:19071 vespaengine/vespa:8
fi
@@ -85,7 +85,7 @@ docker compose -f "$COMPOSE_FILE" -f "$COMPOSE_DEV_FILE" --profile opensearch-en
# Start the Redis container with optional volume
echo "Starting Redis container..."
if [[ -n "$REDIS_VOLUME" ]]; then
docker run --detach --name onyx_redis --publish 6379:6379 -v $REDIS_VOLUME:/data redis
docker run --detach --name onyx_redis --publish 6379:6379 -v "$REDIS_VOLUME":/data redis
else
docker run --detach --name onyx_redis --publish 6379:6379 redis
fi
@@ -93,7 +93,7 @@ fi
# Start the MinIO container with optional volume
echo "Starting MinIO container..."
if [[ -n "$MINIO_VOLUME" ]]; then
docker run --detach --name onyx_minio --publish 9004:9000 --publish 9005:9001 -e MINIO_ROOT_USER=minioadmin -e MINIO_ROOT_PASSWORD=minioadmin -v $MINIO_VOLUME:/data minio/minio server /data --console-address ":9001"
docker run --detach --name onyx_minio --publish 9004:9000 --publish 9005:9001 -e MINIO_ROOT_USER=minioadmin -e MINIO_ROOT_PASSWORD=minioadmin -v "$MINIO_VOLUME":/data minio/minio server /data --console-address ":9001"
else
docker run --detach --name onyx_minio --publish 9004:9000 --publish 9005:9001 -e MINIO_ROOT_USER=minioadmin -e MINIO_ROOT_PASSWORD=minioadmin minio/minio server /data --console-address ":9001"
fi
@@ -111,6 +111,7 @@ sleep 1
# Alembic should be configured in the virtualenv for this repo
if [[ -f "../.venv/bin/activate" ]]; then
# shellcheck source=/dev/null
source ../.venv/bin/activate
else
echo "Warning: Python virtual environment not found at .venv/bin/activate; alembic may not work."

View File

@@ -9,8 +9,10 @@ import pytest
from onyx.configs.constants import BlobType
from onyx.connectors.blob.connector import BlobStorageConnector
from onyx.connectors.cross_connector_utils.tabular_section_utils import is_tabular_file
from onyx.connectors.models import Document
from onyx.connectors.models import HierarchyNode
from onyx.connectors.models import TabularSection
from onyx.connectors.models import TextSection
from onyx.file_processing.extract_file_text import get_file_ext
from onyx.file_processing.file_types import OnyxFileExtensions
@@ -111,15 +113,18 @@ def test_blob_s3_connector(
for doc in all_docs:
section = doc.sections[0]
assert isinstance(section, TextSection)
file_extension = get_file_ext(doc.semantic_identifier)
if file_extension in OnyxFileExtensions.TEXT_AND_DOCUMENT_EXTENSIONS:
if is_tabular_file(doc.semantic_identifier):
assert isinstance(section, TabularSection)
assert len(section.text) > 0
continue
# unknown extension
assert len(section.text) == 0
assert isinstance(section, TextSection)
file_extension = get_file_ext(doc.semantic_identifier)
if file_extension in OnyxFileExtensions.TEXT_AND_DOCUMENT_EXTENSIONS:
assert len(section.text) > 0
else:
assert len(section.text) == 0
@patch(

View File

@@ -0,0 +1,210 @@
"""Tests for `cloud_beat_task_generator`'s tenant work-gating logic.
Exercises the gate-read path end-to-end against real Redis. The Celery
`.app.send_task` is mocked so we can count dispatches without actually
sending messages.
Requires a running Redis instance. Run with::
python -m dotenv -f .vscode/.env run -- pytest \
backend/tests/external_dependency_unit/tenant_work_gating/test_gate_generator.py
"""
from collections.abc import Generator
from unittest.mock import MagicMock
from unittest.mock import patch
import pytest
from ee.onyx.background.celery.tasks.cloud import tasks as cloud_tasks
from onyx.configs.constants import ONYX_CLOUD_TENANT_ID
from onyx.redis import redis_tenant_work_gating as twg
from onyx.redis.redis_pool import get_redis_client
from onyx.redis.redis_tenant_work_gating import _SET_KEY
from onyx.redis.redis_tenant_work_gating import mark_tenant_active
_TENANT_A = "tenant_aaaa0000-0000-0000-0000-000000000001"
_TENANT_B = "tenant_bbbb0000-0000-0000-0000-000000000002"
_TENANT_C = "tenant_cccc0000-0000-0000-0000-000000000003"
_ALL_TEST_TENANTS = [_TENANT_A, _TENANT_B, _TENANT_C]
_FANOUT_KEY_PREFIX = cloud_tasks._FULL_FANOUT_TIMESTAMP_KEY_PREFIX
@pytest.fixture(autouse=True)
def _multi_tenant_true() -> Generator[None, None, None]:
with patch.object(twg, "MULTI_TENANT", True):
yield
@pytest.fixture(autouse=True)
def _clean_redis() -> Generator[None, None, None]:
"""Clear the active set AND the per-task full-fanout timestamp so each
test starts fresh."""
r = get_redis_client(tenant_id=ONYX_CLOUD_TENANT_ID)
r.delete(_SET_KEY)
r.delete(f"{_FANOUT_KEY_PREFIX}:test_task")
r.delete("runtime:tenant_work_gating:enabled")
r.delete("runtime:tenant_work_gating:enforce")
yield
r.delete(_SET_KEY)
r.delete(f"{_FANOUT_KEY_PREFIX}:test_task")
r.delete("runtime:tenant_work_gating:enabled")
r.delete("runtime:tenant_work_gating:enforce")
def _invoke_generator(
*,
work_gated: bool,
enabled: bool,
enforce: bool,
tenant_ids: list[str],
full_fanout_interval_seconds: int = 1200,
ttl_seconds: int = 1800,
) -> MagicMock:
"""Helper: call the generator with runtime flags fixed and the Celery
app mocked. Returns the mock so callers can assert on send_task calls."""
mock_app = MagicMock()
# The task binds `self` = the task itself when invoked via `.run()`;
# patch its `.app` so `self.app.send_task` routes to our mock.
with (
patch.object(cloud_tasks.cloud_beat_task_generator, "app", mock_app),
patch.object(cloud_tasks, "get_all_tenant_ids", return_value=list(tenant_ids)),
patch.object(cloud_tasks, "get_gated_tenants", return_value=set()),
patch(
"onyx.server.runtime.onyx_runtime.OnyxRuntime.get_tenant_work_gating_enabled",
return_value=enabled,
),
patch(
"onyx.server.runtime.onyx_runtime.OnyxRuntime.get_tenant_work_gating_enforce",
return_value=enforce,
),
patch(
"onyx.server.runtime.onyx_runtime.OnyxRuntime.get_tenant_work_gating_full_fanout_interval_seconds",
return_value=full_fanout_interval_seconds,
),
patch(
"onyx.server.runtime.onyx_runtime.OnyxRuntime.get_tenant_work_gating_ttl_seconds",
return_value=ttl_seconds,
),
):
cloud_tasks.cloud_beat_task_generator.run(
task_name="test_task",
work_gated=work_gated,
)
return mock_app
def _dispatched_tenants(mock_app: MagicMock) -> list[str]:
"""Pull tenant_ids out of each send_task call for assertion."""
return [c.kwargs["kwargs"]["tenant_id"] for c in mock_app.send_task.call_args_list]
def _seed_recent_full_fanout_timestamp() -> None:
"""Pre-seed the per-task timestamp so the interval-elapsed branch
reports False, i.e. the gate enforces normally instead of going into
full-fanout on first invocation."""
import time as _t
r = get_redis_client(tenant_id=ONYX_CLOUD_TENANT_ID)
r.set(f"{_FANOUT_KEY_PREFIX}:test_task", str(int(_t.time() * 1000)))
def test_enforce_skips_unmarked_tenants() -> None:
"""With enable+enforce on (interval NOT elapsed), only tenants in the
active set get dispatched."""
mark_tenant_active(_TENANT_A)
_seed_recent_full_fanout_timestamp()
mock_app = _invoke_generator(
work_gated=True,
enabled=True,
enforce=True,
tenant_ids=_ALL_TEST_TENANTS,
full_fanout_interval_seconds=3600,
)
dispatched = _dispatched_tenants(mock_app)
assert dispatched == [_TENANT_A]
def test_shadow_mode_dispatches_all_tenants() -> None:
"""enabled=True, enforce=False: gate computes skip but still dispatches."""
mark_tenant_active(_TENANT_A)
_seed_recent_full_fanout_timestamp()
mock_app = _invoke_generator(
work_gated=True,
enabled=True,
enforce=False,
tenant_ids=_ALL_TEST_TENANTS,
full_fanout_interval_seconds=3600,
)
dispatched = _dispatched_tenants(mock_app)
assert set(dispatched) == set(_ALL_TEST_TENANTS)
def test_full_fanout_cycle_dispatches_all_tenants() -> None:
"""First invocation (no prior timestamp → interval considered elapsed)
counts as full-fanout; every tenant gets dispatched even under enforce."""
mark_tenant_active(_TENANT_A)
mock_app = _invoke_generator(
work_gated=True,
enabled=True,
enforce=True,
tenant_ids=_ALL_TEST_TENANTS,
)
dispatched = _dispatched_tenants(mock_app)
assert set(dispatched) == set(_ALL_TEST_TENANTS)
def test_redis_unavailable_fails_open() -> None:
"""When `get_active_tenants` returns None (simulated Redis outage) the
gate treats the invocation as full-fanout and dispatches everyone —
even when the interval hasn't elapsed and enforce is on."""
mark_tenant_active(_TENANT_A)
_seed_recent_full_fanout_timestamp()
with patch.object(cloud_tasks, "get_active_tenants", return_value=None):
mock_app = _invoke_generator(
work_gated=True,
enabled=True,
enforce=True,
tenant_ids=_ALL_TEST_TENANTS,
full_fanout_interval_seconds=3600,
)
dispatched = _dispatched_tenants(mock_app)
assert set(dispatched) == set(_ALL_TEST_TENANTS)
def test_work_gated_false_bypasses_gate_entirely() -> None:
"""Beat templates that don't opt in (`work_gated=False`) never consult
the set — no matter the flag state."""
# Even with enforce on and nothing in the set, all tenants dispatch.
mock_app = _invoke_generator(
work_gated=False,
enabled=True,
enforce=True,
tenant_ids=_ALL_TEST_TENANTS,
)
dispatched = _dispatched_tenants(mock_app)
assert set(dispatched) == set(_ALL_TEST_TENANTS)
def test_gate_disabled_dispatches_everyone_regardless_of_enforce() -> None:
"""enabled=False means the gate isn't computed — dispatch is unchanged."""
# Intentionally don't add anyone to the set.
mock_app = _invoke_generator(
work_gated=True,
enabled=False,
enforce=True,
tenant_ids=_ALL_TEST_TENANTS,
)
dispatched = _dispatched_tenants(mock_app)
assert set(dispatched) == set(_ALL_TEST_TENANTS)

View File

@@ -16,12 +16,14 @@ from mcp import ClientSession
from mcp.client.streamable_http import streamablehttp_client
from mcp.types import CallToolResult
from mcp.types import TextContent
from pydantic import AnyUrl
from onyx.db.enums import AccessType
from tests.integration.common_utils.constants import MCP_SERVER_URL
from tests.integration.common_utils.managers.api_key import APIKeyManager
from tests.integration.common_utils.managers.cc_pair import CCPairManager
from tests.integration.common_utils.managers.document import DocumentManager
from tests.integration.common_utils.managers.document_set import DocumentSetManager
from tests.integration.common_utils.managers.llm_provider import LLMProviderManager
from tests.integration.common_utils.managers.pat import PATManager
from tests.integration.common_utils.managers.user import UserManager
@@ -34,6 +36,7 @@ from tests.integration.common_utils.test_models import DATestUser
# Constants
MCP_SEARCH_TOOL = "search_indexed_documents"
INDEXED_SOURCES_RESOURCE_URI = "resource://indexed_sources"
DOCUMENT_SETS_RESOURCE_URI = "resource://document_sets"
DEFAULT_SEARCH_LIMIT = 5
STREAMABLE_HTTP_URL = f"{MCP_SERVER_URL.rstrip('/')}/?transportType=streamable-http"
@@ -73,19 +76,22 @@ def _extract_tool_payload(result: CallToolResult) -> dict[str, Any]:
def _call_search_tool(
headers: dict[str, str], query: str, limit: int = DEFAULT_SEARCH_LIMIT
headers: dict[str, str],
query: str,
limit: int = DEFAULT_SEARCH_LIMIT,
document_set_names: list[str] | None = None,
) -> CallToolResult:
"""Call the search_indexed_documents tool via MCP."""
async def _action(session: ClientSession) -> CallToolResult:
await session.initialize()
return await session.call_tool(
MCP_SEARCH_TOOL,
{
"query": query,
"limit": limit,
},
)
arguments: dict[str, Any] = {
"query": query,
"limit": limit,
}
if document_set_names is not None:
arguments["document_set_names"] = document_set_names
return await session.call_tool(MCP_SEARCH_TOOL, arguments)
return _run_with_mcp_session(headers, _action)
@@ -238,3 +244,106 @@ def test_mcp_search_respects_acl_filters(
blocked_payload = _extract_tool_payload(blocked_result)
assert blocked_payload["total_results"] == 0
assert blocked_payload["documents"] == []
def test_mcp_search_filters_by_document_set(
reset: None, # noqa: ARG001
admin_user: DATestUser,
) -> None:
"""Passing document_set_names should scope results to the named set."""
LLMProviderManager.create(user_performing_action=admin_user)
api_key = APIKeyManager.create(user_performing_action=admin_user)
cc_pair_in_set = CCPairManager.create_from_scratch(
user_performing_action=admin_user,
)
cc_pair_out_of_set = CCPairManager.create_from_scratch(
user_performing_action=admin_user,
)
shared_phrase = "document-set-filter-shared-phrase"
in_set_content = f"{shared_phrase} inside curated set"
out_of_set_content = f"{shared_phrase} outside curated set"
_seed_document_and_wait_for_indexing(
cc_pair=cc_pair_in_set,
content=in_set_content,
api_key=api_key,
user_performing_action=admin_user,
)
_seed_document_and_wait_for_indexing(
cc_pair=cc_pair_out_of_set,
content=out_of_set_content,
api_key=api_key,
user_performing_action=admin_user,
)
doc_set = DocumentSetManager.create(
cc_pair_ids=[cc_pair_in_set.id],
user_performing_action=admin_user,
)
DocumentSetManager.wait_for_sync(
user_performing_action=admin_user,
document_sets_to_check=[doc_set],
)
headers = _auth_headers(admin_user, name="mcp-doc-set-filter")
# The document_sets resource should surface the newly created set so MCP
# clients can discover which values to pass to document_set_names.
async def _list_resources(session: ClientSession) -> Any:
await session.initialize()
resources = await session.list_resources()
contents = await session.read_resource(AnyUrl(DOCUMENT_SETS_RESOURCE_URI))
return resources, contents
resources_result, doc_sets_contents = _run_with_mcp_session(
headers, _list_resources
)
resource_uris = {str(resource.uri) for resource in resources_result.resources}
assert DOCUMENT_SETS_RESOURCE_URI in resource_uris
doc_sets_payload = json.loads(doc_sets_contents.contents[0].text)
exposed_names = {entry["name"] for entry in doc_sets_payload}
assert doc_set.name in exposed_names
# Without the filter both documents are visible.
unfiltered_payload = _extract_tool_payload(
_call_search_tool(headers, shared_phrase, limit=10)
)
unfiltered_contents = [
doc.get("content") or "" for doc in unfiltered_payload["documents"]
]
assert any(in_set_content in content for content in unfiltered_contents)
assert any(out_of_set_content in content for content in unfiltered_contents)
# With the document set filter only the in-set document is returned.
filtered_payload = _extract_tool_payload(
_call_search_tool(
headers,
shared_phrase,
limit=10,
document_set_names=[doc_set.name],
)
)
filtered_contents = [
doc.get("content") or "" for doc in filtered_payload["documents"]
]
assert filtered_payload["total_results"] >= 1
assert any(in_set_content in content for content in filtered_contents)
assert all(out_of_set_content not in content for content in filtered_contents)
# An empty document_set_names should behave like "no filter" (normalized
# to None), not "match zero sets".
empty_list_payload = _extract_tool_payload(
_call_search_tool(
headers,
shared_phrase,
limit=10,
document_set_names=[],
)
)
empty_list_contents = [
doc.get("content") or "" for doc in empty_list_payload["documents"]
]
assert any(in_set_content in content for content in empty_list_contents)
assert any(out_of_set_content in content for content in empty_list_contents)

View File

@@ -0,0 +1,101 @@
"""Unit tests for SharepointConnector.load_credentials sp_tenant_domain resolution."""
from __future__ import annotations
import base64
from unittest.mock import MagicMock
from unittest.mock import patch
from onyx.connectors.sharepoint.connector import SharepointConnector
SITE_URL = "https://mytenant.sharepoint.com/sites/MySite"
EXPECTED_TENANT_DOMAIN = "mytenant"
CLIENT_SECRET_CREDS = {
"authentication_method": "client_secret",
"sp_client_id": "fake-client-id",
"sp_client_secret": "fake-client-secret",
"sp_directory_id": "fake-directory-id",
}
CERTIFICATE_CREDS = {
"authentication_method": "certificate",
"sp_client_id": "fake-client-id",
"sp_directory_id": "fake-directory-id",
"sp_private_key": base64.b64encode(b"fake-pfx-data").decode(),
"sp_certificate_password": "fake-password",
}
def _make_mock_msal() -> MagicMock:
mock_app = MagicMock()
mock_app.acquire_token_for_client.return_value = {"access_token": "fake-token"}
return mock_app
@patch("onyx.connectors.sharepoint.connector.msal.ConfidentialClientApplication")
@patch("onyx.connectors.sharepoint.connector.GraphClient")
def test_client_secret_with_site_pages_sets_tenant_domain(
_mock_graph_client: MagicMock,
mock_msal_cls: MagicMock,
) -> None:
"""client_secret auth + include_site_pages=True must resolve sp_tenant_domain."""
mock_msal_cls.return_value = _make_mock_msal()
connector = SharepointConnector(sites=[SITE_URL], include_site_pages=True)
connector.load_credentials(CLIENT_SECRET_CREDS)
assert connector.sp_tenant_domain == EXPECTED_TENANT_DOMAIN
@patch("onyx.connectors.sharepoint.connector.msal.ConfidentialClientApplication")
@patch("onyx.connectors.sharepoint.connector.GraphClient")
def test_client_secret_without_site_pages_still_sets_tenant_domain(
_mock_graph_client: MagicMock,
mock_msal_cls: MagicMock,
) -> None:
"""client_secret auth + include_site_pages=False must still resolve sp_tenant_domain
because _create_rest_client_context is also called for drive items."""
mock_msal_cls.return_value = _make_mock_msal()
connector = SharepointConnector(sites=[SITE_URL], include_site_pages=False)
connector.load_credentials(CLIENT_SECRET_CREDS)
assert connector.sp_tenant_domain == EXPECTED_TENANT_DOMAIN
@patch("onyx.connectors.sharepoint.connector.load_certificate_from_pfx")
@patch("onyx.connectors.sharepoint.connector.msal.ConfidentialClientApplication")
@patch("onyx.connectors.sharepoint.connector.GraphClient")
def test_certificate_with_site_pages_sets_tenant_domain(
_mock_graph_client: MagicMock,
mock_msal_cls: MagicMock,
mock_load_cert: MagicMock,
) -> None:
"""certificate auth + include_site_pages=True must resolve sp_tenant_domain."""
mock_msal_cls.return_value = _make_mock_msal()
mock_load_cert.return_value = MagicMock()
connector = SharepointConnector(sites=[SITE_URL], include_site_pages=True)
connector.load_credentials(CERTIFICATE_CREDS)
assert connector.sp_tenant_domain == EXPECTED_TENANT_DOMAIN
@patch("onyx.connectors.sharepoint.connector.load_certificate_from_pfx")
@patch("onyx.connectors.sharepoint.connector.msal.ConfidentialClientApplication")
@patch("onyx.connectors.sharepoint.connector.GraphClient")
def test_certificate_without_site_pages_sets_tenant_domain(
_mock_graph_client: MagicMock,
mock_msal_cls: MagicMock,
mock_load_cert: MagicMock,
) -> None:
"""certificate auth + include_site_pages=False must still resolve sp_tenant_domain
because _create_rest_client_context is also called for drive items."""
mock_msal_cls.return_value = _make_mock_msal()
mock_load_cert.return_value = MagicMock()
connector = SharepointConnector(sites=[SITE_URL], include_site_pages=False)
connector.load_credentials(CERTIFICATE_CREDS)
assert connector.sp_tenant_domain == EXPECTED_TENANT_DOMAIN

View File

@@ -1,6 +1,7 @@
import io
from typing import cast
from unittest.mock import MagicMock
from unittest.mock import patch
import openpyxl
from openpyxl.worksheet.worksheet import Worksheet
@@ -321,6 +322,17 @@ class TestXlsxSheetExtraction:
sheets = xlsx_sheet_extraction(bad_file, file_name="~$temp.xlsx")
assert sheets == []
def test_known_openpyxl_bug_max_value_returns_empty(self) -> None:
"""openpyxl's strict descriptor validation rejects font family
values >14 with 'Max value is 14'. Treat as a known openpyxl bug
and skip the file rather than fail the whole connector batch."""
with patch(
"onyx.file_processing.extract_file_text.openpyxl.load_workbook",
side_effect=ValueError("Max value is 14"),
):
sheets = xlsx_sheet_extraction(io.BytesIO(b""), file_name="bad_font.xlsx")
assert sheets == []
def test_csv_content_matches_xlsx_to_text_per_sheet(self) -> None:
"""For a single-sheet workbook, xlsx_to_text output should equal
the csv_text from xlsx_sheet_extraction — they share the same

View File

@@ -129,12 +129,36 @@ class TestWorkerHealthCollector:
up = families[1]
assert up.name == "onyx_celery_worker_up"
assert len(up.samples) == 3
# Labels use short names (before @)
labels = {s.labels["worker"] for s in up.samples}
assert labels == {"primary", "docfetching", "monitoring"}
label_pairs = {
(s.labels["worker_type"], s.labels["hostname"]) for s in up.samples
}
assert label_pairs == {
("primary", "host1"),
("docfetching", "host1"),
("monitoring", "host1"),
}
for sample in up.samples:
assert sample.value == 1
def test_replicas_of_same_worker_type_are_distinct(self) -> None:
"""Regression: ``docprocessing@pod-1`` and ``docprocessing@pod-2`` must
produce separate samples, not collapse into one duplicate-timestamp
series.
"""
monitor = WorkerHeartbeatMonitor(MagicMock())
monitor._on_heartbeat({"hostname": "docprocessing@pod-1"})
monitor._on_heartbeat({"hostname": "docprocessing@pod-2"})
monitor._on_heartbeat({"hostname": "docprocessing@pod-3"})
collector = WorkerHealthCollector(cache_ttl=0)
collector.set_monitor(monitor)
up = collector.collect()[1]
assert len(up.samples) == 3
hostnames = {s.labels["hostname"] for s in up.samples}
assert hostnames == {"pod-1", "pod-2", "pod-3"}
assert all(s.labels["worker_type"] == "docprocessing" for s in up.samples)
def test_reports_dead_worker(self) -> None:
monitor = WorkerHeartbeatMonitor(MagicMock())
monitor._on_heartbeat({"hostname": "primary@host1"})
@@ -151,9 +175,9 @@ class TestWorkerHealthCollector:
assert active.samples[0].value == 1
up = families[1]
samples_by_name = {s.labels["worker"]: s.value for s in up.samples}
assert samples_by_name["primary"] == 1
assert samples_by_name["monitoring"] == 0
samples_by_type = {s.labels["worker_type"]: s.value for s in up.samples}
assert samples_by_type["primary"] == 1
assert samples_by_type["monitoring"] == 0
def test_empty_monitor_returns_zero(self) -> None:
monitor = WorkerHeartbeatMonitor(MagicMock())

View File

@@ -58,8 +58,7 @@ SERVICE_ORDER=(
validate_template() {
local template_file=$1
echo "Validating template: $template_file..."
aws cloudformation validate-template --template-body file://"$template_file" --region "$AWS_REGION" > /dev/null
if [ $? -ne 0 ]; then
if ! aws cloudformation validate-template --template-body file://"$template_file" --region "$AWS_REGION" > /dev/null; then
echo "Error: Validation failed for $template_file. Exiting."
exit 1
fi
@@ -108,13 +107,15 @@ deploy_stack() {
fi
# Create temporary parameters file for this template
local temp_params_file=$(create_parameters_from_json "$template_file")
local temp_params_file
temp_params_file=$(create_parameters_from_json "$template_file")
# Special handling for SubnetIDs parameter if needed
if grep -q "SubnetIDs" "$template_file"; then
echo "Template uses SubnetIDs parameter, ensuring it's properly formatted..."
# Make sure we're passing SubnetIDs as a comma-separated list
local subnet_ids=$(remove_comments "$CONFIG_FILE" | jq -r '.SubnetIDs // empty')
local subnet_ids
subnet_ids=$(remove_comments "$CONFIG_FILE" | jq -r '.SubnetIDs // empty')
if [ -n "$subnet_ids" ]; then
echo "Using SubnetIDs from config: $subnet_ids"
else
@@ -123,15 +124,13 @@ deploy_stack() {
fi
echo "Deploying stack: $stack_name with template: $template_file and generated config from: $CONFIG_FILE..."
aws cloudformation deploy \
if ! aws cloudformation deploy \
--stack-name "$stack_name" \
--template-file "$template_file" \
--parameter-overrides file://"$temp_params_file" \
--capabilities CAPABILITY_IAM CAPABILITY_NAMED_IAM CAPABILITY_AUTO_EXPAND \
--region "$AWS_REGION" \
--no-cli-auto-prompt > /dev/null
if [ $? -ne 0 ]; then
--no-cli-auto-prompt > /dev/null; then
echo "Error: Deployment failed for $stack_name. Exiting."
exit 1
fi

View File

@@ -52,11 +52,9 @@ delete_stack() {
--region "$AWS_REGION"
echo "Waiting for stack $stack_name to be deleted..."
aws cloudformation wait stack-delete-complete \
if aws cloudformation wait stack-delete-complete \
--stack-name "$stack_name" \
--region "$AWS_REGION"
if [ $? -eq 0 ]; then
--region "$AWS_REGION"; then
echo "Stack $stack_name deleted successfully."
sleep 10
else

View File

@@ -1,3 +1,4 @@
#!/bin/sh
# fill in the template
export ONYX_BACKEND_API_HOST="${ONYX_BACKEND_API_HOST:-api_server}"
export ONYX_WEB_SERVER_HOST="${ONYX_WEB_SERVER_HOST:-web_server}"
@@ -16,12 +17,15 @@ echo "Using web server host: $ONYX_WEB_SERVER_HOST"
echo "Using MCP server host: $ONYX_MCP_SERVER_HOST"
echo "Using nginx proxy timeouts - connect: ${NGINX_PROXY_CONNECT_TIMEOUT}s, send: ${NGINX_PROXY_SEND_TIMEOUT}s, read: ${NGINX_PROXY_READ_TIMEOUT}s"
# shellcheck disable=SC2016
envsubst '$DOMAIN $SSL_CERT_FILE_NAME $SSL_CERT_KEY_FILE_NAME $ONYX_BACKEND_API_HOST $ONYX_WEB_SERVER_HOST $ONYX_MCP_SERVER_HOST $NGINX_PROXY_CONNECT_TIMEOUT $NGINX_PROXY_SEND_TIMEOUT $NGINX_PROXY_READ_TIMEOUT' < "/etc/nginx/conf.d/$1" > /etc/nginx/conf.d/app.conf
# Conditionally create MCP server configuration
if [ "${MCP_SERVER_ENABLED}" = "True" ] || [ "${MCP_SERVER_ENABLED}" = "true" ]; then
echo "MCP server is enabled, creating MCP configuration..."
# shellcheck disable=SC2016
envsubst '$ONYX_MCP_SERVER_HOST' < "/etc/nginx/conf.d/mcp_upstream.conf.inc.template" > /etc/nginx/conf.d/mcp_upstream.conf.inc
# shellcheck disable=SC2016
envsubst '$ONYX_MCP_SERVER_HOST' < "/etc/nginx/conf.d/mcp.conf.inc.template" > /etc/nginx/conf.d/mcp.conf.inc
else
echo "MCP server is disabled, removing MCP configuration..."

View File

@@ -12,7 +12,7 @@ dependencies = [
"cohere==5.6.1",
"fastapi==0.133.1",
"google-genai==1.52.0",
"litellm==1.81.6",
"litellm[google]==1.81.6",
"openai==2.14.0",
"pydantic==2.11.7",
"prometheus_client>=0.21.1",

View File

@@ -48,6 +48,19 @@ func runWebScript(args []string) {
log.Fatalf("Failed to find web directory: %v", err)
}
nodeModules := filepath.Join(webDir, "node_modules")
if _, err := os.Stat(nodeModules); os.IsNotExist(err) {
log.Info("node_modules not found, running npm install --no-save...")
installCmd := exec.Command("npm", "install", "--no-save")
installCmd.Dir = webDir
installCmd.Stdout = os.Stdout
installCmd.Stderr = os.Stderr
installCmd.Stdin = os.Stdin
if err := installCmd.Run(); err != nil {
log.Fatalf("Failed to run npm install: %v", err)
}
}
scriptName := args[0]
scriptArgs := args[1:]
if len(scriptArgs) > 0 && scriptArgs[0] == "--" {

229
uv.lock generated
View File

@@ -2124,6 +2124,12 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ed/d4/90197b416cb61cefd316964fd9e7bd8324bcbafabf40eef14a9f20b81974/google_api_core-2.28.1-py3-none-any.whl", hash = "sha256:4021b0f8ceb77a6fb4de6fde4502cecab45062e66ff4f2895169e0b35bc9466c", size = 173706, upload-time = "2025-10-28T21:34:50.151Z" },
]
[package.optional-dependencies]
grpc = [
{ name = "grpcio" },
{ name = "grpcio-status" },
]
[[package]]
name = "google-api-python-client"
version = "2.86.0"
@@ -2181,6 +2187,124 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4a/07/8d9a8186e6768b55dfffeb57c719bc03770cf8a970a074616ae6f9e26a57/google_auth_oauthlib-1.0.0-py2.py3-none-any.whl", hash = "sha256:95880ca704928c300f48194d1770cf5b1462835b6e49db61445a520f793fd5fb", size = 18926, upload-time = "2023-02-07T20:53:18.837Z" },
]
[[package]]
name = "google-cloud-aiplatform"
version = "1.133.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "docstring-parser" },
{ name = "google-api-core", extra = ["grpc"] },
{ name = "google-auth" },
{ name = "google-cloud-bigquery" },
{ name = "google-cloud-resource-manager" },
{ name = "google-cloud-storage" },
{ name = "google-genai" },
{ name = "packaging" },
{ name = "proto-plus" },
{ name = "protobuf" },
{ name = "pydantic" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d4/be/31ce7fd658ddebafbe5583977ddee536b2bacc491ad10b5a067388aec66f/google_cloud_aiplatform-1.133.0.tar.gz", hash = "sha256:3a6540711956dd178daaab3c2c05db476e46d94ac25912b8cf4f59b00b058ae0", size = 9921309, upload-time = "2026-01-08T22:11:25.079Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/01/5b/ef74ff65aebb74eaba51078e33ddd897247ba0d1197fd5a7953126205519/google_cloud_aiplatform-1.133.0-py2.py3-none-any.whl", hash = "sha256:dfc81228e987ca10d1c32c7204e2131b3c8d6b7c8e0b4e23bf7c56816bc4c566", size = 8184595, upload-time = "2026-01-08T22:11:22.067Z" },
]
[[package]]
name = "google-cloud-bigquery"
version = "3.41.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "google-api-core", extra = ["grpc"] },
{ name = "google-auth" },
{ name = "google-cloud-core" },
{ name = "google-resumable-media" },
{ name = "packaging" },
{ name = "python-dateutil" },
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ce/13/6515c7aab55a4a0cf708ffd309fb9af5bab54c13e32dc22c5acd6497193c/google_cloud_bigquery-3.41.0.tar.gz", hash = "sha256:2217e488b47ed576360c9b2cc07d59d883a54b83167c0ef37f915c26b01a06fe", size = 513434, upload-time = "2026-03-30T22:50:55.347Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/40/33/1d3902efadef9194566d499d61507e1f038454e0b55499d2d7f8ab2a4fee/google_cloud_bigquery-3.41.0-py3-none-any.whl", hash = "sha256:2a5b5a737b401cbd824a6e5eac7554100b878668d908e6548836b5d8aaa4dcaa", size = 262343, upload-time = "2026-03-30T22:48:45.444Z" },
]
[[package]]
name = "google-cloud-core"
version = "2.5.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "google-api-core" },
{ name = "google-auth" },
]
sdist = { url = "https://files.pythonhosted.org/packages/dc/24/6ca08b0a03c7b0c620427503ab00353a4ae806b848b93bcea18b6b76fde6/google_cloud_core-2.5.1.tar.gz", hash = "sha256:3dc94bdec9d05a31d9f355045ed0f369fbc0d8c665076c734f065d729800f811", size = 36078, upload-time = "2026-03-30T22:50:08.057Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/73/d9/5bb050cb32826466aa9b25f79e2ca2879fe66cb76782d4ed798dd7506151/google_cloud_core-2.5.1-py3-none-any.whl", hash = "sha256:ea62cdf502c20e3e14be8a32c05ed02113d7bef454e40ff3fab6fe1ec9f1f4e7", size = 29452, upload-time = "2026-03-30T22:48:31.567Z" },
]
[[package]]
name = "google-cloud-resource-manager"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "google-api-core", extra = ["grpc"] },
{ name = "google-auth" },
{ name = "grpc-google-iam-v1" },
{ name = "grpcio" },
{ name = "proto-plus" },
{ name = "protobuf" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b2/1a/13060cabf553d52d151d2afc26b39561e82853380d499dd525a0d422d9f0/google_cloud_resource_manager-1.17.0.tar.gz", hash = "sha256:0f486b62e2c58ff992a3a50fa0f4a96eef7750aa6c971bb373398ccb91828660", size = 464971, upload-time = "2026-03-26T22:17:29.204Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3d/f7/661d7a9023e877a226b5683429c3662f75a29ef45cb1464cf39adb689218/google_cloud_resource_manager-1.17.0-py3-none-any.whl", hash = "sha256:e479baf4b014a57f298e01b8279e3290b032e3476d69c8e5e1427af8f82739a5", size = 404403, upload-time = "2026-03-26T22:15:26.57Z" },
]
[[package]]
name = "google-cloud-storage"
version = "3.10.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "google-api-core" },
{ name = "google-auth" },
{ name = "google-cloud-core" },
{ name = "google-crc32c" },
{ name = "google-resumable-media" },
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/4c/47/205eb8e9a1739b5345843e5a425775cbdc472cc38e7eda082ba5b8d02450/google_cloud_storage-3.10.1.tar.gz", hash = "sha256:97db9aa4460727982040edd2bd13ff3d5e2260b5331ad22895802da1fc2a5286", size = 17309950, upload-time = "2026-03-23T09:35:23.409Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ad/ff/ca9ab2417fa913d75aae38bf40bf856bb2749a604b2e0f701b37cfcd23cc/google_cloud_storage-3.10.1-py3-none-any.whl", hash = "sha256:a72f656759b7b99bda700f901adcb3425a828d4a29f911bc26b3ea79c5b1217f", size = 324453, upload-time = "2026-03-23T09:35:21.368Z" },
]
[[package]]
name = "google-crc32c"
version = "1.8.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/03/41/4b9c02f99e4c5fb477122cd5437403b552873f014616ac1d19ac8221a58d/google_crc32c-1.8.0.tar.gz", hash = "sha256:a428e25fb7691024de47fecfbff7ff957214da51eddded0da0ae0e0f03a2cf79", size = 14192, upload-time = "2025-12-16T00:35:25.142Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/ef/21ccfaab3d5078d41efe8612e0ed0bfc9ce22475de074162a91a25f7980d/google_crc32c-1.8.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:014a7e68d623e9a4222d663931febc3033c5c7c9730785727de2a81f87d5bab8", size = 31298, upload-time = "2025-12-16T00:20:32.241Z" },
{ url = "https://files.pythonhosted.org/packages/c5/b8/f8413d3f4b676136e965e764ceedec904fe38ae8de0cdc52a12d8eb1096e/google_crc32c-1.8.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:86cfc00fe45a0ac7359e5214a1704e51a99e757d0272554874f419f79838c5f7", size = 30872, upload-time = "2025-12-16T00:33:58.785Z" },
{ url = "https://files.pythonhosted.org/packages/f6/fd/33aa4ec62b290477181c55bb1c9302c9698c58c0ce9a6ab4874abc8b0d60/google_crc32c-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:19b40d637a54cb71e0829179f6cb41835f0fbd9e8eb60552152a8b52c36cbe15", size = 33243, upload-time = "2025-12-16T00:40:21.46Z" },
{ url = "https://files.pythonhosted.org/packages/71/03/4820b3bd99c9653d1a5210cb32f9ba4da9681619b4d35b6a052432df4773/google_crc32c-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:17446feb05abddc187e5441a45971b8394ea4c1b6efd88ab0af393fd9e0a156a", size = 33608, upload-time = "2025-12-16T00:40:22.204Z" },
{ url = "https://files.pythonhosted.org/packages/7c/43/acf61476a11437bf9733fb2f70599b1ced11ec7ed9ea760fdd9a77d0c619/google_crc32c-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:71734788a88f551fbd6a97be9668a0020698e07b2bf5b3aa26a36c10cdfb27b2", size = 34439, upload-time = "2025-12-16T00:35:20.458Z" },
{ url = "https://files.pythonhosted.org/packages/e9/5f/7307325b1198b59324c0fa9807cafb551afb65e831699f2ce211ad5c8240/google_crc32c-1.8.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:4b8286b659c1335172e39563ab0a768b8015e88e08329fa5321f774275fc3113", size = 31300, upload-time = "2025-12-16T00:21:56.723Z" },
{ url = "https://files.pythonhosted.org/packages/21/8e/58c0d5d86e2220e6a37befe7e6a94dd2f6006044b1a33edf1ff6d9f7e319/google_crc32c-1.8.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:2a3dc3318507de089c5384cc74d54318401410f82aa65b2d9cdde9d297aca7cb", size = 30867, upload-time = "2025-12-16T00:38:31.302Z" },
{ url = "https://files.pythonhosted.org/packages/ce/a9/a780cc66f86335a6019f557a8aaca8fbb970728f0efd2430d15ff1beae0e/google_crc32c-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14f87e04d613dfa218d6135e81b78272c3b904e2a7053b841481b38a7d901411", size = 33364, upload-time = "2025-12-16T00:40:22.96Z" },
{ url = "https://files.pythonhosted.org/packages/21/3f/3457ea803db0198c9aaca2dd373750972ce28a26f00544b6b85088811939/google_crc32c-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb5c869c2923d56cb0c8e6bcdd73c009c36ae39b652dbe46a05eb4ef0ad01454", size = 33740, upload-time = "2025-12-16T00:40:23.96Z" },
{ url = "https://files.pythonhosted.org/packages/df/c0/87c2073e0c72515bb8733d4eef7b21548e8d189f094b5dad20b0ecaf64f6/google_crc32c-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:3cc0c8912038065eafa603b238abf252e204accab2a704c63b9e14837a854962", size = 34437, upload-time = "2025-12-16T00:35:21.395Z" },
{ url = "https://files.pythonhosted.org/packages/d1/db/000f15b41724589b0e7bc24bc7a8967898d8d3bc8caf64c513d91ef1f6c0/google_crc32c-1.8.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:3ebb04528e83b2634857f43f9bb8ef5b2bbe7f10f140daeb01b58f972d04736b", size = 31297, upload-time = "2025-12-16T00:23:20.709Z" },
{ url = "https://files.pythonhosted.org/packages/d7/0d/8ebed0c39c53a7e838e2a486da8abb0e52de135f1b376ae2f0b160eb4c1a/google_crc32c-1.8.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:450dc98429d3e33ed2926fc99ee81001928d63460f8538f21a5d6060912a8e27", size = 30867, upload-time = "2025-12-16T00:43:14.628Z" },
{ url = "https://files.pythonhosted.org/packages/ce/42/b468aec74a0354b34c8cbf748db20d6e350a68a2b0912e128cabee49806c/google_crc32c-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3b9776774b24ba76831609ffbabce8cdf6fa2bd5e9df37b594221c7e333a81fa", size = 33344, upload-time = "2025-12-16T00:40:24.742Z" },
{ url = "https://files.pythonhosted.org/packages/1c/e8/b33784d6fc77fb5062a8a7854e43e1e618b87d5ddf610a88025e4de6226e/google_crc32c-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:89c17d53d75562edfff86679244830599ee0a48efc216200691de8b02ab6b2b8", size = 33694, upload-time = "2025-12-16T00:40:25.505Z" },
{ url = "https://files.pythonhosted.org/packages/92/b1/d3cbd4d988afb3d8e4db94ca953df429ed6db7282ed0e700d25e6c7bfc8d/google_crc32c-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:57a50a9035b75643996fbf224d6661e386c7162d1dfdab9bc4ca790947d1007f", size = 34435, upload-time = "2025-12-16T00:35:22.107Z" },
{ url = "https://files.pythonhosted.org/packages/21/88/8ecf3c2b864a490b9e7010c84fd203ec8cf3b280651106a3a74dd1b0ca72/google_crc32c-1.8.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:e6584b12cb06796d285d09e33f63309a09368b9d806a551d8036a4207ea43697", size = 31301, upload-time = "2025-12-16T00:24:48.527Z" },
{ url = "https://files.pythonhosted.org/packages/36/c6/f7ff6c11f5ca215d9f43d3629163727a272eabc356e5c9b2853df2bfe965/google_crc32c-1.8.0-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:f4b51844ef67d6cf2e9425983274da75f18b1597bb2c998e1c0a0e8d46f8f651", size = 30868, upload-time = "2025-12-16T00:48:12.163Z" },
{ url = "https://files.pythonhosted.org/packages/56/15/c25671c7aad70f8179d858c55a6ae8404902abe0cdcf32a29d581792b491/google_crc32c-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b0d1a7afc6e8e4635564ba8aa5c0548e3173e41b6384d7711a9123165f582de2", size = 33381, upload-time = "2025-12-16T00:40:26.268Z" },
{ url = "https://files.pythonhosted.org/packages/42/fa/f50f51260d7b0ef5d4898af122d8a7ec5a84e2984f676f746445f783705f/google_crc32c-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8b3f68782f3cbd1bce027e48768293072813469af6a61a86f6bb4977a4380f21", size = 33734, upload-time = "2025-12-16T00:40:27.028Z" },
{ url = "https://files.pythonhosted.org/packages/08/a5/7b059810934a09fb3ccb657e0843813c1fee1183d3bc2c8041800374aa2c/google_crc32c-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:d511b3153e7011a27ab6ee6bb3a5404a55b994dc1a7322c0b87b29606d9790e2", size = 34878, upload-time = "2025-12-16T00:35:23.142Z" },
{ url = "https://files.pythonhosted.org/packages/52/c5/c171e4d8c44fec1422d801a6d2e5d7ddabd733eeda505c79730ee9607f07/google_crc32c-1.8.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:87fa445064e7db928226b2e6f0d5304ab4cd0339e664a4e9a25029f384d9bb93", size = 28615, upload-time = "2025-12-16T00:40:29.298Z" },
{ url = "https://files.pythonhosted.org/packages/9c/97/7d75fe37a7a6ed171a2cf17117177e7aab7e6e0d115858741b41e9dd4254/google_crc32c-1.8.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f639065ea2042d5c034bf258a9f085eaa7af0cd250667c0635a3118e8f92c69c", size = 28800, upload-time = "2025-12-16T00:40:30.322Z" },
]
[[package]]
name = "google-genai"
version = "1.52.0"
@@ -2200,6 +2324,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/66/03f663e7bca7abe9ccfebe6cb3fe7da9a118fd723a5abb278d6117e7990e/google_genai-1.52.0-py3-none-any.whl", hash = "sha256:c8352b9f065ae14b9322b949c7debab8562982f03bf71d44130cd2b798c20743", size = 261219, upload-time = "2025-11-21T02:18:54.515Z" },
]
[[package]]
name = "google-resumable-media"
version = "2.8.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "google-crc32c" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3f/d1/b1ea14b93b6b78f57fc580125de44e9f593ab88dd2460f1a8a8d18f74754/google_resumable_media-2.8.2.tar.gz", hash = "sha256:f3354a182ebd193ae3f42e3ef95e6c9b10f128320de23ac7637236713b1acd70", size = 2164510, upload-time = "2026-03-30T23:34:25.369Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5e/f8/50bfaf4658431ff9de45c5c3935af7ab01157a4903c603cd0eee6e78e087/google_resumable_media-2.8.2-py3-none-any.whl", hash = "sha256:82b6d8ccd11765268cdd2a2123f417ec806b8eef3000a9a38dfe3033da5fb220", size = 81511, upload-time = "2026-03-30T23:34:09.671Z" },
]
[[package]]
name = "googleapis-common-protos"
version = "1.72.0"
@@ -2212,6 +2348,11 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c4/ab/09169d5a4612a5f92490806649ac8d41e3ec9129c636754575b3553f4ea4/googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038", size = 297515, upload-time = "2025-11-06T18:29:13.14Z" },
]
[package.optional-dependencies]
grpc = [
{ name = "grpcio" },
]
[[package]]
name = "greenlet"
version = "3.2.4"
@@ -2262,6 +2403,85 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e3/a5/6ddab2b4c112be95601c13428db1d8b6608a8b6039816f2ba09c346c08fc/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", size = 303425, upload-time = "2025-08-07T13:32:27.59Z" },
]
[[package]]
name = "grpc-google-iam-v1"
version = "0.14.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "googleapis-common-protos", extra = ["grpc"] },
{ name = "grpcio" },
{ name = "protobuf" },
]
sdist = { url = "https://files.pythonhosted.org/packages/44/4f/d098419ad0bfc06c9ce440575f05aa22d8973b6c276e86ac7890093d3c37/grpc_google_iam_v1-0.14.4.tar.gz", hash = "sha256:392b3796947ed6334e61171d9ab06bf7eb357f554e5fc7556ad7aab6d0e17038", size = 23706, upload-time = "2026-04-01T01:57:49.813Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/89/22/c2dd50c09bf679bd38173656cd4402d2511e563b33bc88f90009cf50613c/grpc_google_iam_v1-0.14.4-py3-none-any.whl", hash = "sha256:412facc320fcbd94034b4df3d557662051d4d8adfa86e0ddb4dca70a3f739964", size = 32675, upload-time = "2026-04-01T01:57:47.69Z" },
]
[[package]]
name = "grpcio"
version = "1.80.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b7/48/af6173dbca4454f4637a4678b67f52ca7e0c1ed7d5894d89d434fecede05/grpcio-1.80.0.tar.gz", hash = "sha256:29aca15edd0688c22ba01d7cc01cb000d72b2033f4a3c72a81a19b56fd143257", size = 12978905, upload-time = "2026-03-30T08:49:10.502Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/db/1d56e5f5823257b291962d6c0ce106146c6447f405b60b234c4f222a7cde/grpcio-1.80.0-cp311-cp311-linux_armv7l.whl", hash = "sha256:dfab85db094068ff42e2a3563f60ab3dddcc9d6488a35abf0132daec13209c8a", size = 6055009, upload-time = "2026-03-30T08:46:46.265Z" },
{ url = "https://files.pythonhosted.org/packages/6e/18/c83f3cad64c5ca63bca7e91e5e46b0d026afc5af9d0a9972472ceba294b3/grpcio-1.80.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:5c07e82e822e1161354e32da2662f741a4944ea955f9f580ec8fb409dd6f6060", size = 12035295, upload-time = "2026-03-30T08:46:49.099Z" },
{ url = "https://files.pythonhosted.org/packages/0f/8e/e14966b435be2dda99fbe89db9525ea436edc79780431a1c2875a3582644/grpcio-1.80.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ba0915d51fd4ced2db5ff719f84e270afe0e2d4c45a7bdb1e8d036e4502928c2", size = 6610297, upload-time = "2026-03-30T08:46:52.123Z" },
{ url = "https://files.pythonhosted.org/packages/cc/26/d5eb38f42ce0e3fdc8174ea4d52036ef8d58cc4426cb800f2610f625dd75/grpcio-1.80.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:3cb8130ba457d2aa09fa6b7c3ed6b6e4e6a2685fce63cb803d479576c4d80e21", size = 7300208, upload-time = "2026-03-30T08:46:54.859Z" },
{ url = "https://files.pythonhosted.org/packages/25/51/bd267c989f85a17a5b3eea65a6feb4ff672af41ca614e5a0279cc0ea381c/grpcio-1.80.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:09e5e478b3d14afd23f12e49e8b44c8684ac3c5f08561c43a5b9691c54d136ab", size = 6813442, upload-time = "2026-03-30T08:46:57.056Z" },
{ url = "https://files.pythonhosted.org/packages/9e/d9/d80eef735b19e9169e30164bbf889b46f9df9127598a83d174eb13a48b26/grpcio-1.80.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:00168469238b022500e486c1c33916acf2f2a9b2c022202cf8a1885d2e3073c1", size = 7414743, upload-time = "2026-03-30T08:46:59.682Z" },
{ url = "https://files.pythonhosted.org/packages/de/f2/567f5bd5054398ed6b0509b9a30900376dcf2786bd936812098808b49d8d/grpcio-1.80.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8502122a3cc1714038e39a0b071acb1207ca7844208d5ea0d091317555ee7106", size = 8426046, upload-time = "2026-03-30T08:47:02.474Z" },
{ url = "https://files.pythonhosted.org/packages/62/29/73ef0141b4732ff5eacd68430ff2512a65c004696997f70476a83e548e7e/grpcio-1.80.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ce1794f4ea6cc3ca29463f42d665c32ba1b964b48958a66497917fe9069f26e6", size = 7851641, upload-time = "2026-03-30T08:47:05.462Z" },
{ url = "https://files.pythonhosted.org/packages/46/69/abbfa360eb229a8623bab5f5a4f8105e445bd38ce81a89514ba55d281ad0/grpcio-1.80.0-cp311-cp311-win32.whl", hash = "sha256:51b4a7189b0bef2aa30adce3c78f09c83526cf3dddb24c6a96555e3b97340440", size = 4154368, upload-time = "2026-03-30T08:47:08.027Z" },
{ url = "https://files.pythonhosted.org/packages/6f/d4/ae92206d01183b08613e846076115f5ac5991bae358d2a749fa864da5699/grpcio-1.80.0-cp311-cp311-win_amd64.whl", hash = "sha256:02e64bb0bb2da14d947a49e6f120a75e947250aebe65f9629b62bb1f5c14e6e9", size = 4894235, upload-time = "2026-03-30T08:47:10.839Z" },
{ url = "https://files.pythonhosted.org/packages/5c/e8/a2b749265eb3415abc94f2e619bbd9e9707bebdda787e61c593004ec927a/grpcio-1.80.0-cp312-cp312-linux_armv7l.whl", hash = "sha256:c624cc9f1008361014378c9d776de7182b11fe8b2e5a81bc69f23a295f2a1ad0", size = 6015616, upload-time = "2026-03-30T08:47:13.428Z" },
{ url = "https://files.pythonhosted.org/packages/3e/97/b1282161a15d699d1e90c360df18d19165a045ce1c343c7f313f5e8a0b77/grpcio-1.80.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:f49eddcac43c3bf350c0385366a58f36bed8cc2c0ec35ef7b74b49e56552c0c2", size = 12014204, upload-time = "2026-03-30T08:47:15.873Z" },
{ url = "https://files.pythonhosted.org/packages/6e/5e/d319c6e997b50c155ac5a8cb12f5173d5b42677510e886d250d50264949d/grpcio-1.80.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d334591df610ab94714048e0d5b4f3dd5ad1bee74dfec11eee344220077a79de", size = 6563866, upload-time = "2026-03-30T08:47:18.588Z" },
{ url = "https://files.pythonhosted.org/packages/ae/f6/fdd975a2cb4d78eb67769a7b3b3830970bfa2e919f1decf724ae4445f42c/grpcio-1.80.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:0cb517eb1d0d0aaf1d87af7cc5b801d686557c1d88b2619f5e31fab3c2315921", size = 7273060, upload-time = "2026-03-30T08:47:21.113Z" },
{ url = "https://files.pythonhosted.org/packages/db/f0/a3deb5feba60d9538a962913e37bd2e69a195f1c3376a3dd44fe0427e996/grpcio-1.80.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4e78c4ac0d97dc2e569b2f4bcbbb447491167cb358d1a389fc4af71ab6f70411", size = 6782121, upload-time = "2026-03-30T08:47:23.827Z" },
{ url = "https://files.pythonhosted.org/packages/ca/84/36c6dcfddc093e108141f757c407902a05085e0c328007cb090d56646cdf/grpcio-1.80.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2ed770b4c06984f3b47eb0517b1c69ad0b84ef3f40128f51448433be904634cd", size = 7383811, upload-time = "2026-03-30T08:47:26.517Z" },
{ url = "https://files.pythonhosted.org/packages/7c/ef/f3a77e3dc5b471a0ec86c564c98d6adfa3510d38f8ee99010410858d591e/grpcio-1.80.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:256507e2f524092f1473071a05e65a5b10d84b82e3ff24c5b571513cfaa61e2f", size = 8393860, upload-time = "2026-03-30T08:47:29.439Z" },
{ url = "https://files.pythonhosted.org/packages/9b/8d/9d4d27ed7f33d109c50d6b5ce578a9914aa68edab75d65869a17e630a8d1/grpcio-1.80.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:9a6284a5d907c37db53350645567c522be314bac859a64a7a5ca63b77bb7958f", size = 7830132, upload-time = "2026-03-30T08:47:33.254Z" },
{ url = "https://files.pythonhosted.org/packages/14/e4/9990b41c6d7a44e1e9dee8ac11d7a9802ba1378b40d77468a7761d1ad288/grpcio-1.80.0-cp312-cp312-win32.whl", hash = "sha256:c71309cfce2f22be26aa4a847357c502db6c621f1a49825ae98aa0907595b193", size = 4140904, upload-time = "2026-03-30T08:47:35.319Z" },
{ url = "https://files.pythonhosted.org/packages/2f/2c/296f6138caca1f4b92a31ace4ae1b87dab692fc16a7a3417af3bb3c805bf/grpcio-1.80.0-cp312-cp312-win_amd64.whl", hash = "sha256:9fe648599c0e37594c4809d81a9e77bd138cc82eb8baa71b6a86af65426723ff", size = 4880944, upload-time = "2026-03-30T08:47:37.831Z" },
{ url = "https://files.pythonhosted.org/packages/2f/3a/7c3c25789e3f069e581dc342e03613c5b1cb012c4e8c7d9d5cf960a75856/grpcio-1.80.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:e9e408fc016dffd20661f0126c53d8a31c2821b5c13c5d67a0f5ed5de93319ad", size = 6017243, upload-time = "2026-03-30T08:47:40.075Z" },
{ url = "https://files.pythonhosted.org/packages/04/19/21a9806eb8240e174fd1ab0cd5b9aa948bb0e05c2f2f55f9d5d7405e6d08/grpcio-1.80.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:92d787312e613754d4d8b9ca6d3297e69994a7912a32fa38c4c4e01c272974b0", size = 12010840, upload-time = "2026-03-30T08:47:43.11Z" },
{ url = "https://files.pythonhosted.org/packages/18/3a/23347d35f76f639e807fb7a36fad3068aed100996849a33809591f26eca6/grpcio-1.80.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8ac393b58aa16991a2f1144ec578084d544038c12242da3a215966b512904d0f", size = 6567644, upload-time = "2026-03-30T08:47:46.806Z" },
{ url = "https://files.pythonhosted.org/packages/ff/40/96e07ecb604a6a67ae6ab151e3e35b132875d98bc68ec65f3e5ab3e781d7/grpcio-1.80.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:68e5851ac4b9afe07e7f84483803ad167852570d65326b34d54ca560bfa53fb6", size = 7277830, upload-time = "2026-03-30T08:47:49.643Z" },
{ url = "https://files.pythonhosted.org/packages/9b/e2/da1506ecea1f34a5e365964644b35edef53803052b763ca214ba3870c856/grpcio-1.80.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:873ff5d17d68992ef6605330127425d2fc4e77e612fa3c3e0ed4e668685e3140", size = 6783216, upload-time = "2026-03-30T08:47:52.817Z" },
{ url = "https://files.pythonhosted.org/packages/44/83/3b20ff58d0c3b7f6caaa3af9a4174d4023701df40a3f39f7f1c8e7c48f9d/grpcio-1.80.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2bea16af2750fd0a899bf1abd9022244418b55d1f37da2202249ba4ba673838d", size = 7385866, upload-time = "2026-03-30T08:47:55.687Z" },
{ url = "https://files.pythonhosted.org/packages/47/45/55c507599c5520416de5eefecc927d6a0d7af55e91cfffb2e410607e5744/grpcio-1.80.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba0db34f7e1d803a878284cd70e4c63cb6ae2510ba51937bf8f45ba997cefcf7", size = 8391602, upload-time = "2026-03-30T08:47:58.303Z" },
{ url = "https://files.pythonhosted.org/packages/10/bb/dd06f4c24c01db9cf11341b547d0a016b2c90ed7dbbb086a5710df7dd1d7/grpcio-1.80.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8eb613f02d34721f1acf3626dfdb3545bd3c8505b0e52bf8b5710a28d02e8aa7", size = 7826752, upload-time = "2026-03-30T08:48:01.311Z" },
{ url = "https://files.pythonhosted.org/packages/f9/1e/9d67992ba23371fd63d4527096eb8c6b76d74d52b500df992a3343fd7251/grpcio-1.80.0-cp313-cp313-win32.whl", hash = "sha256:93b6f823810720912fd131f561f91f5fed0fda372b6b7028a2681b8194d5d294", size = 4142310, upload-time = "2026-03-30T08:48:04.594Z" },
{ url = "https://files.pythonhosted.org/packages/cf/e6/283326a27da9e2c3038bc93eeea36fb118ce0b2d03922a9cda6688f53c5b/grpcio-1.80.0-cp313-cp313-win_amd64.whl", hash = "sha256:e172cf795a3ba5246d3529e4d34c53db70e888fa582a8ffebd2e6e48bc0cba50", size = 4882833, upload-time = "2026-03-30T08:48:07.363Z" },
{ url = "https://files.pythonhosted.org/packages/c5/6d/e65307ce20f5a09244ba9e9d8476e99fb039de7154f37fb85f26978b59c3/grpcio-1.80.0-cp314-cp314-linux_armv7l.whl", hash = "sha256:3d4147a97c8344d065d01bbf8b6acec2cf86fb0400d40696c8bdad34a64ffc0e", size = 6017376, upload-time = "2026-03-30T08:48:10.005Z" },
{ url = "https://files.pythonhosted.org/packages/69/10/9cef5d9650c72625a699c549940f0abb3c4bfdb5ed45a5ce431f92f31806/grpcio-1.80.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8e11f167935b3eb089ac9038e1a063e6d7dbe995c0bb4a661e614583352e76f", size = 12018133, upload-time = "2026-03-30T08:48:12.927Z" },
{ url = "https://files.pythonhosted.org/packages/04/82/983aabaad82ba26113caceeb9091706a0696b25da004fe3defb5b346e15b/grpcio-1.80.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f14b618fc30de822681ee986cfdcc2d9327229dc4c98aed16896761cacd468b9", size = 6574748, upload-time = "2026-03-30T08:48:16.386Z" },
{ url = "https://files.pythonhosted.org/packages/07/d7/031666ef155aa0bf399ed7e19439656c38bbd143779ae0861b038ce82abd/grpcio-1.80.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:4ed39fbdcf9b87370f6e8df4e39ca7b38b3e5e9d1b0013c7b6be9639d6578d14", size = 7277711, upload-time = "2026-03-30T08:48:19.627Z" },
{ url = "https://files.pythonhosted.org/packages/e8/43/f437a78f7f4f1d311804189e8f11fb311a01049b2e08557c1068d470cb2e/grpcio-1.80.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2dcc70e9f0ba987526e8e8603a610fb4f460e42899e74e7a518bf3c68fe1bf05", size = 6785372, upload-time = "2026-03-30T08:48:22.373Z" },
{ url = "https://files.pythonhosted.org/packages/93/3d/f6558e9c6296cb4227faa5c43c54a34c68d32654b829f53288313d16a86e/grpcio-1.80.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:448c884b668b868562b1bda833c5fce6272d26e1926ec46747cda05741d302c1", size = 7395268, upload-time = "2026-03-30T08:48:25.638Z" },
{ url = "https://files.pythonhosted.org/packages/06/21/0fdd77e84720b08843c371a2efa6f2e19dbebf56adc72df73d891f5506f0/grpcio-1.80.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a1dc80fe55685b4a543555e6eef975303b36c8db1023b1599b094b92aa77965f", size = 8392000, upload-time = "2026-03-30T08:48:28.974Z" },
{ url = "https://files.pythonhosted.org/packages/f5/68/67f4947ed55d2e69f2cc199ab9fd85e0a0034d813bbeef84df6d2ba4d4b7/grpcio-1.80.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:31b9ac4ad1aa28ffee5503821fafd09e4da0a261ce1c1281c6c8da0423c83b6e", size = 7828477, upload-time = "2026-03-30T08:48:32.054Z" },
{ url = "https://files.pythonhosted.org/packages/44/b6/8d4096691b2e385e8271911a0de4f35f0a6c7d05aff7098e296c3de86939/grpcio-1.80.0-cp314-cp314-win32.whl", hash = "sha256:367ce30ba67d05e0592470428f0ec1c31714cab9ef19b8f2e37be1f4c7d32fae", size = 4218563, upload-time = "2026-03-30T08:48:34.538Z" },
{ url = "https://files.pythonhosted.org/packages/e5/8c/bbe6baf2557262834f2070cf668515fa308b2d38a4bbf771f8f7872a7036/grpcio-1.80.0-cp314-cp314-win_amd64.whl", hash = "sha256:3b01e1f5464c583d2f567b2e46ff0d516ef979978f72091fd81f5ab7fa6e2e7f", size = 5019457, upload-time = "2026-03-30T08:48:37.308Z" },
]
[[package]]
name = "grpcio-status"
version = "1.80.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "googleapis-common-protos" },
{ name = "grpcio" },
{ name = "protobuf" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/ed/105f619bdd00cb47a49aa2feea6232ea2bbb04199d52a22cc6a7d603b5cb/grpcio_status-1.80.0.tar.gz", hash = "sha256:df73802a4c89a3ea88aa2aff971e886fccce162bc2e6511408b3d67a144381cd", size = 13901, upload-time = "2026-03-30T08:54:34.784Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/76/80/58cd2dfc19a07d022abe44bde7c365627f6c7cb6f692ada6c65ca437d09a/grpcio_status-1.80.0-py3-none-any.whl", hash = "sha256:4b56990363af50dbf2c2ebb80f1967185c07d87aa25aa2bea45ddb75fc181dbe", size = 14638, upload-time = "2026-03-30T08:54:01.569Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
@@ -3164,6 +3384,11 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e6/05/3516cc7386b220d388aa0bd833308c677e94eceb82b2756dd95e06f6a13f/litellm-1.81.6-py3-none-any.whl", hash = "sha256:573206ba194d49a1691370ba33f781671609ac77c35347f8a0411d852cf6341a", size = 12224343, upload-time = "2026-02-01T04:02:23.704Z" },
]
[package.optional-dependencies]
google = [
{ name = "google-cloud-aiplatform" },
]
[[package]]
name = "locket"
version = "1.0.0"
@@ -4204,7 +4429,7 @@ dependencies = [
{ name = "fastapi" },
{ name = "google-genai" },
{ name = "kubernetes" },
{ name = "litellm" },
{ name = "litellm", extra = ["google"] },
{ name = "openai" },
{ name = "prometheus-client" },
{ name = "prometheus-fastapi-instrumentator" },
@@ -4377,7 +4602,7 @@ requires-dist = [
{ name = "fastapi", specifier = "==0.133.1" },
{ name = "google-genai", specifier = "==1.52.0" },
{ name = "kubernetes", specifier = ">=31.0.0" },
{ name = "litellm", specifier = "==1.81.6" },
{ name = "litellm", extras = ["google"], specifier = "==1.81.6" },
{ name = "openai", specifier = "==2.14.0" },
{ name = "prometheus-client", specifier = ">=0.21.1" },
{ name = "prometheus-fastapi-instrumentator", specifier = "==7.1.0" },

View File

@@ -55,7 +55,7 @@ A two-axis layout component that automatically routes to the correct internal la
Wraps `Content` and adds a `rightChildren` slot. Accepts all `Content` props plus:
- `rightChildren`: `ReactNode` — actions rendered on the right
- `paddingVariant`: `SizeVariant` — controls outer padding
- `padding`: `SizeVariant` — controls outer padding
```typescript
<ContentAction
@@ -544,7 +544,7 @@ function UserCard({
## 4. Spacing Guidelines
**Prefer padding over margins for spacing. When a library component exposes a padding prop
(e.g., `paddingVariant`), use that prop instead of wrapping it in a `<div>` with padding classes.
(e.g., `padding`), use that prop instead of wrapping it in a `<div>` with padding classes.
If a library component does not expose a padding override and you find yourself adding a wrapper
div for spacing, consider updating the library component to accept one.**
@@ -553,7 +553,7 @@ divs that exist solely for spacing.
```typescript
// ✅ Good — use the component's padding prop
<ContentAction paddingVariant="md" ... />
<ContentAction padding="md" ... />
// ✅ Good — padding utilities when no component prop exists
<div className="p-4 space-y-2">

View File

@@ -68,9 +68,7 @@ SCRIPT_DIR="$(dirname "${BASH_SOURCE[0]}")"
# Run the conversion into a temp file so a failed run doesn't destroy an existing .tsx
TMPFILE="${BASE_NAME}.tsx.tmp"
bunx @svgr/cli "$SVG_FILE" --typescript --svgo-config "$SVGO_CONFIG" --template "${SCRIPT_DIR}/icon-template.js" > "$TMPFILE"
if [ $? -eq 0 ]; then
if bunx @svgr/cli "$SVG_FILE" --typescript --svgo-config "$SVGO_CONFIG" --template "${SCRIPT_DIR}/icon-template.js" > "$TMPFILE"; then
# Verify the temp file has content before replacing the destination
if [ ! -s "$TMPFILE" ]; then
rm -f "$TMPFILE"
@@ -84,16 +82,14 @@ if [ $? -eq 0 ]; then
# Using perl for cross-platform compatibility (works on macOS, Linux, Windows with WSL)
# Note: perl -i returns 0 even on some failures, so we validate the output
perl -i -pe 's/<svg/<svg width={size} height={size}/g' "${BASE_NAME}.tsx"
if [ $? -ne 0 ]; then
if ! perl -i -pe 's/<svg/<svg width={size} height={size}/g' "${BASE_NAME}.tsx"; then
echo "Error: Failed to add width/height attributes" >&2
exit 1
fi
# Icons additionally get stroke="currentColor"
if [ "$MODE" = "icon" ]; then
perl -i -pe 's/\{\.\.\.props\}/stroke="currentColor" {...props}/g' "${BASE_NAME}.tsx"
if [ $? -ne 0 ]; then
if ! perl -i -pe 's/\{\.\.\.props\}/stroke="currentColor" {...props}/g' "${BASE_NAME}.tsx"; then
echo "Error: Failed to add stroke attribute" >&2
exit 1
fi

View File

@@ -95,9 +95,9 @@ function Button({
<Interactive.Container
type={type}
border={interactiveProps.prominence === "secondary"}
heightVariant={size}
widthVariant={width}
roundingVariant={isLarge ? "md" : size === "2xs" ? "xs" : "sm"}
size={size}
width={width}
rounding={isLarge ? "md" : size === "2xs" ? "xs" : "sm"}
>
<div className="flex flex-row items-center gap-1">
{iconWrapper(Icon, size, !!children)}

View File

@@ -8,13 +8,13 @@ A composite component that wraps `Interactive.Stateful > Interactive.Container >
```
Interactive.Stateful <- selectVariant, state, interaction, onClick, href, ref
└─ Interactive.Container <- type, width, roundingVariant
└─ ContentAction <- withInteractive, paddingVariant="lg"
└─ Interactive.Container <- type, width, rounding
└─ ContentAction <- withInteractive, padding="lg"
├─ Content <- icon, title, description, sizePreset, variant, ...
└─ rightChildren
```
`paddingVariant` is hardcoded to `"lg"` and `withInteractive` is always `true`. These are not exposed as props.
`padding` is hardcoded to `"lg"` and `withInteractive` is always `true`. These are not exposed as props.
## Props
@@ -35,7 +35,7 @@ Interactive.Stateful <- selectVariant, state, interaction, onClick, href
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `roundingVariant` | `InteractiveContainerRoundingVariant` | `"md"` | Corner rounding preset (height is content-driven) |
| `rounding` | `InteractiveContainerRoundingVariant` | `"md"` | Corner rounding preset (height is content-driven) |
| `width` | `WidthVariant` | `"full"` | Container width |
| `type` | `"submit" \| "button" \| "reset"` | `"button"` | HTML button type |
| `tooltip` | `string` | — | Tooltip text shown on hover |
@@ -63,7 +63,7 @@ import { LineItemButton } from "@opal/components";
<LineItemButton
selectVariant="select-heavy"
state={isSelected ? "selected" : "empty"}
roundingVariant="sm"
rounding="sm"
onClick={handleClick}
title="gpt-4o"
sizePreset="main-ui"

View File

@@ -5,8 +5,7 @@ import {
} from "@opal/core";
import type { ExtremaSizeVariants, DistributiveOmit } from "@opal/types";
import { Tooltip, type TooltipSide } from "@opal/components";
import type { ContentActionProps } from "@opal/layouts/content-action/components";
import { ContentAction } from "@opal/layouts";
import { type ContentActionProps, ContentAction } from "@opal/layouts";
// ---------------------------------------------------------------------------
// Types
@@ -14,7 +13,7 @@ import { ContentAction } from "@opal/layouts";
type ContentPassthroughProps = DistributiveOmit<
ContentActionProps,
"paddingVariant" | "widthVariant" | "ref"
"padding" | "width" | "ref"
>;
type LineItemButtonOwnProps = Pick<
@@ -32,7 +31,7 @@ type LineItemButtonOwnProps = Pick<
selectVariant?: "select-light" | "select-heavy";
/** Corner rounding preset (height is always content-driven). @default "md" */
roundingVariant?: InteractiveContainerRoundingVariant;
rounding?: InteractiveContainerRoundingVariant;
/** Container width. @default "full" */
width?: ExtremaSizeVariants;
@@ -63,7 +62,7 @@ function LineItemButton({
type = "button",
// Sizing
roundingVariant = "md",
rounding = "md",
width = "full",
tooltip,
tooltipSide = "top",
@@ -84,14 +83,16 @@ function LineItemButton({
>
<Interactive.Container
type={type}
widthVariant={width}
heightVariant="lg"
roundingVariant={roundingVariant}
width={width}
size="fit"
rounding={rounding}
>
<ContentAction
{...(contentActionProps as ContentActionProps)}
paddingVariant="fit"
/>
<div className="w-full p-2">
<ContentAction
{...(contentActionProps as ContentActionProps)}
padding="fit"
/>
</div>
</Interactive.Container>
</Interactive.Stateful>
);

View File

@@ -70,7 +70,7 @@ type OpenButtonProps = Omit<InteractiveStatefulProps, "variant"> & {
tooltipSide?: TooltipSide;
/** Override the default rounding derived from `size`. */
roundingVariant?: InteractiveContainerRoundingVariant;
rounding?: InteractiveContainerRoundingVariant;
/** Applies disabled styling and suppresses clicks. */
disabled?: boolean;
@@ -89,7 +89,7 @@ function OpenButton({
justifyContent,
tooltip,
tooltipSide = "top",
roundingVariant: roundingVariantOverride,
rounding: roundingOverride,
interaction,
variant = "select-heavy",
disabled,
@@ -123,11 +123,10 @@ function OpenButton({
>
<Interactive.Container
type="button"
heightVariant={size}
widthVariant={width}
roundingVariant={
roundingVariantOverride ??
(isLarge ? "md" : size === "2xs" ? "xs" : "sm")
size={size}
width={width}
rounding={
roundingOverride ?? (isLarge ? "md" : size === "2xs" ? "xs" : "sm")
}
>
<div

View File

@@ -96,9 +96,9 @@ function SelectButton({
<Interactive.Stateful disabled={disabled} {...statefulProps}>
<Interactive.Container
type={type}
heightVariant={size}
widthVariant={width}
roundingVariant={isLarge ? "md" : size === "2xs" ? "xs" : "sm"}
size={size}
width={width}
rounding={isLarge ? "md" : size === "2xs" ? "xs" : "sm"}
>
<div
className={cn(

View File

@@ -93,12 +93,7 @@ function SidebarTab({
type="button"
group="group/SidebarTab"
>
<Interactive.Container
roundingVariant="sm"
heightVariant="lg"
widthVariant="full"
type={type}
>
<Interactive.Container rounding="sm" size="lg" width="full" type={type}>
{href && !disabled && (
<Link
href={href as Route}
@@ -120,8 +115,8 @@ function SidebarTab({
title={folded ? "" : children}
sizePreset="main-ui"
variant="body"
widthVariant="full"
paddingVariant="fit"
width="full"
padding="fit"
rightChildren={truncationSpacer}
/>
) : (

View File

@@ -55,7 +55,7 @@ export const PaddingVariants: Story = {
<div className="flex flex-col gap-4 w-96">
{PADDING_VARIANTS.map((padding) => (
<Card key={padding} padding={padding} border="solid">
<p>paddingVariant: {padding}</p>
<p>padding: {padding}</p>
</Card>
))}
</div>
@@ -67,7 +67,7 @@ export const RoundingVariants: Story = {
<div className="flex flex-col gap-4 w-96">
{ROUNDING_VARIANTS.map((rounding) => (
<Card key={rounding} rounding={rounding} border="solid">
<p>roundingVariant: {rounding}</p>
<p>rounding: {rounding}</p>
</Card>
))}
</div>
@@ -79,7 +79,7 @@ export const AllCombinations: Story = {
<div className="flex flex-col gap-8">
{PADDING_VARIANTS.map((padding) => (
<div key={padding}>
<p className="font-bold pb-2">paddingVariant: {padding}</p>
<p className="font-bold pb-2">padding: {padding}</p>
<div className="grid grid-cols-3 gap-4">
{BACKGROUND_VARIANTS.map((bg) =>
BORDER_VARIANTS.map((border) => (

View File

@@ -3,9 +3,11 @@ import "@opal/components/cards/message-card/styles.css";
import { cn } from "@opal/utils";
import type {
IconFunctionComponent,
PaddingVariants,
RichStr,
StatusVariants,
} from "@opal/types";
import { paddingVariants } from "@opal/shared";
import { ContentAction } from "@opal/layouts";
import { Button, Divider } from "@opal/components";
import {
@@ -33,6 +35,9 @@ interface MessageCardBaseProps {
/** Optional description below the title. */
description?: string | RichStr;
/** Padding preset. @default "sm" */
padding?: Extract<PaddingVariants, "sm" | "xs">;
/**
* Content rendered below a divider, under the main content area.
* When provided, a `Divider` is inserted between the `ContentAction` and this node.
@@ -116,6 +121,7 @@ function MessageCard({
icon: iconOverride,
title,
description,
padding = "sm",
bottomChildren,
rightChildren,
onClose,
@@ -137,7 +143,11 @@ function MessageCard({
);
return (
<div className="opal-message-card" data-variant={variant} ref={ref}>
<div
className={cn("opal-message-card", paddingVariants[padding])}
data-variant={variant}
ref={ref}
>
<ContentAction
icon={(props) => (
<Icon {...props} className={cn(props.className, iconClass)} />
@@ -146,7 +156,7 @@ function MessageCard({
description={description}
sizePreset="main-ui"
variant="section"
paddingVariant="lg"
padding="md"
rightChildren={right}
/>

View File

@@ -1,5 +1,5 @@
.opal-message-card {
@apply flex flex-col self-stretch rounded-16 border p-2;
@apply flex flex-col w-full self-stretch rounded-16 border;
}
/* Variant colors */

View File

@@ -209,7 +209,7 @@ export const PaddingVariants: Story = {
sizePreset="main-ui"
variant="section"
icon={SvgGlobe}
title={`paddingVariant: ${padding}`}
title={`padding: ${padding}`}
description="Shows padding differences."
/>
</SelectCard>
@@ -227,7 +227,7 @@ export const RoundingVariants: Story = {
sizePreset="main-ui"
variant="section"
icon={SvgGlobe}
title={`roundingVariant: ${rounding}`}
title={`rounding: ${rounding}`}
description="Shows rounding differences."
/>
</SelectCard>

View File

@@ -167,11 +167,7 @@ function FoldableDivider({
interaction={isOpen ? "hover" : "rest"}
onClick={toggle}
>
<Interactive.Container
roundingVariant="sm"
heightVariant="fit"
widthVariant="full"
>
<Interactive.Container rounding="sm" size="fit" width="full">
<div className="opal-divider">
<div className="opal-divider-row">
<div className="opal-divider-title">

View File

@@ -22,7 +22,7 @@ interface TableProps
/** Row selection behavior. @default "no-select" */
selectionBehavior?: SelectionBehavior;
/** Height behavior. `"fit"` = shrink to content, `"full"` = fill available space. */
heightVariant?: ExtremaSizeVariants;
size?: ExtremaSizeVariants;
/** Explicit pixel width for the table (e.g. from `table.getTotalSize()`).
* When provided the table uses exactly this width instead of stretching
* to fill its container, which prevents `table-layout: fixed` from
@@ -38,7 +38,7 @@ function Table({
ref,
variant = "cards",
selectionBehavior = "no-select",
heightVariant,
size: heightVariant,
width,
...props
}: TableProps) {

View File

@@ -17,7 +17,7 @@ interface HoverableRootProps
children: React.ReactNode;
group: string;
/** Width preset. @default "auto" */
widthVariant?: ExtremaSizeVariants;
width?: ExtremaSizeVariants;
/**
* JS-controllable interaction state override.
*
@@ -70,7 +70,7 @@ interface HoverableItemProps
function HoverableRoot({
group,
children,
widthVariant = "full",
width = "full",
interaction = "rest",
ref,
...props
@@ -79,7 +79,7 @@ function HoverableRoot({
<div
{...props}
ref={ref}
className={cn(widthVariants[widthVariant])}
className={cn(widthVariants[width])}
data-hover-group={group}
data-interaction={interaction !== "rest" ? interaction : undefined}
>

View File

@@ -135,7 +135,7 @@ export const VariantMatrix: StoryObj = {
),
};
/** All heightVariant sizes (lg, md, sm, xs, 2xs, fit). */
/** All size presets (lg, md, sm, xs, 2xs, fit). */
export const Sizes: StoryObj = {
render: () => (
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
@@ -146,7 +146,7 @@ export const Sizes: StoryObj = {
prominence="secondary"
onClick={() => {}}
>
<Interactive.Container border heightVariant={size}>
<Interactive.Container border size={size}>
<span>{size}</span>
</Interactive.Container>
</Interactive.Stateless>
@@ -155,7 +155,7 @@ export const Sizes: StoryObj = {
),
};
/** Container with widthVariant="full" stretching to fill its parent. */
/** Container with width="full" stretching to fill its parent. */
export const WidthFull: StoryObj = {
render: () => (
<div style={{ width: 400 }}>
@@ -164,7 +164,7 @@ export const WidthFull: StoryObj = {
prominence="secondary"
onClick={() => {}}
>
<Interactive.Container border widthVariant="full">
<Interactive.Container border width="full">
<span>Full width container</span>
</Interactive.Container>
</Interactive.Stateless>
@@ -183,7 +183,7 @@ export const Rounding: StoryObj = {
prominence="secondary"
onClick={() => {}}
>
<Interactive.Container border roundingVariant={rounding}>
<Interactive.Container border rounding={rounding}>
<span>{rounding}</span>
</Interactive.Container>
</Interactive.Stateless>

View File

@@ -8,9 +8,9 @@ Structural container shared by both `Interactive.Stateless` and `Interactive.Sta
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `heightVariant` | `SizeVariant` | `"lg"` | Height preset (`2xs``lg`, `fit`) |
| `roundingVariant` | `"md" \| "sm" \| "xs"` | `"md"` | Border-radius preset |
| `widthVariant` | `WidthVariant` | — | Width preset (`"auto"`, `"fit"`, `"full"`) |
| `size` | `SizeVariant` | `"lg"` | Height preset (`2xs``lg`, `fit`) |
| `rounding` | `"md" \| "sm" \| "xs"` | `"md"` | Border-radius preset |
| `width` | `WidthVariant` | — | Width preset (`"auto"`, `"fit"`, `"full"`) |
| `border` | `boolean` | `false` | Renders a 1px border |
| `type` | `"submit" \| "button" \| "reset"` | — | When set, renders a `<button>` element |
@@ -18,7 +18,7 @@ Structural container shared by both `Interactive.Stateless` and `Interactive.Sta
```tsx
<Interactive.Stateless variant="default" prominence="primary">
<Interactive.Container heightVariant="sm" roundingVariant="sm" border>
<Interactive.Container size="sm" rounding="sm" border>
<span>Content</span>
</Interactive.Container>
</Interactive.Stateless>

View File

@@ -63,21 +63,21 @@ interface InteractiveContainerProps
*
* @default "default"
*/
roundingVariant?: InteractiveContainerRoundingVariant;
rounding?: InteractiveContainerRoundingVariant;
/**
* Size preset controlling the container's height, min-width, and padding.
*
* @default "lg"
*/
heightVariant?: ContainerSizeVariants;
size?: ContainerSizeVariants;
/**
* Width preset controlling the container's horizontal size.
*
* @default "fit"
*/
widthVariant?: ExtremaSizeVariants;
width?: ExtremaSizeVariants;
}
// ---------------------------------------------------------------------------
@@ -96,9 +96,9 @@ function InteractiveContainer({
ref,
type,
border,
roundingVariant = "md",
heightVariant = "lg",
widthVariant = "fit",
rounding = "md",
size = "lg",
width = "fit",
...props
}: InteractiveContainerProps) {
const {
@@ -115,16 +115,16 @@ function InteractiveContainer({
target?: string;
rel?: string;
};
const { height, minWidth, padding } = containerSizeVariants[heightVariant];
const { height, minWidth, padding } = containerSizeVariants[size];
const sharedProps = {
...rest,
className: cn(
"interactive-container",
interactiveContainerRoundingVariants[roundingVariant],
interactiveContainerRoundingVariants[rounding],
height,
minWidth,
padding,
widthVariants[widthVariant],
widthVariants[width],
slotClassName
),
"data-border": border ? ("true" as const) : undefined,

View File

@@ -46,7 +46,7 @@ import SvgNoResult from "@opal/illustrations/no-result";
description="Some description"
sizePreset="main-content"
variant="section"
paddingVariant="lg"
padding="lg"
rightChildren={
<Button icon={SvgSettings} prominence="tertiary" />
}

View File

@@ -63,7 +63,7 @@ export const NoPadding: Story = {
variant: "section",
title: "Compact Row",
description: "No padding around content area.",
paddingVariant: "fit",
padding: "fit",
rightChildren: <Button prominence="tertiary">Action</Button>,
},
};

View File

@@ -15,9 +15,9 @@ Inherits **all** props from [`Content`](../content/README.md) (same discriminate
| Prop | Type | Default | Description |
|---|---|---|---|
| `rightChildren` | `ReactNode` | `undefined` | Content rendered on the right side. Wrapper stretches to the full height of the row. |
| `paddingVariant` | `SizeVariant` | `"lg"` | Padding preset applied around the `Content` area. Uses the shared size scale from `@opal/shared`. |
| `padding` | `SizeVariant` | `"lg"` | Padding preset applied around the `Content` area. Uses the shared size scale from `@opal/shared`. |
### `paddingVariant` reference
### `padding` reference
| Value | Padding class | Effective padding |
|---|---|---|
@@ -37,7 +37,7 @@ These values are identical to the padding applied by `Interactive.Container` at
```
- The outer wrapper is `flex flex-row items-stretch w-full`.
- `Content` sits inside a `flex-1 min-w-0` div with padding from `paddingVariant`.
- `Content` sits inside a `flex-1 min-w-0` div with padding from `padding`.
- `rightChildren` is wrapped in `flex items-stretch shrink-0` so it stretches vertically.
## Usage Examples
@@ -56,7 +56,7 @@ import SvgSettings from "@opal/icons/settings";
sizePreset="main-content"
variant="section"
tag={{ title: "Default", color: "blue" }}
paddingVariant="lg"
padding="lg"
rightChildren={
<Button icon={SvgSettings} prominence="tertiary" onClick={handleEdit} />
}
@@ -76,7 +76,7 @@ import { SvgArrowExchange, SvgCloud } from "@opal/icons";
description="Gemini"
sizePreset="main-content"
variant="section"
paddingVariant="md"
padding="md"
rightChildren={
<Button rightIcon={SvgArrowExchange} prominence="tertiary">
Connect
@@ -92,7 +92,7 @@ import { SvgArrowExchange, SvgCloud } from "@opal/icons";
title="Section Header"
sizePreset="main-content"
variant="section"
paddingVariant="lg"
padding="lg"
/>
```

View File

@@ -20,7 +20,7 @@ type ContentActionProps = ContentProps & {
* @default "lg"
* @see {@link ContainerSizeVariants} for the full list of presets.
*/
paddingVariant?: ContainerSizeVariants;
padding?: ContainerSizeVariants;
};
// ---------------------------------------------------------------------------
@@ -31,7 +31,7 @@ type ContentActionProps = ContentProps & {
* A row layout that pairs a {@link Content} block with optional right-side
* action children (e.g. buttons, badges).
*
* The `Content` area receives padding controlled by `paddingVariant`, using
* The `Content` area receives padding controlled by `padding`, using
* the same size scale as `Interactive.Container` and `Button`. The
* `rightChildren` wrapper stretches to the full height of the row.
*
@@ -47,21 +47,21 @@ type ContentActionProps = ContentProps & {
* description="GPT"
* sizePreset="main-content"
* variant="section"
* paddingVariant="lg"
* padding="lg"
* rightChildren={<Button icon={SvgSettings} prominence="tertiary" />}
* />
* ```
*/
function ContentAction({
rightChildren,
paddingVariant = "lg",
padding = "lg",
...contentProps
}: ContentActionProps) {
const { padding } = containerSizeVariants[paddingVariant];
const { padding: paddingClass } = containerSizeVariants[padding];
return (
<div className="flex flex-row items-stretch w-full">
<div className={cn("flex-1 min-w-0 self-center", padding)}>
<div className={cn("flex-1 min-w-0 self-center", paddingClass)}>
<Content {...contentProps} />
</div>
{rightChildren && (

View File

@@ -184,7 +184,7 @@ export const SmMuted: Story = {
};
// ---------------------------------------------------------------------------
// widthVariant: full
// width: full
// ---------------------------------------------------------------------------
export const WidthFull: Story = {
@@ -192,7 +192,7 @@ export const WidthFull: Story = {
sizePreset: "main-content",
variant: "section",
title: "Full Width Content",
widthVariant: "full",
width: "full",
},
decorators: [
(Story) => (

View File

@@ -58,9 +58,20 @@ interface ContentBaseProps {
* - `"fit"` — Shrink-wraps to content width
* - `"full"` — Stretches to fill the parent's width
*
* @default "fit"
* @default "full"
*/
widthVariant?: ExtremaSizeVariants;
width?: ExtremaSizeVariants;
/**
* Opt out of the automatic interactive color override.
*
* When `Content` is nested inside an `.interactive` element, its title and
* icon colors are normally overridden by the interactive foreground palette.
* Set this to `true` to keep Content's own colors regardless of context.
*
* @default false
*/
nonInteractive?: boolean;
/** Ref forwarded to the root `<div>` of the resolved layout. */
ref?: React.Ref<HTMLDivElement>;
@@ -126,7 +137,8 @@ function Content(props: ContentProps) {
const {
sizePreset = "headline",
variant = "heading",
widthVariant = "full",
width = "full",
nonInteractive,
ref,
...rest
} = props;
@@ -186,7 +198,14 @@ function Content(props: ContentProps) {
`Content: no layout matched for sizePreset="${sizePreset}" variant="${variant}"`
);
return <div className={widthVariants[widthVariant]}>{layout}</div>;
return (
<div
className={widthVariants[width]}
data-opal-non-interactive={nonInteractive || undefined}
>
{layout}
</div>
);
}
// ---------------------------------------------------------------------------

View File

@@ -352,36 +352,52 @@
the title inherits color from the Interactive's `--interactive-foreground`
and icons switch to `--interactive-foreground-icon`. This is automatic —
no opt-in prop is required.
Opt-out: set `nonInteractive` on <Content> to add
`data-opal-non-interactive` to the wrapper div, which excludes
the element from these overrides via the `:not(…) >` selector.
=========================================================================== */
.interactive .opal-content-xl {
.interactive :not([data-opal-non-interactive]) > .opal-content-xl {
color: inherit;
}
.interactive .opal-content-xl .opal-content-xl-icon {
.interactive
:not([data-opal-non-interactive])
> .opal-content-xl
.opal-content-xl-icon {
color: var(--interactive-foreground-icon);
}
.interactive .opal-content-lg {
.interactive :not([data-opal-non-interactive]) > .opal-content-lg {
color: inherit;
}
.interactive .opal-content-lg .opal-content-lg-icon {
.interactive
:not([data-opal-non-interactive])
> .opal-content-lg
.opal-content-lg-icon {
color: var(--interactive-foreground-icon);
}
.interactive .opal-content-md {
.interactive :not([data-opal-non-interactive]) > .opal-content-md {
color: inherit;
}
.interactive .opal-content-md .opal-content-md-icon {
.interactive
:not([data-opal-non-interactive])
> .opal-content-md
.opal-content-md-icon {
color: var(--interactive-foreground-icon);
}
.interactive .opal-content-sm {
.interactive :not([data-opal-non-interactive]) > .opal-content-sm {
color: inherit;
}
.interactive .opal-content-sm .opal-content-sm-icon {
.interactive
:not([data-opal-non-interactive])
> .opal-content-sm
.opal-content-sm-icon {
color: var(--interactive-foreground-icon);
}

View File

@@ -152,7 +152,7 @@ function Horizontal({
tag={tag}
sizePreset="main-ui"
variant="section"
widthVariant="full"
width="full"
/>
</div>
<div className="flex flex-col items-end">{children}</div>

View File

@@ -57,9 +57,9 @@ const containerSizeVariants: Record<
// A named scale of width/height presets that map to Tailwind width/height utility classes.
//
// Consumers (for width):
// - Interactive.Container (widthVariant)
// - Interactive.Container (width)
// - Button (width)
// - Content (widthVariant)
// - Content (width)
// ---------------------------------------------------------------------------
/**
@@ -96,8 +96,8 @@ const heightVariants: Record<ExtremaSizeVariants, string> = {
// Shared padding and rounding scales for card components (Card, SelectCard).
//
// Consumers:
// - Card (paddingVariant, roundingVariant)
// - SelectCard (paddingVariant, roundingVariant)
// - Card (padding, rounding)
// - SelectCard (padding, rounding)
// ---------------------------------------------------------------------------
const paddingVariants: Record<PaddingVariants, string> = {

View File

@@ -152,6 +152,11 @@ const nextConfig = {
destination: "/ee/agents/:path*",
permanent: true,
},
{
source: "/admin/configuration/llm",
destination: "/admin/configuration/language-models",
permanent: true,
},
];
},
};

View File

@@ -15,6 +15,9 @@ import InputSelect from "@/refresh-components/inputs/InputSelect";
import Button from "@/refresh-components/buttons/Button";
import { errorHandlingFetcher } from "@/lib/fetcher";
const TOGGLE_DISABLED_MESSAGE =
"Changing the retrieval source is not currently possible for this instance of Onyx.";
interface MigrationStatus {
total_chunks_migrated: number;
created_at: string | null;
@@ -24,6 +27,7 @@ interface MigrationStatus {
interface RetrievalStatus {
enable_opensearch_retrieval: boolean;
toggling_retrieval_is_disabled?: boolean;
}
function formatTimestamp(iso: string): string {
@@ -133,6 +137,7 @@ function RetrievalSourceSection() {
: "vespa";
const currentValue = selectedSource ?? serverValue;
const hasChanges = selectedSource !== null && selectedSource !== serverValue;
const togglingDisabled = data?.toggling_retrieval_is_disabled ?? false;
async function handleUpdate() {
setUpdating(true);
@@ -188,7 +193,7 @@ function RetrievalSourceSection() {
<InputSelect
value={currentValue}
onValueChange={setSelectedSource}
disabled={updating}
disabled={updating || togglingDisabled}
>
<InputSelect.Trigger placeholder="Select retrieval source" />
<InputSelect.Content>
@@ -197,6 +202,12 @@ function RetrievalSourceSection() {
</InputSelect.Content>
</InputSelect>
{togglingDisabled && (
<Text mainUiBody text03>
{TOGGLE_DISABLED_MESSAGE}
</Text>
)}
{hasChanges && (
// TODO(@raunakab): migrate to opal Button once className/iconClassName is resolved
<Button

View File

@@ -40,7 +40,7 @@ export default function ScimSyncCard({
description="Connect your identity provider to import and sync users and groups."
sizePreset="main-ui"
variant="section"
paddingVariant="fit"
padding="fit"
rightChildren={
hasToken ? (
<Button

View File

@@ -77,7 +77,7 @@ export const InMessageImage = memo(function InMessageImage({
onOpenChange={(open) => setFullImageShowing(open)}
/>
<Hoverable.Root group="messageImage" widthVariant="fit">
<Hoverable.Root group="messageImage" width="fit">
<div className={cn("relative", shapeContainerClasses)}>
{!imageLoaded && (
<div className="absolute inset-0 bg-background-tint-02 animate-pulse rounded-lg" />

View File

@@ -133,7 +133,7 @@ export default function ProjectContextPanel({
<div className="flex flex-col gap-6 w-full max-w-[var(--app-page-main-content-width)] mx-auto p-4 pt-14 pb-6">
<div className="flex flex-col gap-1 text-text-04">
<SvgFolderOpen className="h-8 w-8 text-text-04" />
<Hoverable.Root group="projectName" widthVariant="fit">
<Hoverable.Root group="projectName" width="fit">
<div className="flex items-center gap-2">
{isEditingName ? (
<ButtonRenaming

View File

@@ -198,7 +198,7 @@ const HumanMessage = React.memo(function HumanMessage({
);
return (
<Hoverable.Root group="humanMessage" widthVariant="full">
<Hoverable.Root group="humanMessage" width="full">
<div
id="onyx-human-message"
className="flex flex-col justify-end w-full relative"

View File

@@ -97,7 +97,7 @@ export default function MultiModelPanel({
<ContentAction
sizePreset="main-ui"
variant="body"
paddingVariant="lg"
padding="lg"
icon={ModelIcon}
title={isHidden ? markdown(`~~${displayName}~~`) : displayName}
rightChildren={

View File

@@ -71,7 +71,7 @@ function MemoryTagWithTooltip({
icon={SvgAddLines}
title={operationLabel}
sizePreset="secondary"
paddingVariant="sm"
padding="sm"
variant="body"
prominence="muted"
rightChildren={

View File

@@ -138,7 +138,7 @@ export default function ShareButton({
description={opt.description}
sizePreset="main-ui"
variant="section"
paddingVariant="sm"
padding="sm"
/>
</div>
))}

View File

@@ -7,4 +7,6 @@
--container-md: 54.5rem;
--container-lg: 62rem;
--container-full: 100%;
--toast-width: 25rem;
}

View File

@@ -671,7 +671,7 @@ export default function NRFPage({ isSidePanel = false }: NRFPageProps) {
width="full"
prominence="secondary"
onClick={() => {
window.location.href = "/admin/configuration/llm";
window.location.href = "/admin/configuration/language-models";
}}
>
Set up an LLM.

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