mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-04-18 07:56:48 +00:00
Compare commits
13 Commits
jamison/wo
...
v3.2.0-clo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8ffd7fbb56 | ||
|
|
f9e88e3c72 | ||
|
|
97efdbbbc3 | ||
|
|
b91a3aed53 | ||
|
|
51480e1099 | ||
|
|
70efbef95e | ||
|
|
f3936e2669 | ||
|
|
c933c71b59 | ||
|
|
e0d9e109b5 | ||
|
|
66c361bd37 | ||
|
|
01cbea8c4b | ||
|
|
2dc2b0da84 | ||
|
|
4b58c9cda6 |
2
.github/workflows/pr-quality-checks.yml
vendored
2
.github/workflows/pr-quality-checks.yml
vendored
@@ -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' || '' }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()}")
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -327,6 +327,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 = (
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
41
backend/onyx/mcp_server/resources/document_sets.py
Normal file
41
backend/onyx/mcp_server/resources/document_sets.py
Normal 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])
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -19,3 +19,4 @@ class OpenSearchRetrievalStatusRequest(BaseModel):
|
||||
class OpenSearchRetrievalStatusResponse(BaseModel):
|
||||
model_config = {"frozen": True}
|
||||
enable_opensearch_retrieval: bool
|
||||
toggling_retrieval_is_disabled: bool = False
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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",
|
||||
|
||||
229
uv.lock
generated
229
uv.lock
generated
@@ -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" },
|
||||
|
||||
@@ -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"
|
||||
paddingVariant="md"
|
||||
rightChildren={right}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -152,6 +152,11 @@ const nextConfig = {
|
||||
destination: "/ee/agents/:path*",
|
||||
permanent: true,
|
||||
},
|
||||
{
|
||||
source: "/admin/configuration/llm",
|
||||
destination: "/admin/configuration/language-models",
|
||||
permanent: true,
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -7,4 +7,6 @@
|
||||
--container-md: 54.5rem;
|
||||
--container-lg: 62rem;
|
||||
--container-full: 100%;
|
||||
|
||||
--toast-width: 25rem;
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -126,7 +126,7 @@ export const ADMIN_ROUTES = {
|
||||
sidebarLabel: "Chat Preferences",
|
||||
},
|
||||
LLM_MODELS: {
|
||||
path: "/admin/configuration/llm",
|
||||
path: "/admin/configuration/language-models",
|
||||
icon: SvgCpu,
|
||||
title: "Language Models",
|
||||
sidebarLabel: "Language Models",
|
||||
|
||||
@@ -51,11 +51,7 @@ function ToastContainer() {
|
||||
return (
|
||||
<div
|
||||
data-testid="toast-container"
|
||||
className={cn(
|
||||
"fixed bottom-4 right-4 z-[10000]",
|
||||
"flex flex-col gap-2 items-end",
|
||||
"max-w-[420px]"
|
||||
)}
|
||||
className="fixed bottom-4 right-4 z-[var(--z-toast)] flex flex-col gap-2 items-end max-w-[var(--toast-width)] w-full"
|
||||
>
|
||||
{visible.map((t) => {
|
||||
const text =
|
||||
@@ -66,7 +62,7 @@ function ToastContainer() {
|
||||
<div
|
||||
key={t.id}
|
||||
className={cn(
|
||||
"shadow-02 rounded-12",
|
||||
"w-full",
|
||||
t.leaving ? "animate-fade-out-scale" : "animate-fade-in-scale"
|
||||
)}
|
||||
>
|
||||
@@ -74,6 +70,7 @@ function ToastContainer() {
|
||||
variant={LEVEL_TO_VARIANT[t.level ?? "info"]}
|
||||
title={text}
|
||||
description={buildDescription(t)}
|
||||
padding="xs"
|
||||
onClose={t.dismissible ? () => handleClose(t.id) : undefined}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -37,7 +37,6 @@ import {
|
||||
OPEN_URL_TOOL_ID,
|
||||
} from "@/app/app/components/tools/constants";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
|
||||
import SimpleCollapsible from "@/refresh-components/SimpleCollapsible";
|
||||
import SwitchField from "@/refresh-components/form/SwitchField";
|
||||
import { Tooltip } from "@opal/components";
|
||||
|
||||
@@ -9,7 +9,6 @@ import { SWR_KEYS } from "@/lib/swr-keys";
|
||||
import { errorHandlingFetcher } from "@/lib/fetcher";
|
||||
import * as SettingsLayouts from "@/layouts/settings-layouts";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
import Card from "@/refresh-components/cards/Card";
|
||||
import SimpleCollapsible from "@/refresh-components/SimpleCollapsible";
|
||||
import { Tooltip } from "@opal/components";
|
||||
import InputTextAreaField from "@/refresh-components/form/InputTextAreaField";
|
||||
@@ -29,6 +28,7 @@ import { ADMIN_ROUTES } from "@/lib/admin-routes";
|
||||
import {
|
||||
Card as CardLayout,
|
||||
Content,
|
||||
ContentAction,
|
||||
InputHorizontal,
|
||||
InputVertical,
|
||||
} from "@opal/layouts";
|
||||
@@ -49,7 +49,7 @@ import {
|
||||
PYTHON_TOOL_ID,
|
||||
OPEN_URL_TOOL_ID,
|
||||
} from "@/app/app/components/tools/constants";
|
||||
import { Button, Divider, Text, Card as OpalCard } from "@opal/components";
|
||||
import { Button, Divider, Text, Card } from "@opal/components";
|
||||
import Modal from "@/refresh-components/Modal";
|
||||
import Switch from "@/refresh-components/inputs/Switch";
|
||||
import useMcpServersForAgentEditor from "@/hooks/useMcpServersForAgentEditor";
|
||||
@@ -110,7 +110,7 @@ function MCPServerCard({
|
||||
const hasContent = tools.length > 0 && filteredTools.length > 0;
|
||||
|
||||
return (
|
||||
<OpalCard
|
||||
<Card
|
||||
expandable
|
||||
expanded={expanded}
|
||||
border="solid"
|
||||
@@ -120,7 +120,7 @@ function MCPServerCard({
|
||||
hasContent ? (
|
||||
<div className="flex flex-col gap-2 p-2">
|
||||
{filteredTools.map((tool) => (
|
||||
<OpalCard key={tool.id} border="solid" rounding="lg" padding="sm">
|
||||
<Card key={tool.id} border="solid" rounding="lg" padding="sm">
|
||||
<CardLayout.Header
|
||||
headerChildren={
|
||||
<Content
|
||||
@@ -143,7 +143,7 @@ function MCPServerCard({
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
</OpalCard>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : undefined
|
||||
@@ -151,23 +151,26 @@ function MCPServerCard({
|
||||
>
|
||||
<CardLayout.Header
|
||||
headerChildren={
|
||||
<Content
|
||||
<ContentAction
|
||||
icon={getActionIcon(server.server_url, server.name)}
|
||||
title={server.name}
|
||||
description={server.description}
|
||||
sizePreset="main-ui"
|
||||
variant="section"
|
||||
paddingVariant="fit"
|
||||
rightChildren={
|
||||
<Tooltip tooltip={authTooltip} side="top">
|
||||
<Switch
|
||||
checked={serverEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
onToggleTools(allToolIds, checked)
|
||||
}
|
||||
disabled={needsAuth}
|
||||
/>
|
||||
</Tooltip>
|
||||
}
|
||||
/>
|
||||
}
|
||||
topRightChildren={
|
||||
<Tooltip tooltip={authTooltip} side="top">
|
||||
<Switch
|
||||
checked={serverEnabled}
|
||||
onCheckedChange={(checked) => onToggleTools(allToolIds, checked)}
|
||||
disabled={needsAuth}
|
||||
/>
|
||||
</Tooltip>
|
||||
}
|
||||
bottomChildren={
|
||||
tools.length > 0 ? (
|
||||
<Section flexDirection="row" gap={0.5}>
|
||||
@@ -190,7 +193,7 @@ function MCPServerCard({
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</OpalCard>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -330,10 +333,11 @@ function FileSizeLimitFields({
|
||||
maxAllowedUploadSizeMb,
|
||||
}: FileSizeLimitFieldsProps) {
|
||||
return (
|
||||
<div className="flex gap-4 w-full items-start">
|
||||
<div className="flex gap-4 w-full items-start pt-2">
|
||||
<div className="flex-1">
|
||||
<InputVertical
|
||||
title="File Size Limit (MB)"
|
||||
title="File Size Limit"
|
||||
suffix="(MB)"
|
||||
subDescription={
|
||||
maxAllowedUploadSizeMb
|
||||
? `Max: ${maxAllowedUploadSizeMb} MB`
|
||||
@@ -351,7 +355,11 @@ function FileSizeLimitFields({
|
||||
</InputVertical>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<InputVertical title="File Token Limit (thousand tokens)" withLabel>
|
||||
<InputVertical
|
||||
title="File Token Limit"
|
||||
withLabel
|
||||
suffix="(thousand tokens)"
|
||||
>
|
||||
<NumericLimitField
|
||||
name="file_token_count_threshold_k"
|
||||
initialValue={initialTokenThresholdK}
|
||||
@@ -365,7 +373,7 @@ function FileSizeLimitFields({
|
||||
);
|
||||
}
|
||||
|
||||
function ChatPreferencesForm() {
|
||||
export default function ChatPreferencesPage() {
|
||||
const router = useRouter();
|
||||
const settings = useSettingsContext();
|
||||
const s = settings.settings;
|
||||
@@ -549,72 +557,67 @@ function ChatPreferencesForm() {
|
||||
|
||||
<SettingsLayouts.Body>
|
||||
{/* Features */}
|
||||
<Card>
|
||||
<Tooltip
|
||||
tooltip={
|
||||
uniqueSources.length === 0
|
||||
? "Set up connectors to use Search Mode"
|
||||
: undefined
|
||||
}
|
||||
side="top"
|
||||
>
|
||||
<Disabled disabled={uniqueSources.length === 0} allowClick>
|
||||
<div className="w-full">
|
||||
<InputHorizontal
|
||||
title="Search Mode"
|
||||
tag={{ title: "beta", color: "blue" }}
|
||||
description="UI mode for quick document search across your organization."
|
||||
<Card border="solid" rounding="lg">
|
||||
<Section>
|
||||
<Disabled
|
||||
disabled={uniqueSources.length === 0}
|
||||
allowClick
|
||||
tooltip="Set up connectors to use Search Mode"
|
||||
>
|
||||
<InputHorizontal
|
||||
title="Search Mode"
|
||||
tag={{ title: "beta", color: "blue" }}
|
||||
description="UI mode for quick document search across your organization."
|
||||
disabled={uniqueSources.length === 0}
|
||||
withLabel
|
||||
>
|
||||
<Switch
|
||||
checked={s.search_ui_enabled ?? true}
|
||||
onCheckedChange={(checked) => {
|
||||
void saveSettings({ search_ui_enabled: checked });
|
||||
}}
|
||||
disabled={uniqueSources.length === 0}
|
||||
withLabel
|
||||
>
|
||||
<Switch
|
||||
checked={s.search_ui_enabled ?? true}
|
||||
onCheckedChange={(checked) => {
|
||||
void saveSettings({ search_ui_enabled: checked });
|
||||
}}
|
||||
disabled={uniqueSources.length === 0}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
</div>
|
||||
/>
|
||||
</InputHorizontal>
|
||||
</Disabled>
|
||||
</Tooltip>
|
||||
<InputHorizontal
|
||||
title="Multi-Model Generation"
|
||||
tag={{ title: "beta", color: "blue" }}
|
||||
description="Allow multiple models to generate responses in parallel in chat."
|
||||
withLabel
|
||||
>
|
||||
<Switch
|
||||
checked={s.multi_model_chat_enabled ?? true}
|
||||
onCheckedChange={(checked) => {
|
||||
void saveSettings({ multi_model_chat_enabled: checked });
|
||||
}}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
<InputHorizontal
|
||||
title="Deep Research"
|
||||
description="Agentic research system that works across the web and connected sources. Uses significantly more tokens per query."
|
||||
withLabel
|
||||
>
|
||||
<Switch
|
||||
checked={s.deep_research_enabled ?? true}
|
||||
onCheckedChange={(checked) => {
|
||||
void saveSettings({ deep_research_enabled: checked });
|
||||
}}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
<InputHorizontal
|
||||
title="Chat Auto-Scroll"
|
||||
description="Automatically scroll to new content as chat generates response. Users can override this in their personal settings."
|
||||
withLabel
|
||||
>
|
||||
<Switch
|
||||
checked={s.auto_scroll ?? false}
|
||||
onCheckedChange={(checked) => {
|
||||
void saveSettings({ auto_scroll: checked });
|
||||
}}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
<InputHorizontal
|
||||
title="Multi-Model Generation"
|
||||
tag={{ title: "beta", color: "blue" }}
|
||||
description="Allow multiple models to generate responses in parallel in chat."
|
||||
withLabel
|
||||
>
|
||||
<Switch
|
||||
checked={s.multi_model_chat_enabled ?? true}
|
||||
onCheckedChange={(checked) => {
|
||||
void saveSettings({ multi_model_chat_enabled: checked });
|
||||
}}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
<InputHorizontal
|
||||
title="Deep Research"
|
||||
description="Agentic research system that works across the web and connected sources. Uses significantly more tokens per query."
|
||||
withLabel
|
||||
>
|
||||
<Switch
|
||||
checked={s.deep_research_enabled ?? true}
|
||||
onCheckedChange={(checked) => {
|
||||
void saveSettings({ deep_research_enabled: checked });
|
||||
}}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
<InputHorizontal
|
||||
title="Chat Auto-Scroll"
|
||||
description="Automatically scroll to new content as chat generates response. Users can override this in their personal settings."
|
||||
withLabel
|
||||
>
|
||||
<Switch
|
||||
checked={s.auto_scroll ?? false}
|
||||
onCheckedChange={(checked) => {
|
||||
void saveSettings({ auto_scroll: checked });
|
||||
}}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
</Section>
|
||||
</Card>
|
||||
|
||||
<Divider paddingParallel="fit" paddingPerpendicular="fit" />
|
||||
@@ -713,17 +716,15 @@ function ChatPreferencesForm() {
|
||||
{uniqueSources.slice(0, 3).map((source) => {
|
||||
const meta = getSourceMetadata(source);
|
||||
return (
|
||||
<Card
|
||||
key={source}
|
||||
padding={0.75}
|
||||
className="w-[10rem]"
|
||||
>
|
||||
<Content
|
||||
icon={meta.icon}
|
||||
title={meta.displayName}
|
||||
sizePreset="main-ui"
|
||||
/>
|
||||
</Card>
|
||||
<div key={source} className="w-[10rem]">
|
||||
<Card padding="sm" border="solid">
|
||||
<Content
|
||||
icon={meta.icon}
|
||||
title={meta.displayName}
|
||||
sizePreset="main-ui"
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Section>
|
||||
@@ -749,7 +750,7 @@ function ChatPreferencesForm() {
|
||||
<SimpleCollapsible.Content>
|
||||
<Section gap={0.5}>
|
||||
{vectorDbEnabled && searchTool && (
|
||||
<Card>
|
||||
<Card border="solid" rounding="lg">
|
||||
<InputHorizontal
|
||||
title="Internal Search"
|
||||
description="Search through your organization's connected knowledge base and documents."
|
||||
@@ -765,15 +766,11 @@ function ChatPreferencesForm() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Tooltip
|
||||
tooltip={
|
||||
imageGenTool
|
||||
? undefined
|
||||
: "Image generation requires a configured model. Set one up under Configuration > Image Generation, or ask an admin."
|
||||
}
|
||||
side="top"
|
||||
<Disabled
|
||||
disabled={!imageGenTool}
|
||||
tooltip="Image generation requires a configured model. Set one up under Configuration > Image Generation, or ask an admin."
|
||||
>
|
||||
<Card variant={imageGenTool ? undefined : "disabled"}>
|
||||
<Card border="solid" rounding="lg">
|
||||
<InputHorizontal
|
||||
title="Image Generation"
|
||||
description="Generate and manipulate images using AI-powered tools."
|
||||
@@ -794,75 +791,79 @@ function ChatPreferencesForm() {
|
||||
/>
|
||||
</InputHorizontal>
|
||||
</Card>
|
||||
</Tooltip>
|
||||
</Disabled>
|
||||
|
||||
<Card variant={webSearchTool ? undefined : "disabled"}>
|
||||
<InputHorizontal
|
||||
title="Web Search"
|
||||
description="Search the web for real-time information and up-to-date results."
|
||||
disabled={!webSearchTool}
|
||||
withLabel
|
||||
>
|
||||
<Switch
|
||||
checked={
|
||||
webSearchTool
|
||||
? isToolEnabled(webSearchTool.id)
|
||||
: false
|
||||
}
|
||||
onCheckedChange={(checked) =>
|
||||
webSearchTool &&
|
||||
void toggleTool(webSearchTool.id, checked)
|
||||
}
|
||||
<Disabled disabled={!webSearchTool}>
|
||||
<Card border="solid" rounding="lg">
|
||||
<InputHorizontal
|
||||
title="Web Search"
|
||||
description="Search the web for real-time information and up-to-date results."
|
||||
disabled={!webSearchTool}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
</Card>
|
||||
withLabel
|
||||
>
|
||||
<Switch
|
||||
checked={
|
||||
webSearchTool
|
||||
? isToolEnabled(webSearchTool.id)
|
||||
: false
|
||||
}
|
||||
onCheckedChange={(checked) =>
|
||||
webSearchTool &&
|
||||
void toggleTool(webSearchTool.id, checked)
|
||||
}
|
||||
disabled={!webSearchTool}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
</Card>
|
||||
</Disabled>
|
||||
|
||||
<Card variant={openURLTool ? undefined : "disabled"}>
|
||||
<InputHorizontal
|
||||
title="Open URL"
|
||||
description="Fetch and read content from web URLs."
|
||||
disabled={!openURLTool}
|
||||
withLabel
|
||||
>
|
||||
<Switch
|
||||
checked={
|
||||
openURLTool
|
||||
? isToolEnabled(openURLTool.id)
|
||||
: false
|
||||
}
|
||||
onCheckedChange={(checked) =>
|
||||
openURLTool &&
|
||||
void toggleTool(openURLTool.id, checked)
|
||||
}
|
||||
<Disabled disabled={!openURLTool}>
|
||||
<Card border="solid" rounding="lg">
|
||||
<InputHorizontal
|
||||
title="Open URL"
|
||||
description="Fetch and read content from web URLs."
|
||||
disabled={!openURLTool}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
</Card>
|
||||
withLabel
|
||||
>
|
||||
<Switch
|
||||
checked={
|
||||
openURLTool
|
||||
? isToolEnabled(openURLTool.id)
|
||||
: false
|
||||
}
|
||||
onCheckedChange={(checked) =>
|
||||
openURLTool &&
|
||||
void toggleTool(openURLTool.id, checked)
|
||||
}
|
||||
disabled={!openURLTool}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
</Card>
|
||||
</Disabled>
|
||||
|
||||
<Card
|
||||
variant={codeInterpreterTool ? undefined : "disabled"}
|
||||
>
|
||||
<InputHorizontal
|
||||
title="Code Interpreter"
|
||||
description="Generate and run code."
|
||||
disabled={!codeInterpreterTool}
|
||||
withLabel
|
||||
>
|
||||
<Switch
|
||||
checked={
|
||||
codeInterpreterTool
|
||||
? isToolEnabled(codeInterpreterTool.id)
|
||||
: false
|
||||
}
|
||||
onCheckedChange={(checked) =>
|
||||
codeInterpreterTool &&
|
||||
void toggleTool(codeInterpreterTool.id, checked)
|
||||
}
|
||||
<Disabled disabled={!codeInterpreterTool}>
|
||||
<Card border="solid" rounding="lg">
|
||||
<InputHorizontal
|
||||
title="Code Interpreter"
|
||||
description="Generate and run code."
|
||||
disabled={!codeInterpreterTool}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
</Card>
|
||||
withLabel
|
||||
>
|
||||
<Switch
|
||||
checked={
|
||||
codeInterpreterTool
|
||||
? isToolEnabled(codeInterpreterTool.id)
|
||||
: false
|
||||
}
|
||||
onCheckedChange={(checked) =>
|
||||
codeInterpreterTool &&
|
||||
void toggleTool(codeInterpreterTool.id, checked)
|
||||
}
|
||||
disabled={!codeInterpreterTool}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
</Card>
|
||||
</Disabled>
|
||||
</Section>
|
||||
|
||||
{/* Separator between built-in tools and MCP/OpenAPI tools */}
|
||||
@@ -887,7 +888,7 @@ function ChatPreferencesForm() {
|
||||
/>
|
||||
))}
|
||||
{openApiTools.map((tool) => (
|
||||
<OpalCard
|
||||
<Card
|
||||
key={tool.id}
|
||||
border="solid"
|
||||
rounding="lg"
|
||||
@@ -912,7 +913,7 @@ function ChatPreferencesForm() {
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</OpalCard>
|
||||
</Card>
|
||||
))}
|
||||
</Section>
|
||||
</SimpleCollapsible.Content>
|
||||
@@ -928,7 +929,7 @@ function ChatPreferencesForm() {
|
||||
<SimpleCollapsible.Header title="Advanced Options" />
|
||||
<SimpleCollapsible.Content>
|
||||
<Section gap={1}>
|
||||
<Card>
|
||||
<Card border="solid" rounding="lg">
|
||||
<InputHorizontal
|
||||
title="Keep Chat History"
|
||||
description="Specify how long Onyx should retain chats in your organization."
|
||||
@@ -961,7 +962,7 @@ function ChatPreferencesForm() {
|
||||
</InputHorizontal>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<Card border="solid" rounding="lg">
|
||||
<InputVertical
|
||||
title="File Attachment Size Limit"
|
||||
description="Files attached in chats and projects must fit within both limits to be accepted. Larger files increase latency, memory usage, and token costs."
|
||||
@@ -996,35 +997,39 @@ function ChatPreferencesForm() {
|
||||
</InputVertical>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<InputHorizontal
|
||||
title="Allow Anonymous Users"
|
||||
description="Allow anyone to start chats without logging in. They do not see any other chats and cannot create agents or update settings."
|
||||
withLabel
|
||||
>
|
||||
<Switch
|
||||
checked={s.anonymous_user_enabled ?? false}
|
||||
onCheckedChange={(checked) => {
|
||||
void saveSettings({ anonymous_user_enabled: checked });
|
||||
}}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
<Card border="solid" rounding="lg">
|
||||
<Section>
|
||||
<InputHorizontal
|
||||
title="Allow Anonymous Users"
|
||||
description="Allow anyone to start chats without logging in. They do not see any other chats and cannot create agents or update settings."
|
||||
withLabel
|
||||
>
|
||||
<Switch
|
||||
checked={s.anonymous_user_enabled ?? false}
|
||||
onCheckedChange={(checked) => {
|
||||
void saveSettings({
|
||||
anonymous_user_enabled: checked,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
|
||||
<InputHorizontal
|
||||
title="Always Start with an Agent"
|
||||
description="This removes the default chat. Users will always start in an agent, and new chats will be created in their last active agent. Set featured agents to help new users get started."
|
||||
withLabel
|
||||
>
|
||||
<Switch
|
||||
id="disable_default_assistant"
|
||||
checked={s.disable_default_assistant ?? false}
|
||||
onCheckedChange={(checked) => {
|
||||
void saveSettings({
|
||||
disable_default_assistant: checked,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
<InputHorizontal
|
||||
title="Always Start with an Agent"
|
||||
description="This removes the default chat. Users will always start in an agent, and new chats will be created in their last active agent. Set featured agents to help new users get started."
|
||||
withLabel
|
||||
>
|
||||
<Switch
|
||||
id="disable_default_assistant"
|
||||
checked={s.disable_default_assistant ?? false}
|
||||
onCheckedChange={(checked) => {
|
||||
void saveSettings({
|
||||
disable_default_assistant: checked,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</InputHorizontal>
|
||||
</Section>
|
||||
</Card>
|
||||
</Section>
|
||||
</SimpleCollapsible.Content>
|
||||
@@ -1086,14 +1091,14 @@ function ChatPreferencesForm() {
|
||||
)}
|
||||
</Text>
|
||||
</Section>
|
||||
<OpalCard background="none" border="solid" padding="sm">
|
||||
<Card background="none" border="solid" padding="sm">
|
||||
<Content
|
||||
sizePreset="main-ui"
|
||||
icon={SvgAlertCircle}
|
||||
title="Modify with caution."
|
||||
description="System prompt affects all chats, agents, and projects. Significant changes may degrade response quality."
|
||||
/>
|
||||
</OpalCard>
|
||||
</Card>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button
|
||||
@@ -1118,7 +1123,3 @@ function ChatPreferencesForm() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ChatPreferencesPage() {
|
||||
return <ChatPreferencesForm />;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
unsetDefaultImageGenerationConfig,
|
||||
deleteImageGenerationConfig,
|
||||
} from "@/refresh-pages/admin/ImageGenerationPage/svc";
|
||||
import ModelIcon from "@/app/admin/configuration/llm/ModelIcon";
|
||||
import ModelIcon from "@/app/admin/configuration/language-models/ModelIcon";
|
||||
import ConfirmationModalLayout from "@/refresh-components/layouts/ConfirmationModalLayout";
|
||||
import InputSelect from "@/refresh-components/inputs/InputSelect";
|
||||
import { Button, MessageCard, SelectCard, Text } from "@opal/components";
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import React, { useState, useMemo, useEffect } from "react";
|
||||
import { Form, Formik, FormikProps } from "formik";
|
||||
import ProviderModal from "@/components/modals/ProviderModal";
|
||||
import ModelIcon from "@/app/admin/configuration/llm/ModelIcon";
|
||||
import ModelIcon from "@/app/admin/configuration/language-models/ModelIcon";
|
||||
import ConnectionProviderIcon from "@/refresh-components/ConnectionProviderIcon";
|
||||
import {
|
||||
testImageGenerationApiKey,
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
SvgServer,
|
||||
SvgSettings,
|
||||
} from "@opal/icons";
|
||||
import ModelIcon from "@/app/admin/configuration/llm/ModelIcon";
|
||||
import ModelIcon from "@/app/admin/configuration/language-models/ModelIcon";
|
||||
|
||||
export interface LLMProviderCardProps {
|
||||
title: string;
|
||||
@@ -40,7 +40,7 @@ function LLMProviderCardInner({
|
||||
|
||||
if (isConnected) {
|
||||
// If connected, redirect to admin page
|
||||
window.location.href = "/admin/configuration/llm";
|
||||
window.location.href = "/admin/configuration/language-models";
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -49,7 +49,9 @@ function LLMProviderCardInner({
|
||||
}, [disabled, isConnected, onClick]);
|
||||
|
||||
const handleSettingsClick = useCallback(
|
||||
noProp(() => (window.location.href = "/admin/configuration/llm")),
|
||||
noProp(
|
||||
() => (window.location.href = "/admin/configuration/language-models")
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from "@/interfaces/llm";
|
||||
import { getProvider } from "@/lib/llmConfig";
|
||||
import { Disabled } from "@opal/core";
|
||||
import ModelIcon from "@/app/admin/configuration/llm/ModelIcon";
|
||||
import ModelIcon from "@/app/admin/configuration/language-models/ModelIcon";
|
||||
import { SvgCheckCircle, SvgCpu, SvgExternalLink } from "@opal/icons";
|
||||
import { ContentAction } from "@opal/layouts";
|
||||
import { useLLMProviderOptions } from "@/lib/hooks/useLLMProviderOptions";
|
||||
@@ -162,7 +162,7 @@ const LLMStep = memo(
|
||||
disabled={disabled}
|
||||
prominence="tertiary"
|
||||
rightIcon={SvgExternalLink}
|
||||
href="/admin/configuration/llm"
|
||||
href="/admin/configuration/language-models"
|
||||
>
|
||||
View in Admin Panel
|
||||
</Button>
|
||||
|
||||
@@ -586,7 +586,11 @@ const MemoizedAppSidebarInner = memo(function AppSidebarInner() {
|
||||
<div>
|
||||
{(isAdmin || isCurator) && (
|
||||
<SidebarTab
|
||||
href={isCurator ? "/admin/agents" : "/admin/configuration/llm"}
|
||||
href={
|
||||
isCurator
|
||||
? "/admin/agents"
|
||||
: "/admin/configuration/language-models"
|
||||
}
|
||||
icon={SvgSettings}
|
||||
folded={folded}
|
||||
>
|
||||
|
||||
@@ -13,7 +13,7 @@ test.describe.configure({ mode: "parallel" });
|
||||
* user / feature-flag configuration.
|
||||
*/
|
||||
async function discoverAdminPages(page: Page): Promise<string[]> {
|
||||
await page.goto("/admin/configuration/llm");
|
||||
await page.goto("/admin/configuration/language-models");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
return page.evaluate(() => {
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Locator, Page } from "@playwright/test";
|
||||
import { loginAs } from "@tests/e2e/utils/auth";
|
||||
import { OnyxApiClient } from "@tests/e2e/utils/onyxApiClient";
|
||||
|
||||
const LLM_SETUP_URL = "/admin/configuration/llm";
|
||||
const LLM_SETUP_URL = "/admin/configuration/language-models";
|
||||
const BASE_URL = process.env.BASE_URL || "http://localhost:3000";
|
||||
const PROVIDER_API_KEY =
|
||||
process.env.E2E_LLM_PROVIDER_API_KEY ||
|
||||
@@ -120,7 +120,7 @@ async function createPublicProviderWithModels(
|
||||
|
||||
async function navigateToAdminLlmPageFromChat(page: Page): Promise<void> {
|
||||
await page.goto(LLM_SETUP_URL);
|
||||
await page.waitForURL("**/admin/configuration/llm**");
|
||||
await page.waitForURL("**/admin/configuration/language-models**");
|
||||
await expect(page.getByLabel("admin-page-title")).toHaveText(
|
||||
/^Language Models/
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user