mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-02-26 04:05:48 +00:00
Compare commits
15 Commits
llm_provid
...
csv_render
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80cf389774 | ||
|
|
e775aaacb7 | ||
|
|
e5b08b3d92 | ||
|
|
7c91304ba2 | ||
|
|
68a292b500 | ||
|
|
e553b80030 | ||
|
|
f3949f8e09 | ||
|
|
c7c064e296 | ||
|
|
68b91a8862 | ||
|
|
c23e5a196d | ||
|
|
093223c6c4 | ||
|
|
89517111d4 | ||
|
|
883d4b4ceb | ||
|
|
f3672b6819 | ||
|
|
921f5d9e96 |
@@ -11,11 +11,6 @@ permissions:
|
||||
|
||||
jobs:
|
||||
cherry-pick-to-latest-release:
|
||||
outputs:
|
||||
should_cherrypick: ${{ steps.gate.outputs.should_cherrypick }}
|
||||
pr_number: ${{ steps.gate.outputs.pr_number }}
|
||||
cherry_pick_reason: ${{ steps.run_cherry_pick.outputs.reason }}
|
||||
cherry_pick_details: ${{ steps.run_cherry_pick.outputs.details }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
@@ -41,13 +36,9 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Read the PR once so we can gate behavior and infer preferred actor.
|
||||
pr_json="$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${pr_number}")"
|
||||
pr_body="$(printf '%s' "$pr_json" | jq -r '.body // ""')"
|
||||
merged_by="$(printf '%s' "$pr_json" | jq -r '.merged_by.login // ""')"
|
||||
|
||||
# Read the PR body and check whether the helper checkbox is checked.
|
||||
pr_body="$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${pr_number}" --jq '.body // ""')"
|
||||
echo "pr_number=$pr_number" >> "$GITHUB_OUTPUT"
|
||||
echo "merged_by=$merged_by" >> "$GITHUB_OUTPUT"
|
||||
|
||||
if echo "$pr_body" | grep -qiE "\\[x\\][[:space:]]*(\\[[^]]+\\][[:space:]]*)?Please cherry-pick this PR to the latest release version"; then
|
||||
echo "should_cherrypick=true" >> "$GITHUB_OUTPUT"
|
||||
@@ -80,82 +71,9 @@ jobs:
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Create cherry-pick PR to latest release
|
||||
id: run_cherry_pick
|
||||
if: steps.gate.outputs.should_cherrypick == 'true'
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
CHERRY_PICK_ASSIGNEE: ${{ steps.gate.outputs.merged_by }}
|
||||
run: |
|
||||
set -o pipefail
|
||||
output_file="$(mktemp)"
|
||||
uv run --no-sync --with onyx-devtools ods cherry-pick "${GITHUB_SHA}" --yes --no-verify 2>&1 | tee "$output_file"
|
||||
exit_code="${PIPESTATUS[0]}"
|
||||
|
||||
if [ "${exit_code}" -eq 0 ]; then
|
||||
echo "status=success" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "status=failure" >> "$GITHUB_OUTPUT"
|
||||
|
||||
reason="command-failed"
|
||||
if grep -qiE "merge conflict during cherry-pick|CONFLICT|could not apply|cherry-pick in progress with staged changes" "$output_file"; then
|
||||
reason="merge-conflict"
|
||||
fi
|
||||
echo "reason=${reason}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
{
|
||||
echo "details<<EOF"
|
||||
tail -n 40 "$output_file"
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Mark workflow as failed if cherry-pick failed
|
||||
if: steps.gate.outputs.should_cherrypick == 'true' && steps.run_cherry_pick.outputs.status == 'failure'
|
||||
run: |
|
||||
echo "::error::Automated cherry-pick failed (${{ steps.run_cherry_pick.outputs.reason }})."
|
||||
exit 1
|
||||
|
||||
notify-slack-on-cherry-pick-failure:
|
||||
needs:
|
||||
- cherry-pick-to-latest-release
|
||||
if: always() && needs.cherry-pick-to-latest-release.outputs.should_cherrypick == 'true' && needs.cherry-pick-to-latest-release.result != 'success'
|
||||
runs-on: ubuntu-slim
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # ratchet:actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Build cherry-pick failure summary
|
||||
id: failure-summary
|
||||
env:
|
||||
SOURCE_PR_NUMBER: ${{ needs.cherry-pick-to-latest-release.outputs.pr_number }}
|
||||
CHERRY_PICK_REASON: ${{ needs.cherry-pick-to-latest-release.outputs.cherry_pick_reason }}
|
||||
CHERRY_PICK_DETAILS: ${{ needs.cherry-pick-to-latest-release.outputs.cherry_pick_details }}
|
||||
run: |
|
||||
source_pr_url="https://github.com/${GITHUB_REPOSITORY}/pull/${SOURCE_PR_NUMBER}"
|
||||
|
||||
reason_text="cherry-pick command failed"
|
||||
if [ "${CHERRY_PICK_REASON}" = "merge-conflict" ]; then
|
||||
reason_text="merge conflict during cherry-pick"
|
||||
fi
|
||||
|
||||
details_excerpt="$(printf '%s' "${CHERRY_PICK_DETAILS}" | tail -n 8 | tr '\n' ' ' | sed "s/[[:space:]]\\+/ /g" | sed "s/\"/'/g" | cut -c1-350)"
|
||||
failed_jobs="• cherry-pick-to-latest-release\\n• source PR: ${source_pr_url}\\n• reason: ${reason_text}"
|
||||
if [ -n "${details_excerpt}" ]; then
|
||||
failed_jobs="${failed_jobs}\\n• excerpt: ${details_excerpt}"
|
||||
fi
|
||||
|
||||
echo "jobs=${failed_jobs}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Notify #cherry-pick-prs about cherry-pick failure
|
||||
uses: ./.github/actions/slack-notify
|
||||
with:
|
||||
webhook-url: ${{ secrets.CHERRY_PICK_PRS_WEBHOOK }}
|
||||
failed-jobs: ${{ steps.failure-summary.outputs.jobs }}
|
||||
title: "🚨 Automated Cherry-Pick Failed"
|
||||
ref-name: ${{ github.ref_name }}
|
||||
uv run --no-sync --with onyx-devtools ods cherry-pick "${GITHUB_SHA}" --yes --no-verify
|
||||
|
||||
@@ -116,6 +116,7 @@ jobs:
|
||||
run: |
|
||||
cat <<EOF > deployment/docker_compose/.env
|
||||
COMPOSE_PROFILES=s3-filestore,opensearch-enabled
|
||||
CODE_INTERPRETER_BETA_ENABLED=true
|
||||
DISABLE_TELEMETRY=true
|
||||
OPENSEARCH_FOR_ONYX_ENABLED=true
|
||||
EOF
|
||||
|
||||
4
.github/workflows/pr-integration-tests.yml
vendored
4
.github/workflows/pr-integration-tests.yml
vendored
@@ -20,7 +20,6 @@ env:
|
||||
# Test Environment Variables
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
SLACK_BOT_TOKEN: ${{ secrets.SLACK_BOT_TOKEN }}
|
||||
SLACK_BOT_TOKEN_TEST_SPACE: ${{ secrets.SLACK_BOT_TOKEN_TEST_SPACE }}
|
||||
CONFLUENCE_TEST_SPACE_URL: ${{ vars.CONFLUENCE_TEST_SPACE_URL }}
|
||||
CONFLUENCE_USER_NAME: ${{ vars.CONFLUENCE_USER_NAME }}
|
||||
CONFLUENCE_ACCESS_TOKEN: ${{ secrets.CONFLUENCE_ACCESS_TOKEN }}
|
||||
@@ -424,7 +423,6 @@ jobs:
|
||||
-e OPENAI_API_KEY=${OPENAI_API_KEY} \
|
||||
-e EXA_API_KEY=${EXA_API_KEY} \
|
||||
-e SLACK_BOT_TOKEN=${SLACK_BOT_TOKEN} \
|
||||
-e SLACK_BOT_TOKEN_TEST_SPACE=${SLACK_BOT_TOKEN_TEST_SPACE} \
|
||||
-e CONFLUENCE_TEST_SPACE_URL=${CONFLUENCE_TEST_SPACE_URL} \
|
||||
-e CONFLUENCE_USER_NAME=${CONFLUENCE_USER_NAME} \
|
||||
-e CONFLUENCE_ACCESS_TOKEN=${CONFLUENCE_ACCESS_TOKEN} \
|
||||
@@ -445,7 +443,6 @@ jobs:
|
||||
-e TEST_WEB_HOSTNAME=test-runner \
|
||||
-e MOCK_CONNECTOR_SERVER_HOST=mock_connector_server \
|
||||
-e MOCK_CONNECTOR_SERVER_PORT=8001 \
|
||||
-e ENABLE_PAID_ENTERPRISE_EDITION_FEATURES=${{ matrix.edition == 'ee' && 'true' || 'false' }} \
|
||||
${{ env.RUNS_ON_ECR_CACHE }}:integration-test-${{ github.run_id }} \
|
||||
/app/tests/integration/${{ matrix.test-dir.path }}
|
||||
|
||||
@@ -704,7 +701,6 @@ jobs:
|
||||
-e OPENAI_API_KEY=${OPENAI_API_KEY} \
|
||||
-e EXA_API_KEY=${EXA_API_KEY} \
|
||||
-e SLACK_BOT_TOKEN=${SLACK_BOT_TOKEN} \
|
||||
-e SLACK_BOT_TOKEN_TEST_SPACE=${SLACK_BOT_TOKEN_TEST_SPACE} \
|
||||
-e TEST_WEB_HOSTNAME=test-runner \
|
||||
-e AUTH_TYPE=cloud \
|
||||
-e MULTI_TENANT=true \
|
||||
|
||||
@@ -548,7 +548,7 @@ class in the utils over directly calling the APIs with a library like `requests`
|
||||
calling the utilities directly (e.g. do NOT create admin users with
|
||||
`admin_user = UserManager.create(name="admin_user")`, instead use the `admin_user` fixture).
|
||||
|
||||
A great example of this type of test is `backend/tests/integration/tests/streaming_endpoints/test_chat_stream.py`.
|
||||
A great example of this type of test is `backend/tests/integration/dev_apis/test_simple_chat_api.py`.
|
||||
|
||||
To run them:
|
||||
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
"""code interpreter seed
|
||||
|
||||
Revision ID: 07b98176f1de
|
||||
Revises: 7cb492013621
|
||||
Create Date: 2026-02-23 15:55:07.606784
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "07b98176f1de"
|
||||
down_revision = "7cb492013621"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# Seed the single instance of code_interpreter_server
|
||||
# NOTE: There should only exist at most and at minimum 1 code_interpreter_server row
|
||||
op.execute(
|
||||
sa.text("INSERT INTO code_interpreter_server (server_enabled) VALUES (true)")
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.execute(sa.text("DELETE FROM code_interpreter_server"))
|
||||
@@ -1,48 +0,0 @@
|
||||
"""add enterprise and name fields to scim_user_mapping
|
||||
|
||||
Revision ID: 7616121f6e97
|
||||
Revises: 07b98176f1de
|
||||
Create Date: 2026-02-23 12:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "7616121f6e97"
|
||||
down_revision = "07b98176f1de"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"scim_user_mapping",
|
||||
sa.Column("department", sa.String(), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"scim_user_mapping",
|
||||
sa.Column("manager", sa.String(), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"scim_user_mapping",
|
||||
sa.Column("given_name", sa.String(), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"scim_user_mapping",
|
||||
sa.Column("family_name", sa.String(), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"scim_user_mapping",
|
||||
sa.Column("scim_emails_json", sa.Text(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("scim_user_mapping", "scim_emails_json")
|
||||
op.drop_column("scim_user_mapping", "family_name")
|
||||
op.drop_column("scim_user_mapping", "given_name")
|
||||
op.drop_column("scim_user_mapping", "manager")
|
||||
op.drop_column("scim_user_mapping", "department")
|
||||
@@ -117,34 +117,15 @@ def _seed_custom_tools(db_session: Session, tools: List[CustomToolSeed]) -> None
|
||||
def _seed_llms(
|
||||
db_session: Session, llm_upsert_requests: list[LLMProviderUpsertRequest]
|
||||
) -> None:
|
||||
if not llm_upsert_requests:
|
||||
return
|
||||
|
||||
logger.notice("Seeding LLMs")
|
||||
seeded_providers = [
|
||||
upsert_llm_provider(llm_upsert_request, db_session)
|
||||
for llm_upsert_request in llm_upsert_requests
|
||||
]
|
||||
|
||||
default_provider = next(
|
||||
(p for p in seeded_providers if p.model_configurations), None
|
||||
)
|
||||
if not default_provider:
|
||||
return
|
||||
|
||||
visible_configs = [
|
||||
mc for mc in default_provider.model_configurations if mc.is_visible
|
||||
]
|
||||
default_config = (
|
||||
visible_configs[0]
|
||||
if visible_configs
|
||||
else default_provider.model_configurations[0]
|
||||
)
|
||||
update_default_provider(
|
||||
provider_id=default_provider.id,
|
||||
model_name=default_config.name,
|
||||
db_session=db_session,
|
||||
)
|
||||
if llm_upsert_requests:
|
||||
logger.notice("Seeding LLMs")
|
||||
seeded_providers = [
|
||||
upsert_llm_provider(llm_upsert_request, db_session)
|
||||
for llm_upsert_request in llm_upsert_requests
|
||||
]
|
||||
update_default_provider(
|
||||
provider_id=seeded_providers[0].id, db_session=db_session
|
||||
)
|
||||
|
||||
|
||||
def _seed_personas(db_session: Session, personas: list[PersonaUpsertRequest]) -> None:
|
||||
|
||||
@@ -302,12 +302,12 @@ def configure_default_api_keys(db_session: Session) -> None:
|
||||
|
||||
has_set_default_provider = False
|
||||
|
||||
def _upsert(request: LLMProviderUpsertRequest, default_model: str) -> None:
|
||||
def _upsert(request: LLMProviderUpsertRequest) -> None:
|
||||
nonlocal has_set_default_provider
|
||||
try:
|
||||
provider = upsert_llm_provider(request, db_session)
|
||||
if not has_set_default_provider:
|
||||
update_default_provider(provider.id, default_model, db_session)
|
||||
update_default_provider(provider.id, db_session)
|
||||
has_set_default_provider = True
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to configure {request.provider} provider: {e}")
|
||||
@@ -332,7 +332,7 @@ def configure_default_api_keys(db_session: Session) -> None:
|
||||
api_key_changed=True,
|
||||
is_auto_mode=True,
|
||||
)
|
||||
_upsert(openai_provider, default_model_name)
|
||||
_upsert(openai_provider)
|
||||
|
||||
# Create default image generation config using the OpenAI API key
|
||||
try:
|
||||
@@ -368,7 +368,7 @@ def configure_default_api_keys(db_session: Session) -> None:
|
||||
api_key_changed=True,
|
||||
is_auto_mode=True,
|
||||
)
|
||||
_upsert(anthropic_provider, default_model_name)
|
||||
_upsert(anthropic_provider)
|
||||
else:
|
||||
logger.info(
|
||||
"ANTHROPIC_DEFAULT_API_KEY not set, skipping Anthropic provider configuration"
|
||||
@@ -400,7 +400,7 @@ def configure_default_api_keys(db_session: Session) -> None:
|
||||
api_key_changed=True,
|
||||
is_auto_mode=True,
|
||||
)
|
||||
_upsert(vertexai_provider, default_model_name)
|
||||
_upsert(vertexai_provider)
|
||||
else:
|
||||
logger.info(
|
||||
"VERTEXAI_DEFAULT_CREDENTIALS not set, skipping Vertex AI provider configuration"
|
||||
@@ -437,7 +437,7 @@ def configure_default_api_keys(db_session: Session) -> None:
|
||||
api_key_changed=True,
|
||||
is_auto_mode=True,
|
||||
)
|
||||
_upsert(openrouter_provider, default_model_name)
|
||||
_upsert(openrouter_provider)
|
||||
else:
|
||||
logger.info(
|
||||
"OPENROUTER_DEFAULT_API_KEY not set, skipping OpenRouter provider configuration"
|
||||
|
||||
@@ -5,10 +5,8 @@ from uuid import UUID
|
||||
|
||||
import httpx
|
||||
import sqlalchemy as sa
|
||||
from celery import Celery
|
||||
from celery import shared_task
|
||||
from celery import Task
|
||||
from redis import Redis
|
||||
from redis.lock import Lock as RedisLock
|
||||
from retry import retry
|
||||
from sqlalchemy import select
|
||||
@@ -26,14 +24,12 @@ from onyx.configs.constants import CELERY_GENERIC_BEAT_LOCK_TIMEOUT
|
||||
from onyx.configs.constants import CELERY_USER_FILE_PROCESSING_LOCK_TIMEOUT
|
||||
from onyx.configs.constants import CELERY_USER_FILE_PROCESSING_TASK_EXPIRES
|
||||
from onyx.configs.constants import CELERY_USER_FILE_PROJECT_SYNC_LOCK_TIMEOUT
|
||||
from onyx.configs.constants import CELERY_USER_FILE_PROJECT_SYNC_TASK_EXPIRES
|
||||
from onyx.configs.constants import DocumentSource
|
||||
from onyx.configs.constants import OnyxCeleryPriority
|
||||
from onyx.configs.constants import OnyxCeleryQueues
|
||||
from onyx.configs.constants import OnyxCeleryTask
|
||||
from onyx.configs.constants import OnyxRedisLocks
|
||||
from onyx.configs.constants import USER_FILE_PROCESSING_MAX_QUEUE_DEPTH
|
||||
from onyx.configs.constants import USER_FILE_PROJECT_SYNC_MAX_QUEUE_DEPTH
|
||||
from onyx.connectors.file.connector import LocalFileConnector
|
||||
from onyx.connectors.models import Document
|
||||
from onyx.connectors.models import HierarchyNode
|
||||
@@ -79,58 +75,10 @@ def _user_file_project_sync_lock_key(user_file_id: str | UUID) -> str:
|
||||
return f"{OnyxRedisLocks.USER_FILE_PROJECT_SYNC_LOCK_PREFIX}:{user_file_id}"
|
||||
|
||||
|
||||
def _user_file_project_sync_queued_key(user_file_id: str | UUID) -> str:
|
||||
return f"{OnyxRedisLocks.USER_FILE_PROJECT_SYNC_QUEUED_PREFIX}:{user_file_id}"
|
||||
|
||||
|
||||
def _user_file_delete_lock_key(user_file_id: str | UUID) -> str:
|
||||
return f"{OnyxRedisLocks.USER_FILE_DELETE_LOCK_PREFIX}:{user_file_id}"
|
||||
|
||||
|
||||
def get_user_file_project_sync_queue_depth(celery_app: Celery) -> int:
|
||||
redis_celery: Redis = celery_app.broker_connection().channel().client # type: ignore
|
||||
return celery_get_queue_length(
|
||||
OnyxCeleryQueues.USER_FILE_PROJECT_SYNC, redis_celery
|
||||
)
|
||||
|
||||
|
||||
def enqueue_user_file_project_sync_task(
|
||||
*,
|
||||
celery_app: Celery,
|
||||
redis_client: Redis,
|
||||
user_file_id: str | UUID,
|
||||
tenant_id: str,
|
||||
priority: OnyxCeleryPriority = OnyxCeleryPriority.HIGH,
|
||||
) -> bool:
|
||||
"""Enqueue a project-sync task if no matching queued task already exists."""
|
||||
queued_key = _user_file_project_sync_queued_key(user_file_id)
|
||||
|
||||
# NX+EX gives us atomic dedupe and a self-healing TTL.
|
||||
queued_guard_set = redis_client.set(
|
||||
queued_key,
|
||||
1,
|
||||
nx=True,
|
||||
ex=CELERY_USER_FILE_PROJECT_SYNC_TASK_EXPIRES,
|
||||
)
|
||||
if not queued_guard_set:
|
||||
return False
|
||||
|
||||
try:
|
||||
celery_app.send_task(
|
||||
OnyxCeleryTask.PROCESS_SINGLE_USER_FILE_PROJECT_SYNC,
|
||||
kwargs={"user_file_id": str(user_file_id), "tenant_id": tenant_id},
|
||||
queue=OnyxCeleryQueues.USER_FILE_PROJECT_SYNC,
|
||||
priority=priority,
|
||||
expires=CELERY_USER_FILE_PROJECT_SYNC_TASK_EXPIRES,
|
||||
)
|
||||
except Exception:
|
||||
# Roll back the queued guard if task publish fails.
|
||||
redis_client.delete(queued_key)
|
||||
raise
|
||||
|
||||
return True
|
||||
|
||||
|
||||
@retry(tries=3, delay=1, backoff=2, jitter=(0.0, 1.0))
|
||||
def _visit_chunks(
|
||||
*,
|
||||
@@ -684,8 +632,8 @@ def process_single_user_file_delete(
|
||||
ignore_result=True,
|
||||
)
|
||||
def check_for_user_file_project_sync(self: Task, *, tenant_id: str) -> None:
|
||||
"""Scan for user files needing project sync and enqueue per-file tasks."""
|
||||
task_logger.info("Starting")
|
||||
"""Scan for user files with PROJECT_SYNC status and enqueue per-file tasks."""
|
||||
task_logger.info("check_for_user_file_project_sync - Starting")
|
||||
|
||||
redis_client = get_redis_client(tenant_id=tenant_id)
|
||||
lock: RedisLock = redis_client.lock(
|
||||
@@ -697,16 +645,7 @@ def check_for_user_file_project_sync(self: Task, *, tenant_id: str) -> None:
|
||||
return None
|
||||
|
||||
enqueued = 0
|
||||
skipped_guard = 0
|
||||
try:
|
||||
queue_depth = get_user_file_project_sync_queue_depth(self.app)
|
||||
if queue_depth > USER_FILE_PROJECT_SYNC_MAX_QUEUE_DEPTH:
|
||||
task_logger.warning(
|
||||
f"Queue depth {queue_depth} exceeds "
|
||||
f"{USER_FILE_PROJECT_SYNC_MAX_QUEUE_DEPTH}, skipping enqueue for tenant={tenant_id}"
|
||||
)
|
||||
return None
|
||||
|
||||
with get_session_with_current_tenant() as db_session:
|
||||
user_file_ids = (
|
||||
db_session.execute(
|
||||
@@ -722,23 +661,19 @@ def check_for_user_file_project_sync(self: Task, *, tenant_id: str) -> None:
|
||||
)
|
||||
|
||||
for user_file_id in user_file_ids:
|
||||
if not enqueue_user_file_project_sync_task(
|
||||
celery_app=self.app,
|
||||
redis_client=redis_client,
|
||||
user_file_id=user_file_id,
|
||||
tenant_id=tenant_id,
|
||||
self.app.send_task(
|
||||
OnyxCeleryTask.PROCESS_SINGLE_USER_FILE_PROJECT_SYNC,
|
||||
kwargs={"user_file_id": str(user_file_id), "tenant_id": tenant_id},
|
||||
queue=OnyxCeleryQueues.USER_FILE_PROJECT_SYNC,
|
||||
priority=OnyxCeleryPriority.HIGH,
|
||||
):
|
||||
skipped_guard += 1
|
||||
continue
|
||||
)
|
||||
enqueued += 1
|
||||
finally:
|
||||
if lock.owned():
|
||||
lock.release()
|
||||
|
||||
task_logger.info(
|
||||
f"Enqueued {enqueued} "
|
||||
f"Skipped guard {skipped_guard} tasks for tenant={tenant_id}"
|
||||
f"check_for_user_file_project_sync - Enqueued {enqueued} tasks for tenant={tenant_id}"
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -757,8 +692,6 @@ def process_single_user_file_project_sync(
|
||||
)
|
||||
|
||||
redis_client = get_redis_client(tenant_id=tenant_id)
|
||||
redis_client.delete(_user_file_project_sync_queued_key(user_file_id))
|
||||
|
||||
file_lock: RedisLock = redis_client.lock(
|
||||
_user_file_project_sync_lock_key(user_file_id),
|
||||
timeout=CELERY_USER_FILE_PROJECT_SYNC_LOCK_TIMEOUT,
|
||||
|
||||
@@ -58,8 +58,6 @@ from onyx.file_store.document_batch_storage import DocumentBatchStorage
|
||||
from onyx.file_store.document_batch_storage import get_document_batch_storage
|
||||
from onyx.indexing.indexing_heartbeat import IndexingHeartbeatInterface
|
||||
from onyx.indexing.indexing_pipeline import index_doc_batch_prepare
|
||||
from onyx.indexing.postgres_sanitization import sanitize_document_for_postgres
|
||||
from onyx.indexing.postgres_sanitization import sanitize_hierarchy_nodes_for_postgres
|
||||
from onyx.redis.redis_hierarchy import cache_hierarchy_nodes_batch
|
||||
from onyx.redis.redis_hierarchy import ensure_source_node_exists
|
||||
from onyx.redis.redis_hierarchy import get_node_id_from_raw_id
|
||||
@@ -158,7 +156,36 @@ def strip_null_characters(doc_batch: list[Document]) -> list[Document]:
|
||||
logger.warning(
|
||||
f"doc {doc.id} too large, Document size: {sys.getsizeof(doc)}"
|
||||
)
|
||||
cleaned_batch.append(sanitize_document_for_postgres(doc))
|
||||
cleaned_doc = doc.model_copy()
|
||||
|
||||
# Postgres cannot handle NUL characters in text fields
|
||||
if "\x00" in cleaned_doc.id:
|
||||
logger.warning(f"NUL characters found in document ID: {cleaned_doc.id}")
|
||||
cleaned_doc.id = cleaned_doc.id.replace("\x00", "")
|
||||
|
||||
if cleaned_doc.title and "\x00" in cleaned_doc.title:
|
||||
logger.warning(
|
||||
f"NUL characters found in document title: {cleaned_doc.title}"
|
||||
)
|
||||
cleaned_doc.title = cleaned_doc.title.replace("\x00", "")
|
||||
|
||||
if "\x00" in cleaned_doc.semantic_identifier:
|
||||
logger.warning(
|
||||
f"NUL characters found in document semantic identifier: {cleaned_doc.semantic_identifier}"
|
||||
)
|
||||
cleaned_doc.semantic_identifier = cleaned_doc.semantic_identifier.replace(
|
||||
"\x00", ""
|
||||
)
|
||||
|
||||
for section in cleaned_doc.sections:
|
||||
if section.link is not None:
|
||||
section.link = section.link.replace("\x00", "")
|
||||
|
||||
# since text can be longer, just replace to avoid double scan
|
||||
if isinstance(section, TextSection) and section.text is not None:
|
||||
section.text = section.text.replace("\x00", "")
|
||||
|
||||
cleaned_batch.append(cleaned_doc)
|
||||
|
||||
return cleaned_batch
|
||||
|
||||
@@ -575,13 +602,10 @@ def connector_document_extraction(
|
||||
|
||||
# Process hierarchy nodes batch - upsert to Postgres and cache in Redis
|
||||
if hierarchy_node_batch:
|
||||
hierarchy_node_batch_cleaned = (
|
||||
sanitize_hierarchy_nodes_for_postgres(hierarchy_node_batch)
|
||||
)
|
||||
with get_session_with_current_tenant() as db_session:
|
||||
upserted_nodes = upsert_hierarchy_nodes_batch(
|
||||
db_session=db_session,
|
||||
nodes=hierarchy_node_batch_cleaned,
|
||||
nodes=hierarchy_node_batch,
|
||||
source=db_connector.source,
|
||||
commit=True,
|
||||
is_connector_public=is_connector_public,
|
||||
@@ -600,7 +624,7 @@ def connector_document_extraction(
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"Persisted and cached {len(hierarchy_node_batch_cleaned)} hierarchy nodes "
|
||||
f"Persisted and cached {len(hierarchy_node_batch)} hierarchy nodes "
|
||||
f"for attempt={index_attempt_id}"
|
||||
)
|
||||
|
||||
|
||||
@@ -30,7 +30,6 @@ from onyx.configs.constants import DocumentSource
|
||||
from onyx.configs.constants import MessageType
|
||||
from onyx.context.search.models import SearchDoc
|
||||
from onyx.context.search.models import SearchDocsResponse
|
||||
from onyx.db.engine.sql_engine import get_session_with_current_tenant
|
||||
from onyx.db.memory import add_memory
|
||||
from onyx.db.memory import update_memory_at_index
|
||||
from onyx.db.memory import UserMemoryContext
|
||||
@@ -657,12 +656,7 @@ def run_llm_loop(
|
||||
fallback_extraction_attempted: bool = False
|
||||
citation_mapping: dict[int, str] = {} # Maps citation_num -> document_id/URL
|
||||
|
||||
# Fetch this in a short-lived session so the long-running stream loop does
|
||||
# not pin a connection just to keep read state alive.
|
||||
with get_session_with_current_tenant() as prompt_db_session:
|
||||
default_base_system_prompt: str = get_default_base_system_prompt(
|
||||
prompt_db_session
|
||||
)
|
||||
default_base_system_prompt: str = get_default_base_system_prompt(db_session)
|
||||
system_prompt = None
|
||||
custom_agent_prompt_msg = None
|
||||
|
||||
|
||||
@@ -856,11 +856,6 @@ def handle_stream_message_objects(
|
||||
reserved_tokens=reserved_token_count,
|
||||
)
|
||||
|
||||
# Release any read transaction before entering the long-running LLM stream.
|
||||
# Without this, the request-scoped session can keep a connection checked out
|
||||
# for the full stream duration.
|
||||
db_session.commit()
|
||||
|
||||
# The stream generator can resume on a different worker thread after early yields.
|
||||
# Set this right before launching the LLM loop so run_in_background copies the right context.
|
||||
if new_msg_req.mock_llm_response is not None:
|
||||
|
||||
@@ -210,10 +210,10 @@ AUTH_COOKIE_EXPIRE_TIME_SECONDS = int(
|
||||
REQUIRE_EMAIL_VERIFICATION = (
|
||||
os.environ.get("REQUIRE_EMAIL_VERIFICATION", "").lower() == "true"
|
||||
)
|
||||
SMTP_SERVER = os.environ.get("SMTP_SERVER") or ""
|
||||
SMTP_SERVER = os.environ.get("SMTP_SERVER") or "smtp.gmail.com"
|
||||
SMTP_PORT = int(os.environ.get("SMTP_PORT") or "587")
|
||||
SMTP_USER = os.environ.get("SMTP_USER") or ""
|
||||
SMTP_PASS = os.environ.get("SMTP_PASS") or ""
|
||||
SMTP_USER = os.environ.get("SMTP_USER", "your-email@gmail.com")
|
||||
SMTP_PASS = os.environ.get("SMTP_PASS", "your-gmail-password")
|
||||
EMAIL_FROM = os.environ.get("EMAIL_FROM") or SMTP_USER
|
||||
|
||||
SENDGRID_API_KEY = os.environ.get("SENDGRID_API_KEY") or ""
|
||||
|
||||
@@ -167,14 +167,6 @@ CELERY_USER_FILE_PROCESSING_TASK_EXPIRES = 60 # 1 minute (in seconds)
|
||||
# beat generator stops adding more. Prevents unbounded queue growth when workers
|
||||
# fall behind.
|
||||
USER_FILE_PROCESSING_MAX_QUEUE_DEPTH = 500
|
||||
# How long a queued user-file-project-sync task remains valid.
|
||||
# Should be short enough to discard stale queue entries under load while still
|
||||
# allowing workers enough time to pick up new tasks.
|
||||
CELERY_USER_FILE_PROJECT_SYNC_TASK_EXPIRES = 60 # 1 minute (in seconds)
|
||||
|
||||
# Max queue depth before user-file-project-sync producers stop enqueuing.
|
||||
# This applies backpressure when workers are falling behind.
|
||||
USER_FILE_PROJECT_SYNC_MAX_QUEUE_DEPTH = 500
|
||||
|
||||
CELERY_USER_FILE_PROJECT_SYNC_LOCK_TIMEOUT = 5 * 60 # 5 minutes (in seconds)
|
||||
|
||||
@@ -467,7 +459,6 @@ class OnyxRedisLocks:
|
||||
USER_FILE_QUEUED_PREFIX = "da_lock:user_file_queued"
|
||||
USER_FILE_PROJECT_SYNC_BEAT_LOCK = "da_lock:check_user_file_project_sync_beat"
|
||||
USER_FILE_PROJECT_SYNC_LOCK_PREFIX = "da_lock:user_file_project_sync"
|
||||
USER_FILE_PROJECT_SYNC_QUEUED_PREFIX = "da_lock:user_file_project_sync_queued"
|
||||
USER_FILE_DELETE_BEAT_LOCK = "da_lock:check_user_file_delete_beat"
|
||||
USER_FILE_DELETE_LOCK_PREFIX = "da_lock:user_file_delete"
|
||||
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
"""Inverse mapping from user-facing Microsoft host URLs to the SDK's AzureEnvironment.
|
||||
|
||||
The office365 library's GraphClient requires an ``AzureEnvironment`` string
|
||||
(e.g. ``"Global"``, ``"GCC High"``) to route requests to the correct national
|
||||
cloud. Our connectors instead expose free-text ``authority_host`` and
|
||||
``graph_api_host`` fields so the frontend doesn't need to know about SDK
|
||||
internals.
|
||||
|
||||
This module bridges the gap: given the two host URLs the user configured, it
|
||||
resolves the matching ``AzureEnvironment`` value (and the implied SharePoint
|
||||
domain suffix) so callers can pass ``environment=…`` to ``GraphClient``.
|
||||
"""
|
||||
|
||||
from office365.graph_client import AzureEnvironment # type: ignore[import-untyped]
|
||||
from pydantic import BaseModel
|
||||
|
||||
from onyx.connectors.exceptions import ConnectorValidationError
|
||||
|
||||
|
||||
class MicrosoftGraphEnvironment(BaseModel):
|
||||
"""One row of the inverse mapping."""
|
||||
|
||||
environment: str
|
||||
graph_host: str
|
||||
authority_host: str
|
||||
sharepoint_domain_suffix: str
|
||||
|
||||
|
||||
_ENVIRONMENTS: list[MicrosoftGraphEnvironment] = [
|
||||
MicrosoftGraphEnvironment(
|
||||
environment=AzureEnvironment.Global,
|
||||
graph_host="https://graph.microsoft.com",
|
||||
authority_host="https://login.microsoftonline.com",
|
||||
sharepoint_domain_suffix="sharepoint.com",
|
||||
),
|
||||
MicrosoftGraphEnvironment(
|
||||
environment=AzureEnvironment.USGovernmentHigh,
|
||||
graph_host="https://graph.microsoft.us",
|
||||
authority_host="https://login.microsoftonline.us",
|
||||
sharepoint_domain_suffix="sharepoint.us",
|
||||
),
|
||||
MicrosoftGraphEnvironment(
|
||||
environment=AzureEnvironment.USGovernmentDoD,
|
||||
graph_host="https://dod-graph.microsoft.us",
|
||||
authority_host="https://login.microsoftonline.us",
|
||||
sharepoint_domain_suffix="sharepoint.us",
|
||||
),
|
||||
MicrosoftGraphEnvironment(
|
||||
environment=AzureEnvironment.China,
|
||||
graph_host="https://microsoftgraph.chinacloudapi.cn",
|
||||
authority_host="https://login.chinacloudapi.cn",
|
||||
sharepoint_domain_suffix="sharepoint.cn",
|
||||
),
|
||||
MicrosoftGraphEnvironment(
|
||||
environment=AzureEnvironment.Germany,
|
||||
graph_host="https://graph.microsoft.de",
|
||||
authority_host="https://login.microsoftonline.de",
|
||||
sharepoint_domain_suffix="sharepoint.de",
|
||||
),
|
||||
]
|
||||
|
||||
_GRAPH_HOST_INDEX: dict[str, MicrosoftGraphEnvironment] = {
|
||||
env.graph_host: env for env in _ENVIRONMENTS
|
||||
}
|
||||
|
||||
|
||||
def resolve_microsoft_environment(
|
||||
graph_api_host: str,
|
||||
authority_host: str,
|
||||
) -> MicrosoftGraphEnvironment:
|
||||
"""Return the ``MicrosoftGraphEnvironment`` that matches the supplied hosts.
|
||||
|
||||
Raises ``ConnectorValidationError`` when the combination is unknown or
|
||||
internally inconsistent (e.g. a GCC-High graph host paired with a
|
||||
commercial authority host).
|
||||
"""
|
||||
graph_api_host = graph_api_host.rstrip("/")
|
||||
authority_host = authority_host.rstrip("/")
|
||||
|
||||
env = _GRAPH_HOST_INDEX.get(graph_api_host)
|
||||
if env is None:
|
||||
known = ", ".join(sorted(_GRAPH_HOST_INDEX))
|
||||
raise ConnectorValidationError(
|
||||
f"Unsupported Microsoft Graph API host '{graph_api_host}'. "
|
||||
f"Recognised hosts: {known}"
|
||||
)
|
||||
|
||||
if env.authority_host != authority_host:
|
||||
raise ConnectorValidationError(
|
||||
f"Authority host '{authority_host}' is inconsistent with "
|
||||
f"graph API host '{graph_api_host}'. "
|
||||
f"Expected authority host '{env.authority_host}' "
|
||||
f"for the {env.environment} environment."
|
||||
)
|
||||
|
||||
return env
|
||||
@@ -6,7 +6,6 @@ from typing import cast
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import Field
|
||||
from pydantic import field_validator
|
||||
from pydantic import model_validator
|
||||
|
||||
from onyx.access.models import ExternalAccess
|
||||
@@ -168,14 +167,6 @@ class DocumentBase(BaseModel):
|
||||
# list of strings.
|
||||
metadata: dict[str, str | list[str]]
|
||||
|
||||
@field_validator("metadata", mode="before")
|
||||
@classmethod
|
||||
def _coerce_metadata_values(cls, v: dict[str, Any]) -> dict[str, str | list[str]]:
|
||||
return {
|
||||
key: [str(item) for item in val] if isinstance(val, list) else str(val)
|
||||
for key, val in v.items()
|
||||
}
|
||||
|
||||
# UTC time
|
||||
doc_updated_at: datetime | None = None
|
||||
chunk_count: int | None = None
|
||||
|
||||
@@ -47,7 +47,6 @@ from onyx.connectors.interfaces import GenerateSlimDocumentOutput
|
||||
from onyx.connectors.interfaces import IndexingHeartbeatInterface
|
||||
from onyx.connectors.interfaces import SecondsSinceUnixEpoch
|
||||
from onyx.connectors.interfaces import SlimConnectorWithPermSync
|
||||
from onyx.connectors.microsoft_graph_env import resolve_microsoft_environment
|
||||
from onyx.connectors.models import BasicExpertInfo
|
||||
from onyx.connectors.models import ConnectorCheckpoint
|
||||
from onyx.connectors.models import ConnectorFailure
|
||||
@@ -147,9 +146,7 @@ class DriveItemData(BaseModel):
|
||||
self.id,
|
||||
ResourcePath("items", ResourcePath(self.drive_id, ResourcePath("drives"))),
|
||||
)
|
||||
item = DriveItem(graph_client, path)
|
||||
item.set_property("id", self.id)
|
||||
return item
|
||||
return DriveItem(graph_client, path)
|
||||
|
||||
|
||||
# The office365 library's ClientContext caches the access token from its
|
||||
@@ -840,20 +837,10 @@ class SharepointConnector(
|
||||
self._cached_rest_ctx: ClientContext | None = None
|
||||
self._cached_rest_ctx_url: str | None = None
|
||||
self._cached_rest_ctx_created_at: float = 0.0
|
||||
|
||||
resolved_env = resolve_microsoft_environment(graph_api_host, authority_host)
|
||||
self._azure_environment = resolved_env.environment
|
||||
self.authority_host = resolved_env.authority_host
|
||||
self.graph_api_host = resolved_env.graph_host
|
||||
self.authority_host = authority_host.rstrip("/")
|
||||
self.graph_api_host = graph_api_host.rstrip("/")
|
||||
self.graph_api_base = f"{self.graph_api_host}/v1.0"
|
||||
self.sharepoint_domain_suffix = resolved_env.sharepoint_domain_suffix
|
||||
if sharepoint_domain_suffix != resolved_env.sharepoint_domain_suffix:
|
||||
logger.warning(
|
||||
f"Configured sharepoint_domain_suffix '{sharepoint_domain_suffix}' "
|
||||
f"differs from the expected suffix '{resolved_env.sharepoint_domain_suffix}' "
|
||||
f"for the {resolved_env.environment} environment. "
|
||||
f"Using '{resolved_env.sharepoint_domain_suffix}'."
|
||||
)
|
||||
self.sharepoint_domain_suffix = sharepoint_domain_suffix
|
||||
|
||||
def validate_connector_settings(self) -> None:
|
||||
# Validate that at least one content type is enabled
|
||||
@@ -1605,7 +1592,6 @@ class SharepointConnector(
|
||||
if certificate_data is None:
|
||||
raise RuntimeError("Failed to load certificate")
|
||||
|
||||
logger.info(f"Creating MSAL app with authority url {authority_url}")
|
||||
self.msal_app = msal.ConfidentialClientApplication(
|
||||
authority=authority_url,
|
||||
client_id=sp_client_id,
|
||||
@@ -1637,9 +1623,7 @@ class SharepointConnector(
|
||||
raise ConnectorValidationError("Failed to acquire token for graph")
|
||||
return token
|
||||
|
||||
self._graph_client = GraphClient(
|
||||
_acquire_token_for_graph, environment=self._azure_environment
|
||||
)
|
||||
self._graph_client = GraphClient(_acquire_token_for_graph)
|
||||
if auth_method == SharepointAuthMethod.CERTIFICATE.value:
|
||||
org = self.graph_client.organization.get().execute_query()
|
||||
if not org or len(org) == 0:
|
||||
|
||||
@@ -23,7 +23,6 @@ from onyx.connectors.interfaces import CheckpointOutput
|
||||
from onyx.connectors.interfaces import GenerateSlimDocumentOutput
|
||||
from onyx.connectors.interfaces import SecondsSinceUnixEpoch
|
||||
from onyx.connectors.interfaces import SlimConnectorWithPermSync
|
||||
from onyx.connectors.microsoft_graph_env import resolve_microsoft_environment
|
||||
from onyx.connectors.models import ConnectorCheckpoint
|
||||
from onyx.connectors.models import ConnectorFailure
|
||||
from onyx.connectors.models import ConnectorMissingCredentialError
|
||||
@@ -74,11 +73,8 @@ class TeamsConnector(
|
||||
self.msal_app: msal.ConfidentialClientApplication | None = None
|
||||
self.max_workers = max_workers
|
||||
self.requested_team_list: list[str] = teams
|
||||
|
||||
resolved_env = resolve_microsoft_environment(graph_api_host, authority_host)
|
||||
self._azure_environment = resolved_env.environment
|
||||
self.authority_host = resolved_env.authority_host
|
||||
self.graph_api_host = resolved_env.graph_host
|
||||
self.authority_host = authority_host.rstrip("/")
|
||||
self.graph_api_host = graph_api_host.rstrip("/")
|
||||
|
||||
# impls for BaseConnector
|
||||
|
||||
@@ -110,9 +106,7 @@ class TeamsConnector(
|
||||
|
||||
return token
|
||||
|
||||
self.graph_client = GraphClient(
|
||||
_acquire_token_func, environment=self._azure_environment
|
||||
)
|
||||
self.graph_client = GraphClient(_acquire_token_func)
|
||||
return None
|
||||
|
||||
def validate_connector_settings(self) -> None:
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from onyx.db.models import CodeInterpreterServer
|
||||
|
||||
|
||||
def fetch_code_interpreter_server(
|
||||
db_session: Session,
|
||||
) -> CodeInterpreterServer:
|
||||
server = db_session.scalars(select(CodeInterpreterServer)).one()
|
||||
return server
|
||||
|
||||
|
||||
def update_code_interpreter_server_enabled(
|
||||
db_session: Session,
|
||||
enabled: bool,
|
||||
) -> CodeInterpreterServer:
|
||||
server = db_session.scalars(select(CodeInterpreterServer)).one()
|
||||
server.server_enabled = enabled
|
||||
db_session.commit()
|
||||
return server
|
||||
@@ -488,22 +488,6 @@ def fetch_existing_llm_provider(
|
||||
return provider_model
|
||||
|
||||
|
||||
def fetch_existing_llm_provider_by_id(
|
||||
id: int, db_session: Session
|
||||
) -> LLMProviderModel | None:
|
||||
provider_model = db_session.scalar(
|
||||
select(LLMProviderModel)
|
||||
.where(LLMProviderModel.id == id)
|
||||
.options(
|
||||
selectinload(LLMProviderModel.model_configurations),
|
||||
selectinload(LLMProviderModel.groups),
|
||||
selectinload(LLMProviderModel.personas),
|
||||
)
|
||||
)
|
||||
|
||||
return provider_model
|
||||
|
||||
|
||||
def fetch_embedding_provider(
|
||||
db_session: Session, provider_type: EmbeddingProvider
|
||||
) -> CloudEmbeddingProviderModel | None:
|
||||
@@ -620,13 +604,22 @@ def remove_llm_provider__no_commit(db_session: Session, provider_id: int) -> Non
|
||||
db_session.flush()
|
||||
|
||||
|
||||
def update_default_provider(
|
||||
provider_id: int, model_name: str, db_session: Session
|
||||
) -> None:
|
||||
def update_default_provider(provider_id: int, db_session: Session) -> None:
|
||||
# Attempt to get the default_model_name from the provider first
|
||||
# TODO: Remove default_model_name check
|
||||
provider = db_session.scalar(
|
||||
select(LLMProviderModel).where(
|
||||
LLMProviderModel.id == provider_id,
|
||||
)
|
||||
)
|
||||
|
||||
if provider is None:
|
||||
raise ValueError(f"LLM Provider with id={provider_id} does not exist")
|
||||
|
||||
_update_default_model(
|
||||
db_session,
|
||||
provider_id,
|
||||
model_name,
|
||||
provider.default_model_name,
|
||||
LLMModelFlowType.CHAT,
|
||||
)
|
||||
|
||||
|
||||
@@ -4940,11 +4940,6 @@ class ScimUserMapping(Base):
|
||||
ForeignKey("user.id", ondelete="CASCADE"), unique=True, nullable=False
|
||||
)
|
||||
scim_username: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
department: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
manager: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
given_name: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
family_name: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
scim_emails_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
created_at: Mapped[datetime.datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
|
||||
@@ -2,7 +2,6 @@ import random
|
||||
from datetime import datetime
|
||||
from datetime import timedelta
|
||||
from logging import getLogger
|
||||
from uuid import UUID
|
||||
|
||||
from onyx.configs.constants import MessageType
|
||||
from onyx.db.chat import create_chat_session
|
||||
@@ -14,26 +13,18 @@ from onyx.db.models import ChatSession
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
def seed_chat_history(
|
||||
num_sessions: int,
|
||||
num_messages: int,
|
||||
days: int,
|
||||
user_id: UUID | None = None,
|
||||
persona_id: int | None = None,
|
||||
) -> None:
|
||||
def seed_chat_history(num_sessions: int, num_messages: int, days: int) -> None:
|
||||
"""Utility function to seed chat history for testing.
|
||||
|
||||
num_sessions: the number of sessions to seed
|
||||
num_messages: the number of messages to seed per sessions
|
||||
days: the number of days looking backwards from the current time over which to randomize
|
||||
the times.
|
||||
user_id: optional user to associate with sessions
|
||||
persona_id: optional persona/assistant to associate with sessions
|
||||
"""
|
||||
with get_session_with_current_tenant() as db_session:
|
||||
logger.info(f"Seeding {num_sessions} sessions.")
|
||||
for y in range(0, num_sessions):
|
||||
create_chat_session(db_session, f"pytest_session_{y}", user_id, persona_id)
|
||||
create_chat_session(db_session, f"pytest_session_{y}", None, None)
|
||||
|
||||
# randomize all session times
|
||||
logger.info(f"Seeding {num_messages} messages per session.")
|
||||
|
||||
@@ -12,9 +12,6 @@ if TYPE_CHECKING:
|
||||
|
||||
|
||||
class AzureImageGenerationProvider(ImageGenerationProvider):
|
||||
_GPT_IMAGE_MODEL_PREFIX = "gpt-image-"
|
||||
_DALL_E_2_MODEL_NAME = "dall-e-2"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str,
|
||||
@@ -56,25 +53,6 @@ class AzureImageGenerationProvider(ImageGenerationProvider):
|
||||
deployment_name=credentials.deployment_name,
|
||||
)
|
||||
|
||||
@property
|
||||
def supports_reference_images(self) -> bool:
|
||||
return True
|
||||
|
||||
@property
|
||||
def max_reference_images(self) -> int:
|
||||
# Azure GPT image models support up to 16 input images for edits.
|
||||
return 16
|
||||
|
||||
def _normalize_model_name(self, model: str) -> str:
|
||||
return model.rsplit("/", 1)[-1]
|
||||
|
||||
def _model_supports_image_edits(self, model: str) -> bool:
|
||||
normalized_model = self._normalize_model_name(model)
|
||||
return (
|
||||
normalized_model.startswith(self._GPT_IMAGE_MODEL_PREFIX)
|
||||
or normalized_model == self._DALL_E_2_MODEL_NAME
|
||||
)
|
||||
|
||||
def generate_image(
|
||||
self,
|
||||
prompt: str,
|
||||
@@ -82,44 +60,14 @@ class AzureImageGenerationProvider(ImageGenerationProvider):
|
||||
size: str,
|
||||
n: int,
|
||||
quality: str | None = None,
|
||||
reference_images: list[ReferenceImage] | None = None,
|
||||
reference_images: list[ReferenceImage] | None = None, # noqa: ARG002
|
||||
**kwargs: Any,
|
||||
) -> ImageGenerationResponse:
|
||||
from litellm import image_generation
|
||||
|
||||
deployment = self._deployment_name or model
|
||||
model_name = f"azure/{deployment}"
|
||||
|
||||
if reference_images:
|
||||
if not self._model_supports_image_edits(model):
|
||||
raise ValueError(
|
||||
f"Model '{model}' does not support image edits with reference images."
|
||||
)
|
||||
|
||||
normalized_model = self._normalize_model_name(model)
|
||||
if (
|
||||
normalized_model == self._DALL_E_2_MODEL_NAME
|
||||
and len(reference_images) > 1
|
||||
):
|
||||
raise ValueError(
|
||||
"Model 'dall-e-2' only supports a single reference image for edits."
|
||||
)
|
||||
|
||||
from litellm import image_edit
|
||||
|
||||
return image_edit(
|
||||
image=[image.data for image in reference_images],
|
||||
prompt=prompt,
|
||||
model=model_name,
|
||||
api_key=self._api_key,
|
||||
api_base=self._api_base,
|
||||
api_version=self._api_version,
|
||||
size=size,
|
||||
n=n,
|
||||
quality=quality,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
from litellm import image_generation
|
||||
|
||||
return image_generation(
|
||||
prompt=prompt,
|
||||
model=model_name,
|
||||
|
||||
@@ -12,9 +12,6 @@ if TYPE_CHECKING:
|
||||
|
||||
|
||||
class OpenAIImageGenerationProvider(ImageGenerationProvider):
|
||||
_GPT_IMAGE_MODEL_PREFIX = "gpt-image-"
|
||||
_DALL_E_2_MODEL_NAME = "dall-e-2"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str,
|
||||
@@ -42,25 +39,6 @@ class OpenAIImageGenerationProvider(ImageGenerationProvider):
|
||||
api_base=credentials.api_base,
|
||||
)
|
||||
|
||||
@property
|
||||
def supports_reference_images(self) -> bool:
|
||||
return True
|
||||
|
||||
@property
|
||||
def max_reference_images(self) -> int:
|
||||
# GPT image models support up to 16 input images for edits.
|
||||
return 16
|
||||
|
||||
def _normalize_model_name(self, model: str) -> str:
|
||||
return model.rsplit("/", 1)[-1]
|
||||
|
||||
def _model_supports_image_edits(self, model: str) -> bool:
|
||||
normalized_model = self._normalize_model_name(model)
|
||||
return (
|
||||
normalized_model.startswith(self._GPT_IMAGE_MODEL_PREFIX)
|
||||
or normalized_model == self._DALL_E_2_MODEL_NAME
|
||||
)
|
||||
|
||||
def generate_image(
|
||||
self,
|
||||
prompt: str,
|
||||
@@ -68,38 +46,9 @@ class OpenAIImageGenerationProvider(ImageGenerationProvider):
|
||||
size: str,
|
||||
n: int,
|
||||
quality: str | None = None,
|
||||
reference_images: list[ReferenceImage] | None = None,
|
||||
reference_images: list[ReferenceImage] | None = None, # noqa: ARG002
|
||||
**kwargs: Any,
|
||||
) -> ImageGenerationResponse:
|
||||
if reference_images:
|
||||
if not self._model_supports_image_edits(model):
|
||||
raise ValueError(
|
||||
f"Model '{model}' does not support image edits with reference images."
|
||||
)
|
||||
|
||||
normalized_model = self._normalize_model_name(model)
|
||||
if (
|
||||
normalized_model == self._DALL_E_2_MODEL_NAME
|
||||
and len(reference_images) > 1
|
||||
):
|
||||
raise ValueError(
|
||||
"Model 'dall-e-2' only supports a single reference image for edits."
|
||||
)
|
||||
|
||||
from litellm import image_edit
|
||||
|
||||
return image_edit(
|
||||
image=[image.data for image in reference_images],
|
||||
prompt=prompt,
|
||||
model=model,
|
||||
api_key=self._api_key,
|
||||
api_base=self._api_base,
|
||||
size=size,
|
||||
n=n,
|
||||
quality=quality,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
from litellm import image_generation
|
||||
|
||||
return image_generation(
|
||||
|
||||
@@ -49,7 +49,6 @@ from onyx.indexing.embedder import IndexingEmbedder
|
||||
from onyx.indexing.models import DocAwareChunk
|
||||
from onyx.indexing.models import IndexingBatchAdapter
|
||||
from onyx.indexing.models import UpdatableChunkData
|
||||
from onyx.indexing.postgres_sanitization import sanitize_documents_for_postgres
|
||||
from onyx.indexing.vector_db_insertion import write_chunks_to_vector_db_with_backoff
|
||||
from onyx.llm.factory import get_default_llm_with_vision
|
||||
from onyx.llm.factory import get_llm_for_contextual_rag
|
||||
@@ -229,8 +228,6 @@ def index_doc_batch_prepare(
|
||||
) -> DocumentBatchPrepareContext | None:
|
||||
"""Sets up the documents in the relational DB (source of truth) for permissions, metadata, etc.
|
||||
This preceeds indexing it into the actual document index."""
|
||||
documents = sanitize_documents_for_postgres(documents)
|
||||
|
||||
# Create a trimmed list of docs that don't have a newer updated at
|
||||
# Shortcuts the time-consuming flow on connector index retries
|
||||
document_ids: list[str] = [document.id for document in documents]
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
from typing import Any
|
||||
|
||||
from onyx.access.models import ExternalAccess
|
||||
from onyx.connectors.models import BasicExpertInfo
|
||||
from onyx.connectors.models import Document
|
||||
from onyx.connectors.models import HierarchyNode
|
||||
|
||||
|
||||
def _sanitize_string(value: str) -> str:
|
||||
return value.replace("\x00", "")
|
||||
|
||||
|
||||
def _sanitize_json_like(value: Any) -> Any:
|
||||
if isinstance(value, str):
|
||||
return _sanitize_string(value)
|
||||
|
||||
if isinstance(value, list):
|
||||
return [_sanitize_json_like(item) for item in value]
|
||||
|
||||
if isinstance(value, tuple):
|
||||
return tuple(_sanitize_json_like(item) for item in value)
|
||||
|
||||
if isinstance(value, dict):
|
||||
sanitized: dict[Any, Any] = {}
|
||||
for key, nested_value in value.items():
|
||||
cleaned_key = _sanitize_string(key) if isinstance(key, str) else key
|
||||
sanitized[cleaned_key] = _sanitize_json_like(nested_value)
|
||||
return sanitized
|
||||
|
||||
return value
|
||||
|
||||
|
||||
def _sanitize_expert_info(expert: BasicExpertInfo) -> BasicExpertInfo:
|
||||
return expert.model_copy(
|
||||
update={
|
||||
"display_name": (
|
||||
_sanitize_string(expert.display_name)
|
||||
if expert.display_name is not None
|
||||
else None
|
||||
),
|
||||
"first_name": (
|
||||
_sanitize_string(expert.first_name)
|
||||
if expert.first_name is not None
|
||||
else None
|
||||
),
|
||||
"middle_initial": (
|
||||
_sanitize_string(expert.middle_initial)
|
||||
if expert.middle_initial is not None
|
||||
else None
|
||||
),
|
||||
"last_name": (
|
||||
_sanitize_string(expert.last_name)
|
||||
if expert.last_name is not None
|
||||
else None
|
||||
),
|
||||
"email": (
|
||||
_sanitize_string(expert.email) if expert.email is not None else None
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _sanitize_external_access(external_access: ExternalAccess) -> ExternalAccess:
|
||||
return ExternalAccess(
|
||||
external_user_emails={
|
||||
_sanitize_string(email) for email in external_access.external_user_emails
|
||||
},
|
||||
external_user_group_ids={
|
||||
_sanitize_string(group_id)
|
||||
for group_id in external_access.external_user_group_ids
|
||||
},
|
||||
is_public=external_access.is_public,
|
||||
)
|
||||
|
||||
|
||||
def sanitize_document_for_postgres(document: Document) -> Document:
|
||||
cleaned_doc = document.model_copy(deep=True)
|
||||
|
||||
cleaned_doc.id = _sanitize_string(cleaned_doc.id)
|
||||
cleaned_doc.semantic_identifier = _sanitize_string(cleaned_doc.semantic_identifier)
|
||||
if cleaned_doc.title is not None:
|
||||
cleaned_doc.title = _sanitize_string(cleaned_doc.title)
|
||||
if cleaned_doc.parent_hierarchy_raw_node_id is not None:
|
||||
cleaned_doc.parent_hierarchy_raw_node_id = _sanitize_string(
|
||||
cleaned_doc.parent_hierarchy_raw_node_id
|
||||
)
|
||||
|
||||
cleaned_doc.metadata = {
|
||||
_sanitize_string(key): (
|
||||
[_sanitize_string(item) for item in value]
|
||||
if isinstance(value, list)
|
||||
else _sanitize_string(value)
|
||||
)
|
||||
for key, value in cleaned_doc.metadata.items()
|
||||
}
|
||||
|
||||
if cleaned_doc.doc_metadata is not None:
|
||||
cleaned_doc.doc_metadata = _sanitize_json_like(cleaned_doc.doc_metadata)
|
||||
|
||||
if cleaned_doc.primary_owners is not None:
|
||||
cleaned_doc.primary_owners = [
|
||||
_sanitize_expert_info(expert) for expert in cleaned_doc.primary_owners
|
||||
]
|
||||
if cleaned_doc.secondary_owners is not None:
|
||||
cleaned_doc.secondary_owners = [
|
||||
_sanitize_expert_info(expert) for expert in cleaned_doc.secondary_owners
|
||||
]
|
||||
|
||||
if cleaned_doc.external_access is not None:
|
||||
cleaned_doc.external_access = _sanitize_external_access(
|
||||
cleaned_doc.external_access
|
||||
)
|
||||
|
||||
for section in cleaned_doc.sections:
|
||||
if section.link is not None:
|
||||
section.link = _sanitize_string(section.link)
|
||||
if section.text is not None:
|
||||
section.text = _sanitize_string(section.text)
|
||||
if section.image_file_id is not None:
|
||||
section.image_file_id = _sanitize_string(section.image_file_id)
|
||||
|
||||
return cleaned_doc
|
||||
|
||||
|
||||
def sanitize_documents_for_postgres(documents: list[Document]) -> list[Document]:
|
||||
return [sanitize_document_for_postgres(document) for document in documents]
|
||||
|
||||
|
||||
def sanitize_hierarchy_node_for_postgres(node: HierarchyNode) -> HierarchyNode:
|
||||
cleaned_node = node.model_copy(deep=True)
|
||||
|
||||
cleaned_node.raw_node_id = _sanitize_string(cleaned_node.raw_node_id)
|
||||
cleaned_node.display_name = _sanitize_string(cleaned_node.display_name)
|
||||
if cleaned_node.raw_parent_id is not None:
|
||||
cleaned_node.raw_parent_id = _sanitize_string(cleaned_node.raw_parent_id)
|
||||
if cleaned_node.link is not None:
|
||||
cleaned_node.link = _sanitize_string(cleaned_node.link)
|
||||
|
||||
if cleaned_node.external_access is not None:
|
||||
cleaned_node.external_access = _sanitize_external_access(
|
||||
cleaned_node.external_access
|
||||
)
|
||||
|
||||
return cleaned_node
|
||||
|
||||
|
||||
def sanitize_hierarchy_nodes_for_postgres(
|
||||
nodes: list[HierarchyNode],
|
||||
) -> list[HierarchyNode]:
|
||||
return [sanitize_hierarchy_node_for_postgres(node) for node in nodes]
|
||||
@@ -97,9 +97,6 @@ from onyx.server.features.web_search.api import router as web_search_router
|
||||
from onyx.server.federated.api import router as federated_router
|
||||
from onyx.server.kg.api import admin_router as kg_admin_router
|
||||
from onyx.server.manage.administrative import router as admin_router
|
||||
from onyx.server.manage.code_interpreter.api import (
|
||||
admin_router as code_interpreter_admin_router,
|
||||
)
|
||||
from onyx.server.manage.discord_bot.api import router as discord_bot_router
|
||||
from onyx.server.manage.embedding.api import admin_router as embedding_admin_router
|
||||
from onyx.server.manage.embedding.api import basic_router as embedding_router
|
||||
@@ -424,9 +421,6 @@ def get_application(lifespan_override: Lifespan | None = None) -> FastAPI:
|
||||
include_router_with_global_prefix_prepended(application, llm_admin_router)
|
||||
include_router_with_global_prefix_prepended(application, kg_admin_router)
|
||||
include_router_with_global_prefix_prepended(application, llm_router)
|
||||
include_router_with_global_prefix_prepended(
|
||||
application, code_interpreter_admin_router
|
||||
)
|
||||
include_router_with_global_prefix_prepended(
|
||||
application, image_generation_admin_router
|
||||
)
|
||||
|
||||
@@ -1,68 +1,14 @@
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from mistune import create_markdown
|
||||
from mistune import HTMLRenderer
|
||||
|
||||
_CITATION_LINK_PATTERN = re.compile(r"\[\[\d+\]\]\(")
|
||||
|
||||
|
||||
def _extract_link_destination(message: str, start_idx: int) -> tuple[str, int | None]:
|
||||
"""Extract markdown link destination, allowing nested parentheses in the URL."""
|
||||
depth = 0
|
||||
i = start_idx
|
||||
|
||||
while i < len(message):
|
||||
curr = message[i]
|
||||
if curr == "\\":
|
||||
i += 2
|
||||
continue
|
||||
|
||||
if curr == "(":
|
||||
depth += 1
|
||||
elif curr == ")":
|
||||
if depth == 0:
|
||||
return message[start_idx:i], i
|
||||
depth -= 1
|
||||
i += 1
|
||||
|
||||
return message[start_idx:], None
|
||||
|
||||
|
||||
def _normalize_citation_link_destinations(message: str) -> str:
|
||||
"""Wrap citation URLs in angle brackets so markdown parsers handle parentheses safely."""
|
||||
if "[[" not in message:
|
||||
return message
|
||||
|
||||
normalized_parts: list[str] = []
|
||||
cursor = 0
|
||||
|
||||
while match := _CITATION_LINK_PATTERN.search(message, cursor):
|
||||
normalized_parts.append(message[cursor : match.end()])
|
||||
destination_start = match.end()
|
||||
destination, end_idx = _extract_link_destination(message, destination_start)
|
||||
if end_idx is None:
|
||||
normalized_parts.append(message[destination_start:])
|
||||
return "".join(normalized_parts)
|
||||
|
||||
already_wrapped = destination.startswith("<") and destination.endswith(">")
|
||||
if destination and not already_wrapped:
|
||||
destination = f"<{destination}>"
|
||||
|
||||
normalized_parts.append(destination)
|
||||
normalized_parts.append(")")
|
||||
cursor = end_idx + 1
|
||||
|
||||
normalized_parts.append(message[cursor:])
|
||||
return "".join(normalized_parts)
|
||||
|
||||
|
||||
def format_slack_message(message: str | None) -> str:
|
||||
if message is None:
|
||||
return ""
|
||||
md = create_markdown(renderer=SlackRenderer(), plugins=["strikethrough"])
|
||||
normalized_message = _normalize_citation_link_destinations(message)
|
||||
result = md(normalized_message)
|
||||
result = md(message)
|
||||
# With HTMLRenderer, result is always str (not AST list)
|
||||
assert isinstance(result, str)
|
||||
return result
|
||||
|
||||
@@ -762,43 +762,6 @@ def download_webapp(
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{session_id}/download-directory/{path:path}")
|
||||
def download_directory(
|
||||
session_id: UUID,
|
||||
path: str,
|
||||
user: User = Depends(current_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> Response:
|
||||
"""
|
||||
Download a directory as a zip file.
|
||||
|
||||
Returns the specified directory as a zip archive.
|
||||
"""
|
||||
user_id: UUID = user.id
|
||||
session_manager = SessionManager(db_session)
|
||||
|
||||
try:
|
||||
result = session_manager.download_directory(session_id, user_id, path)
|
||||
except ValueError as e:
|
||||
error_message = str(e)
|
||||
if "path traversal" in error_message.lower():
|
||||
raise HTTPException(status_code=403, detail="Access denied")
|
||||
raise HTTPException(status_code=400, detail=error_message)
|
||||
|
||||
if result is None:
|
||||
raise HTTPException(status_code=404, detail="Directory not found")
|
||||
|
||||
zip_bytes, filename = result
|
||||
|
||||
return Response(
|
||||
content=zip_bytes,
|
||||
media_type="application/zip",
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="{filename}"',
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{session_id}/upload", response_model=UploadResponse)
|
||||
def upload_file_endpoint(
|
||||
session_id: UUID,
|
||||
|
||||
@@ -107,23 +107,27 @@ def get_or_create_craft_connector(db_session: Session, user: User) -> tuple[int,
|
||||
)
|
||||
|
||||
for cc_pair in cc_pairs:
|
||||
if (
|
||||
cc_pair.connector.source == DocumentSource.CRAFT_FILE
|
||||
and cc_pair.creator_id == user.id
|
||||
):
|
||||
if cc_pair.connector.source == DocumentSource.CRAFT_FILE:
|
||||
return cc_pair.connector.id, cc_pair.credential.id
|
||||
|
||||
# No cc_pair for this user — find or create the shared CRAFT_FILE connector
|
||||
# Check for orphaned connector (created but cc_pair creation failed previously)
|
||||
existing_connectors = fetch_connectors(
|
||||
db_session, sources=[DocumentSource.CRAFT_FILE]
|
||||
)
|
||||
connector_id: int | None = None
|
||||
orphaned_connector = None
|
||||
for conn in existing_connectors:
|
||||
if conn.name == USER_LIBRARY_CONNECTOR_NAME:
|
||||
connector_id = conn.id
|
||||
if conn.name != USER_LIBRARY_CONNECTOR_NAME:
|
||||
continue
|
||||
if not conn.credentials:
|
||||
orphaned_connector = conn
|
||||
break
|
||||
|
||||
if connector_id is None:
|
||||
if orphaned_connector:
|
||||
connector_id = orphaned_connector.id
|
||||
logger.info(
|
||||
f"Found orphaned User Library connector {connector_id}, completing setup"
|
||||
)
|
||||
else:
|
||||
connector_data = ConnectorBase(
|
||||
name=USER_LIBRARY_CONNECTOR_NAME,
|
||||
source=DocumentSource.CRAFT_FILE,
|
||||
|
||||
Binary file not shown.
@@ -68,7 +68,6 @@ from onyx.server.features.build.db.sandbox import create_sandbox__no_commit
|
||||
from onyx.server.features.build.db.sandbox import get_running_sandbox_count_by_tenant
|
||||
from onyx.server.features.build.db.sandbox import get_sandbox_by_session_id
|
||||
from onyx.server.features.build.db.sandbox import get_sandbox_by_user_id
|
||||
from onyx.server.features.build.db.sandbox import get_snapshots_for_session
|
||||
from onyx.server.features.build.db.sandbox import update_sandbox_heartbeat
|
||||
from onyx.server.features.build.db.sandbox import update_sandbox_status__no_commit
|
||||
from onyx.server.features.build.sandbox import get_sandbox_manager
|
||||
@@ -647,30 +646,16 @@ class SessionManager:
|
||||
|
||||
if sandbox and sandbox.status.is_active():
|
||||
# Quick health check to verify sandbox is actually responsive
|
||||
# AND verify the session workspace still exists on disk
|
||||
# (it may have been wiped if the sandbox was re-provisioned)
|
||||
is_healthy = self._sandbox_manager.health_check(sandbox.id, timeout=5.0)
|
||||
workspace_exists = (
|
||||
is_healthy
|
||||
and self._sandbox_manager.session_workspace_exists(
|
||||
sandbox.id, existing.id
|
||||
)
|
||||
)
|
||||
if is_healthy and workspace_exists:
|
||||
if self._sandbox_manager.health_check(sandbox.id, timeout=5.0):
|
||||
logger.info(
|
||||
f"Returning existing empty session {existing.id} for user {user_id}"
|
||||
)
|
||||
return existing
|
||||
elif not is_healthy:
|
||||
else:
|
||||
logger.warning(
|
||||
f"Empty session {existing.id} has unhealthy sandbox {sandbox.id}. "
|
||||
f"Deleting and creating fresh session."
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Empty session {existing.id} workspace missing in sandbox "
|
||||
f"{sandbox.id}. Deleting and creating fresh session."
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
f"Empty session {existing.id} has no active sandbox "
|
||||
@@ -1050,23 +1035,6 @@ class SessionManager:
|
||||
# workspace cleanup fails (e.g., if pod is already terminated)
|
||||
logger.warning(f"Failed to cleanup session workspace {session_id}: {e}")
|
||||
|
||||
# Delete snapshot files from S3 before removing DB records
|
||||
snapshots = get_snapshots_for_session(self._db_session, session_id)
|
||||
if snapshots:
|
||||
from onyx.file_store.file_store import get_default_file_store
|
||||
from onyx.server.features.build.sandbox.manager.snapshot_manager import (
|
||||
SnapshotManager,
|
||||
)
|
||||
|
||||
snapshot_manager = SnapshotManager(get_default_file_store())
|
||||
for snapshot in snapshots:
|
||||
try:
|
||||
snapshot_manager.delete_snapshot(snapshot.storage_path)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Failed to delete snapshot file {snapshot.storage_path}: {e}"
|
||||
)
|
||||
|
||||
# Delete session (uses flush, caller commits)
|
||||
return delete_build_session__no_commit(session_id, user_id, self._db_session)
|
||||
|
||||
@@ -1935,94 +1903,6 @@ class SessionManager:
|
||||
|
||||
return zip_buffer.getvalue(), filename
|
||||
|
||||
def download_directory(
|
||||
self,
|
||||
session_id: UUID,
|
||||
user_id: UUID,
|
||||
path: str,
|
||||
) -> tuple[bytes, str] | None:
|
||||
"""
|
||||
Create a zip file of an arbitrary directory in the session workspace.
|
||||
|
||||
Args:
|
||||
session_id: The session UUID
|
||||
user_id: The user ID to verify ownership
|
||||
path: Relative path to the directory (within session workspace)
|
||||
|
||||
Returns:
|
||||
Tuple of (zip_bytes, filename) or None if session not found
|
||||
|
||||
Raises:
|
||||
ValueError: If path traversal attempted or path is not a directory
|
||||
"""
|
||||
# Verify session ownership
|
||||
session = get_build_session(session_id, user_id, self._db_session)
|
||||
if session is None:
|
||||
return None
|
||||
|
||||
sandbox = get_sandbox_by_user_id(self._db_session, user_id)
|
||||
if sandbox is None:
|
||||
return None
|
||||
|
||||
# Check if directory exists
|
||||
try:
|
||||
self._sandbox_manager.list_directory(
|
||||
sandbox_id=sandbox.id,
|
||||
session_id=session_id,
|
||||
path=path,
|
||||
)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
# Recursively collect all files
|
||||
def collect_files(dir_path: str) -> list[tuple[str, str]]:
|
||||
"""Collect all files recursively, returning (full_path, arcname) tuples."""
|
||||
files: list[tuple[str, str]] = []
|
||||
try:
|
||||
entries = self._sandbox_manager.list_directory(
|
||||
sandbox_id=sandbox.id,
|
||||
session_id=session_id,
|
||||
path=dir_path,
|
||||
)
|
||||
for entry in entries:
|
||||
if entry.is_directory:
|
||||
files.extend(collect_files(entry.path))
|
||||
else:
|
||||
# arcname is relative to the target directory
|
||||
prefix_len = len(path) + 1 # +1 for trailing slash
|
||||
arcname = entry.path[prefix_len:]
|
||||
files.append((entry.path, arcname))
|
||||
except ValueError:
|
||||
pass
|
||||
return files
|
||||
|
||||
file_list = collect_files(path)
|
||||
|
||||
# Create zip file in memory
|
||||
zip_buffer = io.BytesIO()
|
||||
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file:
|
||||
for full_path, arcname in file_list:
|
||||
try:
|
||||
content = self._sandbox_manager.read_file(
|
||||
sandbox_id=sandbox.id,
|
||||
session_id=session_id,
|
||||
path=full_path,
|
||||
)
|
||||
zip_file.writestr(arcname, content)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
zip_buffer.seek(0)
|
||||
|
||||
# Use the directory name for the zip filename
|
||||
dir_name = Path(path).name
|
||||
safe_name = "".join(
|
||||
c if c.isalnum() or c in ("-", "_", ".") else "_" for c in dir_name
|
||||
)
|
||||
filename = f"{safe_name}.zip"
|
||||
|
||||
return zip_buffer.getvalue(), filename
|
||||
|
||||
# =========================================================================
|
||||
# File System Operations
|
||||
# =========================================================================
|
||||
@@ -2057,18 +1937,11 @@ class SessionManager:
|
||||
return None
|
||||
|
||||
# Use sandbox manager to list directory (works for both local and K8s)
|
||||
# If the directory doesn't exist (e.g., session workspace not yet loaded),
|
||||
# return an empty listing rather than erroring out.
|
||||
try:
|
||||
raw_entries = self._sandbox_manager.list_directory(
|
||||
sandbox_id=sandbox.id,
|
||||
session_id=session_id,
|
||||
path=path,
|
||||
)
|
||||
except ValueError as e:
|
||||
if "path traversal" in str(e).lower():
|
||||
raise
|
||||
return DirectoryListing(path=path, entries=[])
|
||||
raw_entries = self._sandbox_manager.list_directory(
|
||||
sandbox_id=sandbox.id,
|
||||
session_id=session_id,
|
||||
path=path,
|
||||
)
|
||||
|
||||
# Filter hidden files and directories
|
||||
entries: list[FileSystemEntry] = [
|
||||
|
||||
@@ -12,18 +12,11 @@ from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from onyx.auth.users import current_user
|
||||
from onyx.background.celery.tasks.user_file_processing.tasks import (
|
||||
enqueue_user_file_project_sync_task,
|
||||
)
|
||||
from onyx.background.celery.tasks.user_file_processing.tasks import (
|
||||
get_user_file_project_sync_queue_depth,
|
||||
)
|
||||
from onyx.background.celery.versioned_apps.client import app as client_app
|
||||
from onyx.configs.constants import OnyxCeleryPriority
|
||||
from onyx.configs.constants import OnyxCeleryQueues
|
||||
from onyx.configs.constants import OnyxCeleryTask
|
||||
from onyx.configs.constants import PUBLIC_API_TAGS
|
||||
from onyx.configs.constants import USER_FILE_PROJECT_SYNC_MAX_QUEUE_DEPTH
|
||||
from onyx.db.engine.sql_engine import get_session
|
||||
from onyx.db.enums import UserFileStatus
|
||||
from onyx.db.models import ChatSession
|
||||
@@ -34,7 +27,6 @@ from onyx.db.models import UserProject
|
||||
from onyx.db.persona import get_personas_by_ids
|
||||
from onyx.db.projects import get_project_token_count
|
||||
from onyx.db.projects import upload_files_to_user_files_with_indexing
|
||||
from onyx.redis.redis_pool import get_redis_client
|
||||
from onyx.server.features.projects.models import CategorizedFilesSnapshot
|
||||
from onyx.server.features.projects.models import ChatSessionRequest
|
||||
from onyx.server.features.projects.models import TokenCountResponse
|
||||
@@ -55,33 +47,6 @@ class UserFileDeleteResult(BaseModel):
|
||||
assistant_names: list[str] = []
|
||||
|
||||
|
||||
def _trigger_user_file_project_sync(user_file_id: UUID, tenant_id: str) -> None:
|
||||
queue_depth = get_user_file_project_sync_queue_depth(client_app)
|
||||
if queue_depth > USER_FILE_PROJECT_SYNC_MAX_QUEUE_DEPTH:
|
||||
logger.warning(
|
||||
f"Skipping immediate project sync for user_file_id={user_file_id} due to "
|
||||
f"queue depth {queue_depth}>{USER_FILE_PROJECT_SYNC_MAX_QUEUE_DEPTH}. "
|
||||
"It will be picked up by beat later."
|
||||
)
|
||||
return
|
||||
|
||||
redis_client = get_redis_client(tenant_id=tenant_id)
|
||||
enqueued = enqueue_user_file_project_sync_task(
|
||||
celery_app=client_app,
|
||||
redis_client=redis_client,
|
||||
user_file_id=user_file_id,
|
||||
tenant_id=tenant_id,
|
||||
priority=OnyxCeleryPriority.HIGHEST,
|
||||
)
|
||||
if not enqueued:
|
||||
logger.info(
|
||||
f"Skipped duplicate project sync enqueue for user_file_id={user_file_id}"
|
||||
)
|
||||
return
|
||||
|
||||
logger.info(f"Triggered project sync for user_file_id={user_file_id}")
|
||||
|
||||
|
||||
@router.get("", tags=PUBLIC_API_TAGS)
|
||||
def get_projects(
|
||||
user: User = Depends(current_user),
|
||||
@@ -224,7 +189,15 @@ def unlink_user_file_from_project(
|
||||
db_session.commit()
|
||||
|
||||
tenant_id = get_current_tenant_id()
|
||||
_trigger_user_file_project_sync(user_file.id, tenant_id)
|
||||
task = client_app.send_task(
|
||||
OnyxCeleryTask.PROCESS_SINGLE_USER_FILE_PROJECT_SYNC,
|
||||
kwargs={"user_file_id": user_file.id, "tenant_id": tenant_id},
|
||||
queue=OnyxCeleryQueues.USER_FILE_PROJECT_SYNC,
|
||||
priority=OnyxCeleryPriority.HIGHEST,
|
||||
)
|
||||
logger.info(
|
||||
f"Triggered project sync for user_file_id={user_file.id} with task_id={task.id}"
|
||||
)
|
||||
|
||||
return Response(status_code=204)
|
||||
|
||||
@@ -268,7 +241,15 @@ def link_user_file_to_project(
|
||||
db_session.commit()
|
||||
|
||||
tenant_id = get_current_tenant_id()
|
||||
_trigger_user_file_project_sync(user_file.id, tenant_id)
|
||||
task = client_app.send_task(
|
||||
OnyxCeleryTask.PROCESS_SINGLE_USER_FILE_PROJECT_SYNC,
|
||||
kwargs={"user_file_id": user_file.id, "tenant_id": tenant_id},
|
||||
queue=OnyxCeleryQueues.USER_FILE_PROJECT_SYNC,
|
||||
priority=OnyxCeleryPriority.HIGHEST,
|
||||
)
|
||||
logger.info(
|
||||
f"Triggered project sync for user_file_id={user_file.id} with task_id={task.id}"
|
||||
)
|
||||
|
||||
return UserFileSnapshot.from_model(user_file)
|
||||
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
from fastapi import APIRouter
|
||||
from fastapi import Depends
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from onyx.auth.users import current_admin_user
|
||||
from onyx.db.code_interpreter import fetch_code_interpreter_server
|
||||
from onyx.db.code_interpreter import update_code_interpreter_server_enabled
|
||||
from onyx.db.engine.sql_engine import get_session
|
||||
from onyx.db.models import User
|
||||
from onyx.server.manage.code_interpreter.models import CodeInterpreterServer
|
||||
from onyx.server.manage.code_interpreter.models import CodeInterpreterServerHealth
|
||||
from onyx.tools.tool_implementations.python.code_interpreter_client import (
|
||||
CodeInterpreterClient,
|
||||
)
|
||||
|
||||
admin_router = APIRouter(prefix="/admin/code-interpreter")
|
||||
|
||||
|
||||
@admin_router.get("/health")
|
||||
def get_code_interpreter_health(
|
||||
_: User = Depends(current_admin_user),
|
||||
) -> CodeInterpreterServerHealth:
|
||||
try:
|
||||
client = CodeInterpreterClient()
|
||||
return CodeInterpreterServerHealth(healthy=client.health())
|
||||
except ValueError:
|
||||
return CodeInterpreterServerHealth(healthy=False)
|
||||
|
||||
|
||||
@admin_router.get("")
|
||||
def get_code_interpreter(
|
||||
_: User = Depends(current_admin_user), db_session: Session = Depends(get_session)
|
||||
) -> CodeInterpreterServer:
|
||||
ci_server = fetch_code_interpreter_server(db_session)
|
||||
return CodeInterpreterServer(enabled=ci_server.server_enabled)
|
||||
|
||||
|
||||
@admin_router.put("")
|
||||
def update_code_interpreter(
|
||||
update: CodeInterpreterServer,
|
||||
_: User = Depends(current_admin_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> None:
|
||||
update_code_interpreter_server_enabled(
|
||||
db_session=db_session,
|
||||
enabled=update.enabled,
|
||||
)
|
||||
@@ -1,9 +0,0 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class CodeInterpreterServer(BaseModel):
|
||||
enabled: bool
|
||||
|
||||
|
||||
class CodeInterpreterServerHealth(BaseModel):
|
||||
healthy: bool
|
||||
@@ -22,10 +22,7 @@ from onyx.auth.users import current_chat_accessible_user
|
||||
from onyx.db.engine.sql_engine import get_session
|
||||
from onyx.db.enums import LLMModelFlowType
|
||||
from onyx.db.llm import can_user_access_llm_provider
|
||||
from onyx.db.llm import fetch_default_llm_model
|
||||
from onyx.db.llm import fetch_default_vision_model
|
||||
from onyx.db.llm import fetch_existing_llm_provider
|
||||
from onyx.db.llm import fetch_existing_llm_provider_by_id
|
||||
from onyx.db.llm import fetch_existing_llm_providers
|
||||
from onyx.db.llm import fetch_existing_models
|
||||
from onyx.db.llm import fetch_persona_with_groups
|
||||
@@ -55,10 +52,8 @@ from onyx.llm.well_known_providers.llm_provider_options import (
|
||||
)
|
||||
from onyx.server.manage.llm.models import BedrockFinalModelResponse
|
||||
from onyx.server.manage.llm.models import BedrockModelsRequest
|
||||
from onyx.server.manage.llm.models import DefaultModel
|
||||
from onyx.server.manage.llm.models import LLMCost
|
||||
from onyx.server.manage.llm.models import LLMProviderDescriptor
|
||||
from onyx.server.manage.llm.models import LLMProviderResponse
|
||||
from onyx.server.manage.llm.models import LLMProviderUpsertRequest
|
||||
from onyx.server.manage.llm.models import LLMProviderView
|
||||
from onyx.server.manage.llm.models import ModelConfigurationUpsertRequest
|
||||
@@ -238,9 +233,12 @@ def test_llm_configuration(
|
||||
|
||||
test_api_key = test_llm_request.api_key
|
||||
test_custom_config = test_llm_request.custom_config
|
||||
if test_llm_request.id:
|
||||
existing_provider = fetch_existing_llm_provider_by_id(
|
||||
id=test_llm_request.id, db_session=db_session
|
||||
if test_llm_request.name:
|
||||
# NOTE: we are querying by name. we probably should be querying by an invariant id, but
|
||||
# as it turns out the name is not editable in the UI and other code also keys off name,
|
||||
# so we won't rock the boat just yet.
|
||||
existing_provider = fetch_existing_llm_provider(
|
||||
name=test_llm_request.name, db_session=db_session
|
||||
)
|
||||
if existing_provider:
|
||||
test_custom_config = _restore_masked_custom_config_values(
|
||||
@@ -270,7 +268,7 @@ def test_llm_configuration(
|
||||
|
||||
llm = get_llm(
|
||||
provider=test_llm_request.provider,
|
||||
model=test_llm_request.model,
|
||||
model=test_llm_request.default_model_name,
|
||||
api_key=test_api_key,
|
||||
api_base=test_llm_request.api_base,
|
||||
api_version=test_llm_request.api_version,
|
||||
@@ -305,7 +303,7 @@ def list_llm_providers(
|
||||
include_image_gen: bool = Query(False),
|
||||
_: User = Depends(current_admin_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> LLMProviderResponse[LLMProviderView]:
|
||||
) -> list[LLMProviderView]:
|
||||
start_time = datetime.now(timezone.utc)
|
||||
logger.debug("Starting to fetch LLM providers")
|
||||
|
||||
@@ -330,15 +328,7 @@ def list_llm_providers(
|
||||
duration = (end_time - start_time).total_seconds()
|
||||
logger.debug(f"Completed fetching LLM providers in {duration:.2f} seconds")
|
||||
|
||||
return LLMProviderResponse[LLMProviderView].from_models(
|
||||
providers=llm_provider_list,
|
||||
default_text=DefaultModel.from_model_config(
|
||||
fetch_default_llm_model(db_session)
|
||||
),
|
||||
default_vision=DefaultModel.from_model_config(
|
||||
fetch_default_vision_model(db_session)
|
||||
),
|
||||
)
|
||||
return llm_provider_list
|
||||
|
||||
|
||||
@admin_router.put("/provider")
|
||||
@@ -479,29 +469,28 @@ def delete_llm_provider(
|
||||
raise HTTPException(status_code=404, detail=str(e))
|
||||
|
||||
|
||||
@admin_router.post("/default")
|
||||
@admin_router.post("/provider/{provider_id}/default")
|
||||
def set_provider_as_default(
|
||||
default_model_request: DefaultModel,
|
||||
provider_id: int,
|
||||
_: User = Depends(current_admin_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> None:
|
||||
update_default_provider(
|
||||
provider_id=default_model_request.provider_id,
|
||||
model_name=default_model_request.model_name,
|
||||
db_session=db_session,
|
||||
)
|
||||
update_default_provider(provider_id=provider_id, db_session=db_session)
|
||||
|
||||
|
||||
@admin_router.post("/default-vision")
|
||||
@admin_router.post("/provider/{provider_id}/default-vision")
|
||||
def set_provider_as_default_vision(
|
||||
default_model: DefaultModel,
|
||||
provider_id: int,
|
||||
vision_model: str | None = Query(
|
||||
None, description="The default vision model to use"
|
||||
),
|
||||
_: User = Depends(current_admin_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> None:
|
||||
if vision_model is None:
|
||||
raise HTTPException(status_code=404, detail="Vision model not provided")
|
||||
update_default_vision_provider(
|
||||
provider_id=default_model.provider_id,
|
||||
vision_model=default_model.model_name,
|
||||
db_session=db_session,
|
||||
provider_id=provider_id, vision_model=vision_model, db_session=db_session
|
||||
)
|
||||
|
||||
|
||||
@@ -527,7 +516,7 @@ def get_auto_config(
|
||||
def get_vision_capable_providers(
|
||||
_: User = Depends(current_admin_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> LLMProviderResponse[VisionProviderResponse]:
|
||||
) -> list[VisionProviderResponse]:
|
||||
"""Return a list of LLM providers and their models that support image input"""
|
||||
vision_models = fetch_existing_models(
|
||||
db_session=db_session, flow_types=[LLMModelFlowType.VISION]
|
||||
@@ -556,13 +545,7 @@ def get_vision_capable_providers(
|
||||
]
|
||||
|
||||
logger.debug(f"Found {len(vision_provider_response)} vision-capable providers")
|
||||
|
||||
return LLMProviderResponse[VisionProviderResponse].from_models(
|
||||
providers=vision_provider_response,
|
||||
default_vision=DefaultModel.from_model_config(
|
||||
fetch_default_vision_model(db_session)
|
||||
),
|
||||
)
|
||||
return vision_provider_response
|
||||
|
||||
|
||||
"""Endpoints for all"""
|
||||
@@ -572,7 +555,7 @@ def get_vision_capable_providers(
|
||||
def list_llm_provider_basics(
|
||||
user: User = Depends(current_chat_accessible_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> LLMProviderResponse[LLMProviderDescriptor]:
|
||||
) -> list[LLMProviderDescriptor]:
|
||||
"""Get LLM providers accessible to the current user.
|
||||
|
||||
Returns:
|
||||
@@ -609,15 +592,7 @@ def list_llm_provider_basics(
|
||||
f"Completed fetching {len(accessible_providers)} user-accessible providers in {duration:.2f} seconds"
|
||||
)
|
||||
|
||||
return LLMProviderResponse[LLMProviderDescriptor].from_models(
|
||||
providers=accessible_providers,
|
||||
default_text=DefaultModel.from_model_config(
|
||||
fetch_default_llm_model(db_session)
|
||||
),
|
||||
default_vision=DefaultModel.from_model_config(
|
||||
fetch_default_vision_model(db_session)
|
||||
),
|
||||
)
|
||||
return accessible_providers
|
||||
|
||||
|
||||
def get_valid_model_names_for_persona(
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from typing import Generic
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TypeVar
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import Field
|
||||
@@ -25,8 +21,6 @@ if TYPE_CHECKING:
|
||||
ModelConfiguration as ModelConfigurationModel,
|
||||
)
|
||||
|
||||
T = TypeVar("T", "LLMProviderDescriptor", "LLMProviderView")
|
||||
|
||||
|
||||
# TODO: Clear this up on api refactor
|
||||
# There is still logic that requires sending each providers default model name
|
||||
@@ -58,17 +52,19 @@ def get_default_vision_model_name(llm_provider_model: "LLMProviderModel") -> str
|
||||
|
||||
class TestLLMRequest(BaseModel):
|
||||
# provider level
|
||||
id: int | None = None
|
||||
name: str | None = None
|
||||
provider: str
|
||||
model: str
|
||||
api_key: str | None = None
|
||||
api_base: str | None = None
|
||||
api_version: str | None = None
|
||||
custom_config: dict[str, str] | None = None
|
||||
|
||||
# model level
|
||||
default_model_name: str
|
||||
deployment_name: str | None = None
|
||||
|
||||
model_configurations: list["ModelConfigurationUpsertRequest"]
|
||||
|
||||
# if try and use the existing API/custom config key
|
||||
api_key_changed: bool
|
||||
custom_config_changed: bool
|
||||
@@ -425,38 +421,3 @@ class OpenRouterFinalModelResponse(BaseModel):
|
||||
int | None
|
||||
) # From OpenRouter API context_length (may be missing for some models)
|
||||
supports_image_input: bool
|
||||
|
||||
|
||||
class DefaultModel(BaseModel):
|
||||
provider_id: int
|
||||
model_name: str
|
||||
|
||||
@classmethod
|
||||
def from_model_config(
|
||||
cls, model_config: ModelConfigurationModel | None
|
||||
) -> DefaultModel | None:
|
||||
if not model_config:
|
||||
return None
|
||||
return cls(
|
||||
provider_id=model_config.llm_provider_id,
|
||||
model_name=model_config.name,
|
||||
)
|
||||
|
||||
|
||||
class LLMProviderResponse(BaseModel, Generic[T]):
|
||||
providers: list[T]
|
||||
default_text: DefaultModel | None = None
|
||||
default_vision: DefaultModel | None = None
|
||||
|
||||
@classmethod
|
||||
def from_models(
|
||||
cls,
|
||||
providers: list[T],
|
||||
default_text: DefaultModel | None = None,
|
||||
default_vision: DefaultModel | None = None,
|
||||
) -> LLMProviderResponse[T]:
|
||||
return cls(
|
||||
providers=providers,
|
||||
default_text=default_text,
|
||||
default_vision=default_vision,
|
||||
)
|
||||
|
||||
@@ -35,18 +35,6 @@ if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
|
||||
class EmailInviteStatus(str, Enum):
|
||||
SENT = "SENT"
|
||||
NOT_CONFIGURED = "NOT_CONFIGURED"
|
||||
SEND_FAILED = "SEND_FAILED"
|
||||
DISABLED = "DISABLED"
|
||||
|
||||
|
||||
class BulkInviteResponse(BaseModel):
|
||||
invited_count: int
|
||||
email_invite_status: EmailInviteStatus
|
||||
|
||||
|
||||
class VersionResponse(BaseModel):
|
||||
backend_version: str
|
||||
|
||||
|
||||
@@ -36,7 +36,6 @@ from onyx.configs.app_configs import AUTH_BACKEND
|
||||
from onyx.configs.app_configs import AUTH_TYPE
|
||||
from onyx.configs.app_configs import AuthBackend
|
||||
from onyx.configs.app_configs import DEV_MODE
|
||||
from onyx.configs.app_configs import EMAIL_CONFIGURED
|
||||
from onyx.configs.app_configs import ENABLE_EMAIL_INVITES
|
||||
from onyx.configs.app_configs import NUM_FREE_TRIAL_USER_INVITES
|
||||
from onyx.configs.app_configs import REDIS_AUTH_KEY_PREFIX
|
||||
@@ -79,10 +78,8 @@ from onyx.server.documents.models import PaginatedReturn
|
||||
from onyx.server.features.projects.models import UserFileSnapshot
|
||||
from onyx.server.manage.models import AllUsersResponse
|
||||
from onyx.server.manage.models import AutoScrollRequest
|
||||
from onyx.server.manage.models import BulkInviteResponse
|
||||
from onyx.server.manage.models import ChatBackgroundRequest
|
||||
from onyx.server.manage.models import DefaultAppModeRequest
|
||||
from onyx.server.manage.models import EmailInviteStatus
|
||||
from onyx.server.manage.models import MemoryItem
|
||||
from onyx.server.manage.models import PersonalizationUpdateRequest
|
||||
from onyx.server.manage.models import TenantInfo
|
||||
@@ -371,7 +368,7 @@ def bulk_invite_users(
|
||||
emails: list[str] = Body(..., embed=True),
|
||||
current_user: User = Depends(current_admin_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> BulkInviteResponse:
|
||||
) -> int:
|
||||
"""emails are string validated. If any email fails validation, no emails are
|
||||
invited and an exception is raised."""
|
||||
tenant_id = get_current_tenant_id()
|
||||
@@ -430,41 +427,34 @@ def bulk_invite_users(
|
||||
number_of_invited_users = write_invited_users(all_emails)
|
||||
|
||||
# send out email invitations only to new users (not already invited or existing)
|
||||
if not ENABLE_EMAIL_INVITES:
|
||||
email_invite_status = EmailInviteStatus.DISABLED
|
||||
elif not EMAIL_CONFIGURED:
|
||||
email_invite_status = EmailInviteStatus.NOT_CONFIGURED
|
||||
else:
|
||||
if ENABLE_EMAIL_INVITES:
|
||||
try:
|
||||
for email in emails_needing_seats:
|
||||
send_user_email_invite(email, current_user, AUTH_TYPE)
|
||||
email_invite_status = EmailInviteStatus.SENT
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending email invite to invited users: {e}")
|
||||
email_invite_status = EmailInviteStatus.SEND_FAILED
|
||||
|
||||
if MULTI_TENANT and not DEV_MODE:
|
||||
# for billing purposes, write to the control plane about the number of new users
|
||||
try:
|
||||
logger.info("Registering tenant users")
|
||||
fetch_ee_implementation_or_noop(
|
||||
"onyx.server.tenants.billing", "register_tenant_users", None
|
||||
)(tenant_id, get_live_users_count(db_session))
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to register tenant users: {str(e)}")
|
||||
logger.info(
|
||||
"Reverting changes: removing users from tenant and resetting invited users"
|
||||
)
|
||||
write_invited_users(initial_invited_users) # Reset to original state
|
||||
fetch_ee_implementation_or_noop(
|
||||
"onyx.server.tenants.user_mapping", "remove_users_from_tenant", None
|
||||
)(new_invited_emails, tenant_id)
|
||||
raise e
|
||||
if not MULTI_TENANT or DEV_MODE:
|
||||
return number_of_invited_users
|
||||
|
||||
return BulkInviteResponse(
|
||||
invited_count=number_of_invited_users,
|
||||
email_invite_status=email_invite_status,
|
||||
)
|
||||
# for billing purposes, write to the control plane about the number of new users
|
||||
try:
|
||||
logger.info("Registering tenant users")
|
||||
fetch_ee_implementation_or_noop(
|
||||
"onyx.server.tenants.billing", "register_tenant_users", None
|
||||
)(tenant_id, get_live_users_count(db_session))
|
||||
|
||||
return number_of_invited_users
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to register tenant users: {str(e)}")
|
||||
logger.info(
|
||||
"Reverting changes: removing users from tenant and resetting invited users"
|
||||
)
|
||||
write_invited_users(initial_invited_users) # Reset to original state
|
||||
fetch_ee_implementation_or_noop(
|
||||
"onyx.server.tenants.user_mapping", "remove_users_from_tenant", None
|
||||
)(new_invited_emails, tenant_id)
|
||||
raise e
|
||||
|
||||
|
||||
@router.patch("/manage/admin/remove-invited-user", tags=PUBLIC_API_TAGS)
|
||||
|
||||
@@ -587,7 +587,6 @@ def handle_send_chat_message(
|
||||
request.headers
|
||||
),
|
||||
mcp_headers=chat_message_req.mcp_headers,
|
||||
additional_context=chat_message_req.additional_context,
|
||||
external_state_container=state_container,
|
||||
)
|
||||
result = gather_stream_full(packets, state_container)
|
||||
@@ -610,7 +609,6 @@ def handle_send_chat_message(
|
||||
request.headers
|
||||
),
|
||||
mcp_headers=chat_message_req.mcp_headers,
|
||||
additional_context=chat_message_req.additional_context,
|
||||
external_state_container=state_container,
|
||||
):
|
||||
yield get_json_line(obj.model_dump())
|
||||
|
||||
@@ -125,11 +125,6 @@ class SendMessageRequest(BaseModel):
|
||||
# - No CitationInfo packets are emitted during streaming
|
||||
include_citations: bool = True
|
||||
|
||||
# Additional context injected into the LLM call but NOT stored in the DB
|
||||
# (not shown in chat history). Used e.g. by the Chrome extension to pass
|
||||
# the current tab URL when "Read this tab" is enabled.
|
||||
additional_context: str | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def check_chat_session_id_or_info(self) -> "SendMessageRequest":
|
||||
# If neither is provided, default to creating a new chat session using the
|
||||
|
||||
@@ -269,9 +269,7 @@ def setup_postgres(db_session: Session) -> None:
|
||||
new_llm_provider = upsert_llm_provider(
|
||||
llm_provider_upsert_request=model_req, db_session=db_session
|
||||
)
|
||||
update_default_provider(
|
||||
provider_id=new_llm_provider.id, model_name=llm_model, db_session=db_session
|
||||
)
|
||||
update_default_provider(provider_id=new_llm_provider.id, db_session=db_session)
|
||||
|
||||
|
||||
def update_default_multipass_indexing(db_session: Session) -> None:
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import json
|
||||
from collections.abc import Generator
|
||||
from typing import Literal
|
||||
from typing import TypedDict
|
||||
from typing import Union
|
||||
|
||||
import requests
|
||||
from pydantic import BaseModel
|
||||
@@ -39,39 +36,6 @@ class ExecuteResponse(BaseModel):
|
||||
files: list[WorkspaceFile]
|
||||
|
||||
|
||||
class StreamOutputEvent(BaseModel):
|
||||
"""SSE 'output' event: a chunk of stdout or stderr"""
|
||||
|
||||
stream: Literal["stdout", "stderr"]
|
||||
data: str
|
||||
|
||||
|
||||
class StreamResultEvent(BaseModel):
|
||||
"""SSE 'result' event: final execution result"""
|
||||
|
||||
exit_code: int | None
|
||||
timed_out: bool
|
||||
duration_ms: int
|
||||
files: list[WorkspaceFile]
|
||||
|
||||
|
||||
class StreamErrorEvent(BaseModel):
|
||||
"""SSE 'error' event: execution-level error"""
|
||||
|
||||
message: str
|
||||
|
||||
|
||||
StreamEvent = Union[StreamOutputEvent, StreamResultEvent, StreamErrorEvent]
|
||||
|
||||
_SSE_EVENT_MAP: dict[
|
||||
str, type[StreamOutputEvent | StreamResultEvent | StreamErrorEvent]
|
||||
] = {
|
||||
"output": StreamOutputEvent,
|
||||
"result": StreamResultEvent,
|
||||
"error": StreamErrorEvent,
|
||||
}
|
||||
|
||||
|
||||
class CodeInterpreterClient:
|
||||
"""Client for Code Interpreter service"""
|
||||
|
||||
@@ -81,34 +45,6 @@ class CodeInterpreterClient:
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.session = requests.Session()
|
||||
|
||||
def _build_payload(
|
||||
self,
|
||||
code: str,
|
||||
stdin: str | None,
|
||||
timeout_ms: int,
|
||||
files: list[FileInput] | None,
|
||||
) -> dict:
|
||||
payload: dict = {
|
||||
"code": code,
|
||||
"timeout_ms": timeout_ms,
|
||||
}
|
||||
if stdin is not None:
|
||||
payload["stdin"] = stdin
|
||||
if files:
|
||||
payload["files"] = files
|
||||
return payload
|
||||
|
||||
def health(self) -> bool:
|
||||
"""Check if the Code Interpreter service is healthy"""
|
||||
url = f"{self.base_url}/health"
|
||||
try:
|
||||
response = self.session.get(url, timeout=5)
|
||||
response.raise_for_status()
|
||||
return response.json().get("status") == "ok"
|
||||
except Exception as e:
|
||||
logger.warning(f"Exception caught when checking health, e={e}")
|
||||
return False
|
||||
|
||||
def execute(
|
||||
self,
|
||||
code: str,
|
||||
@@ -116,110 +52,25 @@ class CodeInterpreterClient:
|
||||
timeout_ms: int = 30000,
|
||||
files: list[FileInput] | None = None,
|
||||
) -> ExecuteResponse:
|
||||
"""Execute Python code (batch)"""
|
||||
"""Execute Python code"""
|
||||
url = f"{self.base_url}/v1/execute"
|
||||
payload = self._build_payload(code, stdin, timeout_ms, files)
|
||||
|
||||
payload = {
|
||||
"code": code,
|
||||
"timeout_ms": timeout_ms,
|
||||
}
|
||||
|
||||
if stdin is not None:
|
||||
payload["stdin"] = stdin
|
||||
|
||||
if files:
|
||||
payload["files"] = files
|
||||
|
||||
response = self.session.post(url, json=payload, timeout=timeout_ms / 1000 + 10)
|
||||
response.raise_for_status()
|
||||
|
||||
return ExecuteResponse(**response.json())
|
||||
|
||||
def execute_streaming(
|
||||
self,
|
||||
code: str,
|
||||
stdin: str | None = None,
|
||||
timeout_ms: int = 30000,
|
||||
files: list[FileInput] | None = None,
|
||||
) -> Generator[StreamEvent, None, None]:
|
||||
"""Execute Python code with streaming SSE output.
|
||||
|
||||
Yields StreamEvent objects (StreamOutputEvent, StreamResultEvent,
|
||||
StreamErrorEvent) as execution progresses. Falls back to batch
|
||||
execution if the streaming endpoint is not available (older
|
||||
code-interpreter versions).
|
||||
"""
|
||||
url = f"{self.base_url}/v1/execute/stream"
|
||||
payload = self._build_payload(code, stdin, timeout_ms, files)
|
||||
|
||||
response = self.session.post(
|
||||
url,
|
||||
json=payload,
|
||||
stream=True,
|
||||
timeout=timeout_ms / 1000 + 10,
|
||||
)
|
||||
|
||||
if response.status_code == 404:
|
||||
logger.info(
|
||||
"Streaming endpoint not available, " "falling back to batch execution"
|
||||
)
|
||||
response.close()
|
||||
yield from self._batch_as_stream(code, stdin, timeout_ms, files)
|
||||
return
|
||||
|
||||
response.raise_for_status()
|
||||
yield from self._parse_sse(response)
|
||||
|
||||
def _parse_sse(
|
||||
self, response: requests.Response
|
||||
) -> Generator[StreamEvent, None, None]:
|
||||
"""Parse SSE streaming response into StreamEvent objects.
|
||||
|
||||
Expected format per event:
|
||||
event: <type>
|
||||
data: <json>
|
||||
<blank line>
|
||||
"""
|
||||
event_type: str | None = None
|
||||
data_lines: list[str] = []
|
||||
|
||||
for line in response.iter_lines(decode_unicode=True):
|
||||
if line is None:
|
||||
continue
|
||||
|
||||
if line == "":
|
||||
# Blank line marks end of an SSE event
|
||||
if event_type is not None and data_lines:
|
||||
data = "\n".join(data_lines)
|
||||
model_cls = _SSE_EVENT_MAP.get(event_type)
|
||||
if model_cls is not None:
|
||||
yield model_cls(**json.loads(data))
|
||||
else:
|
||||
logger.warning(f"Unknown SSE event type: {event_type}")
|
||||
event_type = None
|
||||
data_lines = []
|
||||
elif line.startswith("event:"):
|
||||
event_type = line[len("event:") :].strip()
|
||||
elif line.startswith("data:"):
|
||||
data_lines.append(line[len("data:") :].strip())
|
||||
|
||||
if event_type is not None or data_lines:
|
||||
logger.warning(
|
||||
f"SSE stream ended with incomplete event: "
|
||||
f"event_type={event_type}, data_lines={data_lines}"
|
||||
)
|
||||
|
||||
def _batch_as_stream(
|
||||
self,
|
||||
code: str,
|
||||
stdin: str | None,
|
||||
timeout_ms: int,
|
||||
files: list[FileInput] | None,
|
||||
) -> Generator[StreamEvent, None, None]:
|
||||
"""Execute via batch endpoint and yield results as stream events."""
|
||||
result = self.execute(code, stdin, timeout_ms, files)
|
||||
|
||||
if result.stdout:
|
||||
yield StreamOutputEvent(stream="stdout", data=result.stdout)
|
||||
if result.stderr:
|
||||
yield StreamOutputEvent(stream="stderr", data=result.stderr)
|
||||
yield StreamResultEvent(
|
||||
exit_code=result.exit_code,
|
||||
timed_out=result.timed_out,
|
||||
duration_ms=result.duration_ms,
|
||||
files=result.files,
|
||||
)
|
||||
|
||||
def upload_file(self, file_content: bytes, filename: str) -> str:
|
||||
"""Upload file to Code Interpreter and return file_id"""
|
||||
url = f"{self.base_url}/v1/files"
|
||||
|
||||
@@ -28,15 +28,6 @@ from onyx.tools.tool_implementations.python.code_interpreter_client import (
|
||||
CodeInterpreterClient,
|
||||
)
|
||||
from onyx.tools.tool_implementations.python.code_interpreter_client import FileInput
|
||||
from onyx.tools.tool_implementations.python.code_interpreter_client import (
|
||||
StreamErrorEvent,
|
||||
)
|
||||
from onyx.tools.tool_implementations.python.code_interpreter_client import (
|
||||
StreamOutputEvent,
|
||||
)
|
||||
from onyx.tools.tool_implementations.python.code_interpreter_client import (
|
||||
StreamResultEvent,
|
||||
)
|
||||
from onyx.utils.logger import setup_logger
|
||||
|
||||
|
||||
@@ -190,50 +181,19 @@ class PythonTool(Tool[PythonToolOverrideKwargs]):
|
||||
try:
|
||||
logger.debug(f"Executing code: {code}")
|
||||
|
||||
# Execute code with streaming (falls back to batch if unavailable)
|
||||
stdout_parts: list[str] = []
|
||||
stderr_parts: list[str] = []
|
||||
result_event: StreamResultEvent | None = None
|
||||
|
||||
for event in client.execute_streaming(
|
||||
# Execute code with timeout
|
||||
response = client.execute(
|
||||
code=code,
|
||||
timeout_ms=CODE_INTERPRETER_DEFAULT_TIMEOUT_MS,
|
||||
files=files_to_stage or None,
|
||||
):
|
||||
if isinstance(event, StreamOutputEvent):
|
||||
if event.stream == "stdout":
|
||||
stdout_parts.append(event.data)
|
||||
else:
|
||||
stderr_parts.append(event.data)
|
||||
# Emit incremental delta to frontend
|
||||
self.emitter.emit(
|
||||
Packet(
|
||||
placement=placement,
|
||||
obj=PythonToolDelta(
|
||||
stdout=event.data if event.stream == "stdout" else "",
|
||||
stderr=event.data if event.stream == "stderr" else "",
|
||||
),
|
||||
)
|
||||
)
|
||||
elif isinstance(event, StreamResultEvent):
|
||||
result_event = event
|
||||
elif isinstance(event, StreamErrorEvent):
|
||||
raise RuntimeError(f"Code interpreter error: {event.message}")
|
||||
|
||||
if result_event is None:
|
||||
raise RuntimeError(
|
||||
"Code interpreter stream ended without a result event"
|
||||
)
|
||||
|
||||
full_stdout = "".join(stdout_parts)
|
||||
full_stderr = "".join(stderr_parts)
|
||||
)
|
||||
|
||||
# Truncate output for LLM consumption
|
||||
truncated_stdout = _truncate_output(
|
||||
full_stdout, CODE_INTERPRETER_MAX_OUTPUT_LENGTH, "stdout"
|
||||
response.stdout, CODE_INTERPRETER_MAX_OUTPUT_LENGTH, "stdout"
|
||||
)
|
||||
truncated_stderr = _truncate_output(
|
||||
full_stderr, CODE_INTERPRETER_MAX_OUTPUT_LENGTH, "stderr"
|
||||
response.stderr, CODE_INTERPRETER_MAX_OUTPUT_LENGTH, "stderr"
|
||||
)
|
||||
|
||||
# Handle generated files
|
||||
@@ -242,7 +202,7 @@ class PythonTool(Tool[PythonToolOverrideKwargs]):
|
||||
file_ids_to_cleanup: list[str] = []
|
||||
file_store = get_default_file_store()
|
||||
|
||||
for workspace_file in result_event.files:
|
||||
for workspace_file in response.files:
|
||||
if workspace_file.kind != "file" or not workspace_file.file_id:
|
||||
continue
|
||||
|
||||
@@ -298,23 +258,26 @@ class PythonTool(Tool[PythonToolOverrideKwargs]):
|
||||
f"Failed to delete Code Interpreter staged file {file_mapping['file_id']}: {e}"
|
||||
)
|
||||
|
||||
# Emit file_ids once files are processed
|
||||
if generated_file_ids:
|
||||
self.emitter.emit(
|
||||
Packet(
|
||||
placement=placement,
|
||||
obj=PythonToolDelta(file_ids=generated_file_ids),
|
||||
)
|
||||
# Emit delta with stdout/stderr and generated files
|
||||
self.emitter.emit(
|
||||
Packet(
|
||||
placement=placement,
|
||||
obj=PythonToolDelta(
|
||||
stdout=truncated_stdout,
|
||||
stderr=truncated_stderr,
|
||||
file_ids=generated_file_ids,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
# Build result
|
||||
result = LlmPythonExecutionResult(
|
||||
stdout=truncated_stdout,
|
||||
stderr=truncated_stderr,
|
||||
exit_code=result_event.exit_code,
|
||||
timed_out=result_event.timed_out,
|
||||
exit_code=response.exit_code,
|
||||
timed_out=response.timed_out,
|
||||
generated_files=generated_files,
|
||||
error=None if result_event.exit_code == 0 else truncated_stderr,
|
||||
error=None if response.exit_code == 0 else truncated_stderr,
|
||||
)
|
||||
|
||||
# Serialize result for LLM
|
||||
|
||||
@@ -6,8 +6,6 @@ aioboto3==15.1.0
|
||||
# via onyx
|
||||
aiobotocore==2.24.0
|
||||
# via aioboto3
|
||||
aiofile==3.9.0
|
||||
# via py-key-value-aio
|
||||
aiofiles==25.1.0
|
||||
# via
|
||||
# aioboto3
|
||||
@@ -42,10 +40,8 @@ anyio==4.11.0
|
||||
# httpx
|
||||
# mcp
|
||||
# openai
|
||||
# py-key-value-aio
|
||||
# sse-starlette
|
||||
# starlette
|
||||
# watchfiles
|
||||
argon2-cffi==23.1.0
|
||||
# via pwdlib
|
||||
argon2-cffi-bindings==25.1.0
|
||||
@@ -78,7 +74,9 @@ backports-tarfile==1.2.0 ; python_full_version < '3.12'
|
||||
bcrypt==4.3.0
|
||||
# via pwdlib
|
||||
beartype==0.22.6
|
||||
# via py-key-value-aio
|
||||
# via
|
||||
# py-key-value-aio
|
||||
# py-key-value-shared
|
||||
beautifulsoup4==4.12.3
|
||||
# via
|
||||
# atlassian-python-api
|
||||
@@ -112,8 +110,6 @@ cachetools==6.2.2
|
||||
# via
|
||||
# google-auth
|
||||
# py-key-value-aio
|
||||
caio==0.9.25
|
||||
# via aiofile
|
||||
celery==5.5.1
|
||||
# via onyx
|
||||
certifi==2025.11.12
|
||||
@@ -174,6 +170,7 @@ cloudpickle==3.1.2
|
||||
# via
|
||||
# dask
|
||||
# distributed
|
||||
# pydocket
|
||||
cobble==0.1.4
|
||||
# via mammoth
|
||||
cohere==5.6.1
|
||||
@@ -221,6 +218,8 @@ deprecated==1.3.1
|
||||
# pygithub
|
||||
discord-py==2.4.0
|
||||
# via onyx
|
||||
diskcache==5.6.3
|
||||
# via py-key-value-aio
|
||||
distributed==2026.1.1
|
||||
# via onyx
|
||||
distro==1.9.0
|
||||
@@ -257,6 +256,8 @@ exceptiongroup==1.3.0
|
||||
# via
|
||||
# braintrust
|
||||
# fastmcp
|
||||
fakeredis==2.33.0
|
||||
# via pydocket
|
||||
fastapi==0.128.0
|
||||
# via
|
||||
# fastapi-limiter
|
||||
@@ -272,7 +273,7 @@ fastapi-users-db-sqlalchemy==7.0.0
|
||||
# via onyx
|
||||
fastavro==1.12.1
|
||||
# via cohere
|
||||
fastmcp==3.0.2
|
||||
fastmcp==2.14.2
|
||||
# via onyx
|
||||
fastuuid==0.14.0
|
||||
# via litellm
|
||||
@@ -477,9 +478,7 @@ jsonpatch==1.33
|
||||
jsonpointer==3.0.0
|
||||
# via jsonpatch
|
||||
jsonref==1.1.0
|
||||
# via
|
||||
# fastmcp
|
||||
# onyx
|
||||
# via onyx
|
||||
jsonschema==4.25.1
|
||||
# via
|
||||
# litellm
|
||||
@@ -514,6 +513,8 @@ locket==1.0.0
|
||||
# via
|
||||
# distributed
|
||||
# partd
|
||||
lupa==2.6
|
||||
# via fakeredis
|
||||
lxml==5.3.0
|
||||
# via
|
||||
# htmldate
|
||||
@@ -555,7 +556,7 @@ marshmallow==3.26.2
|
||||
# via dataclasses-json
|
||||
matrix-client==0.3.2
|
||||
# via zulip
|
||||
mcp==1.26.0
|
||||
mcp==1.25.0
|
||||
# via
|
||||
# claude-agent-sdk
|
||||
# fastmcp
|
||||
@@ -612,7 +613,7 @@ oauthlib==3.2.2
|
||||
# kubernetes
|
||||
# onyx
|
||||
# requests-oauthlib
|
||||
office365-rest-python-client==2.6.2
|
||||
office365-rest-python-client==2.5.9
|
||||
# via onyx
|
||||
olefile==0.47
|
||||
# via
|
||||
@@ -641,16 +642,22 @@ opensearch-py==3.0.0
|
||||
opentelemetry-api==1.39.1
|
||||
# via
|
||||
# ddtrace
|
||||
# fastmcp
|
||||
# langfuse
|
||||
# openinference-instrumentation
|
||||
# opentelemetry-exporter-otlp-proto-http
|
||||
# opentelemetry-exporter-prometheus
|
||||
# opentelemetry-instrumentation
|
||||
# opentelemetry-sdk
|
||||
# opentelemetry-semantic-conventions
|
||||
# pydocket
|
||||
opentelemetry-exporter-otlp-proto-common==1.39.1
|
||||
# via opentelemetry-exporter-otlp-proto-http
|
||||
opentelemetry-exporter-otlp-proto-http==1.39.1
|
||||
# via langfuse
|
||||
opentelemetry-exporter-prometheus==0.60b1
|
||||
# via pydocket
|
||||
opentelemetry-instrumentation==0.60b1
|
||||
# via pydocket
|
||||
opentelemetry-proto==1.39.1
|
||||
# via
|
||||
# onyx
|
||||
@@ -661,15 +668,17 @@ opentelemetry-sdk==1.39.1
|
||||
# langfuse
|
||||
# openinference-instrumentation
|
||||
# opentelemetry-exporter-otlp-proto-http
|
||||
# opentelemetry-exporter-prometheus
|
||||
opentelemetry-semantic-conventions==0.60b1
|
||||
# via opentelemetry-sdk
|
||||
# via
|
||||
# opentelemetry-instrumentation
|
||||
# opentelemetry-sdk
|
||||
orjson==3.11.4 ; platform_python_implementation != 'PyPy'
|
||||
# via langsmith
|
||||
packaging==24.2
|
||||
# via
|
||||
# dask
|
||||
# distributed
|
||||
# fastmcp
|
||||
# google-cloud-aiplatform
|
||||
# google-cloud-bigquery
|
||||
# huggingface-hub
|
||||
@@ -680,6 +689,7 @@ packaging==24.2
|
||||
# langsmith
|
||||
# marshmallow
|
||||
# onnxruntime
|
||||
# opentelemetry-instrumentation
|
||||
# pytest
|
||||
# pywikibot
|
||||
pandas==2.3.3
|
||||
@@ -692,6 +702,8 @@ passlib==1.7.4
|
||||
# via onyx
|
||||
pathable==0.4.4
|
||||
# via jsonschema-path
|
||||
pathvalidate==3.3.1
|
||||
# via py-key-value-aio
|
||||
pdfminer-six==20251107
|
||||
# via markitdown
|
||||
pillow==12.1.1
|
||||
@@ -711,7 +723,9 @@ ply==3.11
|
||||
prometheus-client==0.23.1
|
||||
# via
|
||||
# onyx
|
||||
# opentelemetry-exporter-prometheus
|
||||
# prometheus-fastapi-instrumentator
|
||||
# pydocket
|
||||
prometheus-fastapi-instrumentator==7.1.0
|
||||
# via onyx
|
||||
prompt-toolkit==3.0.52
|
||||
@@ -750,8 +764,12 @@ pwdlib==0.3.0
|
||||
# via fastapi-users
|
||||
py==1.11.0
|
||||
# via retry
|
||||
py-key-value-aio==0.4.4
|
||||
# via fastmcp
|
||||
py-key-value-aio==0.3.0
|
||||
# via
|
||||
# fastmcp
|
||||
# pydocket
|
||||
py-key-value-shared==0.3.0
|
||||
# via py-key-value-aio
|
||||
pyairtable==3.0.1
|
||||
# via onyx
|
||||
pyasn1==0.6.2
|
||||
@@ -788,6 +806,8 @@ pydantic-core==2.33.2
|
||||
# via pydantic
|
||||
pydantic-settings==2.12.0
|
||||
# via mcp
|
||||
pydocket==0.16.3
|
||||
# via fastmcp
|
||||
pyee==13.0.0
|
||||
# via playwright
|
||||
pygithub==2.5.0
|
||||
@@ -859,6 +879,8 @@ python-http-client==3.3.7
|
||||
# via sendgrid
|
||||
python-iso639==2025.11.16
|
||||
# via unstructured
|
||||
python-json-logger==4.0.0
|
||||
# via pydocket
|
||||
python-magic==0.4.27
|
||||
# via unstructured
|
||||
python-multipart==0.0.22
|
||||
@@ -896,7 +918,6 @@ pyyaml==6.0.3
|
||||
# via
|
||||
# dask
|
||||
# distributed
|
||||
# fastmcp
|
||||
# huggingface-hub
|
||||
# jsonschema-path
|
||||
# kubernetes
|
||||
@@ -907,8 +928,11 @@ rapidfuzz==3.13.0
|
||||
# unstructured
|
||||
redis==5.0.8
|
||||
# via
|
||||
# fakeredis
|
||||
# fastapi-limiter
|
||||
# onyx
|
||||
# py-key-value-aio
|
||||
# pydocket
|
||||
referencing==0.36.2
|
||||
# via
|
||||
# jsonschema
|
||||
@@ -983,6 +1007,7 @@ rich==14.2.0
|
||||
# via
|
||||
# cyclopts
|
||||
# fastmcp
|
||||
# pydocket
|
||||
# rich-rst
|
||||
# typer
|
||||
rich-rst==1.3.2
|
||||
@@ -1031,7 +1056,9 @@ sniffio==1.3.1
|
||||
# anyio
|
||||
# openai
|
||||
sortedcontainers==2.4.0
|
||||
# via distributed
|
||||
# via
|
||||
# distributed
|
||||
# fakeredis
|
||||
soupsieve==2.8
|
||||
# via beautifulsoup4
|
||||
sqlalchemy==2.0.15
|
||||
@@ -1097,7 +1124,9 @@ tqdm==4.67.1
|
||||
trafilatura==1.12.2
|
||||
# via onyx
|
||||
typer==0.20.0
|
||||
# via mcp
|
||||
# via
|
||||
# mcp
|
||||
# pydocket
|
||||
types-awscrt==0.28.4
|
||||
# via botocore-stubs
|
||||
types-openpyxl==3.0.4.7
|
||||
@@ -1133,10 +1162,11 @@ typing-extensions==4.15.0
|
||||
# opentelemetry-exporter-otlp-proto-http
|
||||
# opentelemetry-sdk
|
||||
# opentelemetry-semantic-conventions
|
||||
# py-key-value-aio
|
||||
# py-key-value-shared
|
||||
# pyairtable
|
||||
# pydantic
|
||||
# pydantic-core
|
||||
# pydocket
|
||||
# pyee
|
||||
# pygithub
|
||||
# python-docx
|
||||
@@ -1204,8 +1234,6 @@ vine==5.1.0
|
||||
# kombu
|
||||
voyageai==0.2.3
|
||||
# via onyx
|
||||
watchfiles==1.1.1
|
||||
# via fastmcp
|
||||
wcwidth==0.2.14
|
||||
# via prompt-toolkit
|
||||
webencodings==0.5.1
|
||||
@@ -1226,6 +1254,7 @@ wrapt==1.17.3
|
||||
# deprecated
|
||||
# langfuse
|
||||
# openinference-instrumentation
|
||||
# opentelemetry-instrumentation
|
||||
# unstructured
|
||||
xlrd==2.0.2
|
||||
# via markitdown
|
||||
|
||||
@@ -288,7 +288,7 @@ matplotlib-inline==0.2.1
|
||||
# via
|
||||
# ipykernel
|
||||
# ipython
|
||||
mcp==1.26.0
|
||||
mcp==1.25.0
|
||||
# via claude-agent-sdk
|
||||
multidict==6.7.0
|
||||
# via
|
||||
@@ -317,7 +317,7 @@ oauthlib==3.2.2
|
||||
# via
|
||||
# kubernetes
|
||||
# requests-oauthlib
|
||||
onyx-devtools==0.6.1
|
||||
onyx-devtools==0.6.0
|
||||
# via onyx
|
||||
openai==2.14.0
|
||||
# via
|
||||
|
||||
@@ -211,7 +211,7 @@ litellm==1.81.6
|
||||
# via onyx
|
||||
markupsafe==3.0.3
|
||||
# via jinja2
|
||||
mcp==1.26.0
|
||||
mcp==1.25.0
|
||||
# via claude-agent-sdk
|
||||
monotonic==1.6
|
||||
# via posthog
|
||||
|
||||
@@ -246,7 +246,7 @@ litellm==1.81.6
|
||||
# via onyx
|
||||
markupsafe==3.0.3
|
||||
# via jinja2
|
||||
mcp==1.26.0
|
||||
mcp==1.25.0
|
||||
# via claude-agent-sdk
|
||||
mpmath==1.3.0
|
||||
# via sympy
|
||||
|
||||
@@ -3,8 +3,8 @@ set -e
|
||||
|
||||
cleanup() {
|
||||
echo "Error occurred. Cleaning up..."
|
||||
docker stop onyx_postgres onyx_vespa onyx_redis onyx_minio onyx_code_interpreter 2>/dev/null || true
|
||||
docker rm onyx_postgres onyx_vespa onyx_redis onyx_minio onyx_code_interpreter 2>/dev/null || true
|
||||
docker stop onyx_postgres onyx_vespa onyx_redis onyx_minio 2>/dev/null || true
|
||||
docker rm onyx_postgres onyx_vespa onyx_redis onyx_minio 2>/dev/null || true
|
||||
}
|
||||
|
||||
# Trap errors and output a message, then cleanup
|
||||
@@ -20,8 +20,8 @@ MINIO_VOLUME=${4:-""} # Default is empty if not provided
|
||||
|
||||
# Stop and remove the existing containers
|
||||
echo "Stopping and removing existing containers..."
|
||||
docker stop onyx_postgres onyx_vespa onyx_redis onyx_minio onyx_code_interpreter 2>/dev/null || true
|
||||
docker rm onyx_postgres onyx_vespa onyx_redis onyx_minio onyx_code_interpreter 2>/dev/null || true
|
||||
docker stop onyx_postgres onyx_vespa onyx_redis onyx_minio 2>/dev/null || true
|
||||
docker rm onyx_postgres onyx_vespa onyx_redis onyx_minio 2>/dev/null || true
|
||||
|
||||
# Start the PostgreSQL container with optional volume
|
||||
echo "Starting PostgreSQL container..."
|
||||
@@ -55,10 +55,6 @@ else
|
||||
docker run --detach --name onyx_minio --publish 9004:9000 --publish 9005:9001 -e MINIO_ROOT_USER=minioadmin -e MINIO_ROOT_PASSWORD=minioadmin minio/minio server /data --console-address ":9001"
|
||||
fi
|
||||
|
||||
# Start the Code Interpreter container
|
||||
echo "Starting Code Interpreter container..."
|
||||
docker run --detach --name onyx_code_interpreter --publish 8000:8000 --user root -v /var/run/docker.sock:/var/run/docker.sock onyxdotapp/code-interpreter:latest bash ./entrypoint.sh code-interpreter-api
|
||||
|
||||
# Ensure alembic runs in the correct directory (backend/)
|
||||
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )"
|
||||
PARENT_DIR="$(dirname "$SCRIPT_DIR")"
|
||||
|
||||
@@ -9,7 +9,6 @@ from collections.abc import AsyncGenerator
|
||||
from collections.abc import Generator
|
||||
from contextlib import asynccontextmanager
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from dotenv import load_dotenv
|
||||
@@ -47,15 +46,11 @@ def mock_current_admin_user() -> MagicMock:
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def client() -> Generator[TestClient, None, None]:
|
||||
# Initialize TestClient with the FastAPI app using a no-op test lifespan.
|
||||
# Patch out prometheus metrics setup to avoid "Duplicated timeseries in
|
||||
# CollectorRegistry" errors when multiple tests each create a new app
|
||||
# (prometheus registers metrics globally and rejects duplicate names).
|
||||
# Initialize TestClient with the FastAPI app using a no-op test lifespan
|
||||
get_app = fetch_versioned_implementation(
|
||||
module="onyx.main", attribute="get_application"
|
||||
)
|
||||
with patch("onyx.main.setup_prometheus_metrics"):
|
||||
app: FastAPI = get_app(lifespan_override=test_lifespan)
|
||||
app: FastAPI = get_app(lifespan_override=test_lifespan)
|
||||
|
||||
# Override the database session dependency with a mock
|
||||
# (these tests don't actually need DB access)
|
||||
|
||||
@@ -17,7 +17,7 @@ def test_bedrock_llm_configuration(client: TestClient) -> None:
|
||||
# Prepare the test request payload
|
||||
test_request: dict[str, Any] = {
|
||||
"provider": LlmProviderNames.BEDROCK,
|
||||
"model": _DEFAULT_BEDROCK_MODEL,
|
||||
"default_model_name": _DEFAULT_BEDROCK_MODEL,
|
||||
"api_key": None,
|
||||
"api_base": None,
|
||||
"api_version": None,
|
||||
@@ -44,7 +44,7 @@ def test_bedrock_llm_configuration_invalid_key(client: TestClient) -> None:
|
||||
# Prepare the test request payload with invalid credentials
|
||||
test_request: dict[str, Any] = {
|
||||
"provider": LlmProviderNames.BEDROCK,
|
||||
"model": _DEFAULT_BEDROCK_MODEL,
|
||||
"default_model_name": _DEFAULT_BEDROCK_MODEL,
|
||||
"api_key": None,
|
||||
"api_base": None,
|
||||
"api_version": None,
|
||||
|
||||
@@ -41,7 +41,7 @@ def ensure_default_llm_provider(db_session: Session) -> None:
|
||||
llm_provider_upsert_request=llm_provider_request,
|
||||
db_session=db_session,
|
||||
)
|
||||
update_default_provider(provider.id, "gpt-4o-mini", db_session)
|
||||
update_default_provider(provider.id, db_session)
|
||||
except Exception as exc: # pragma: no cover - only hits on duplicate setup issues
|
||||
# Rollback to clear the pending transaction state
|
||||
db_session.rollback()
|
||||
|
||||
@@ -59,7 +59,7 @@ def test_answer_with_only_anthropic_provider(
|
||||
)
|
||||
|
||||
try:
|
||||
update_default_provider(anthropic_provider.id, anthropic_model, db_session)
|
||||
update_default_provider(anthropic_provider.id, db_session)
|
||||
|
||||
test_user = create_test_user(db_session, email_prefix="anthropic_only")
|
||||
chat_session = create_chat_session(
|
||||
|
||||
@@ -29,7 +29,6 @@ from onyx.server.manage.llm.api import (
|
||||
test_llm_configuration as run_test_llm_configuration,
|
||||
)
|
||||
from onyx.server.manage.llm.models import LLMProviderUpsertRequest
|
||||
from onyx.server.manage.llm.models import LLMProviderView
|
||||
from onyx.server.manage.llm.models import ModelConfigurationUpsertRequest
|
||||
from onyx.server.manage.llm.models import TestLLMRequest as LLMTestRequest
|
||||
|
||||
@@ -45,9 +44,9 @@ def _create_test_provider(
|
||||
db_session: Session,
|
||||
name: str,
|
||||
api_key: str = "sk-test-key-00000000000000000000000000000000000",
|
||||
) -> LLMProviderView:
|
||||
) -> None:
|
||||
"""Helper to create a test LLM provider in the database."""
|
||||
return upsert_llm_provider(
|
||||
upsert_llm_provider(
|
||||
LLMProviderUpsertRequest(
|
||||
name=name,
|
||||
provider=LlmProviderNames.OPENAI,
|
||||
@@ -103,11 +102,17 @@ class TestLLMConfigurationEndpoint:
|
||||
# This should complete without exception
|
||||
run_test_llm_configuration(
|
||||
test_llm_request=LLMTestRequest(
|
||||
name=None, # New provider (not in DB)
|
||||
provider=LlmProviderNames.OPENAI,
|
||||
api_key="sk-new-test-key-0000000000000000000000000000",
|
||||
api_key_changed=True,
|
||||
custom_config_changed=False,
|
||||
model="gpt-4o-mini",
|
||||
default_model_name="gpt-4o-mini",
|
||||
model_configurations=[
|
||||
ModelConfigurationUpsertRequest(
|
||||
name="gpt-4o-mini", is_visible=True
|
||||
)
|
||||
],
|
||||
),
|
||||
_=_create_mock_admin(),
|
||||
db_session=db_session,
|
||||
@@ -147,11 +152,17 @@ class TestLLMConfigurationEndpoint:
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
run_test_llm_configuration(
|
||||
test_llm_request=LLMTestRequest(
|
||||
name=None,
|
||||
provider=LlmProviderNames.OPENAI,
|
||||
api_key="sk-invalid-key-00000000000000000000000000",
|
||||
api_key_changed=True,
|
||||
custom_config_changed=False,
|
||||
model="gpt-4o-mini",
|
||||
default_model_name="gpt-4o-mini",
|
||||
model_configurations=[
|
||||
ModelConfigurationUpsertRequest(
|
||||
name="gpt-4o-mini", is_visible=True
|
||||
)
|
||||
],
|
||||
),
|
||||
_=_create_mock_admin(),
|
||||
db_session=db_session,
|
||||
@@ -183,9 +194,7 @@ class TestLLMConfigurationEndpoint:
|
||||
|
||||
try:
|
||||
# First, create the provider in the database
|
||||
provider = _create_test_provider(
|
||||
db_session, provider_name, api_key=original_api_key
|
||||
)
|
||||
_create_test_provider(db_session, provider_name, api_key=original_api_key)
|
||||
|
||||
with patch(
|
||||
"onyx.server.manage.llm.api.test_llm", side_effect=mock_test_llm_capture
|
||||
@@ -193,12 +202,17 @@ class TestLLMConfigurationEndpoint:
|
||||
# Test with api_key_changed=False - should use stored key
|
||||
run_test_llm_configuration(
|
||||
test_llm_request=LLMTestRequest(
|
||||
id=provider.id,
|
||||
name=provider_name, # Existing provider
|
||||
provider=LlmProviderNames.OPENAI,
|
||||
api_key=None, # Not providing a new key
|
||||
api_key_changed=False, # Using existing key
|
||||
custom_config_changed=False,
|
||||
model="gpt-4o-mini",
|
||||
default_model_name="gpt-4o-mini",
|
||||
model_configurations=[
|
||||
ModelConfigurationUpsertRequest(
|
||||
name="gpt-4o-mini", is_visible=True
|
||||
)
|
||||
],
|
||||
),
|
||||
_=_create_mock_admin(),
|
||||
db_session=db_session,
|
||||
@@ -232,9 +246,7 @@ class TestLLMConfigurationEndpoint:
|
||||
|
||||
try:
|
||||
# First, create the provider in the database
|
||||
provider = _create_test_provider(
|
||||
db_session, provider_name, api_key=original_api_key
|
||||
)
|
||||
_create_test_provider(db_session, provider_name, api_key=original_api_key)
|
||||
|
||||
with patch(
|
||||
"onyx.server.manage.llm.api.test_llm", side_effect=mock_test_llm_capture
|
||||
@@ -242,12 +254,17 @@ class TestLLMConfigurationEndpoint:
|
||||
# Test with api_key_changed=True - should use new key
|
||||
run_test_llm_configuration(
|
||||
test_llm_request=LLMTestRequest(
|
||||
id=provider.id,
|
||||
name=provider_name, # Existing provider
|
||||
provider=LlmProviderNames.OPENAI,
|
||||
api_key=new_api_key, # Providing a new key
|
||||
api_key_changed=True, # Key is being changed
|
||||
custom_config_changed=False,
|
||||
model="gpt-4o-mini",
|
||||
default_model_name="gpt-4o-mini",
|
||||
model_configurations=[
|
||||
ModelConfigurationUpsertRequest(
|
||||
name="gpt-4o-mini", is_visible=True
|
||||
)
|
||||
],
|
||||
),
|
||||
_=_create_mock_admin(),
|
||||
db_session=db_session,
|
||||
@@ -280,7 +297,7 @@ class TestLLMConfigurationEndpoint:
|
||||
|
||||
try:
|
||||
# First, create the provider in the database with custom_config
|
||||
provider = upsert_llm_provider(
|
||||
upsert_llm_provider(
|
||||
LLMProviderUpsertRequest(
|
||||
name=provider_name,
|
||||
provider=LlmProviderNames.OPENAI,
|
||||
@@ -304,13 +321,18 @@ class TestLLMConfigurationEndpoint:
|
||||
# Test with custom_config_changed=False - should use stored config
|
||||
run_test_llm_configuration(
|
||||
test_llm_request=LLMTestRequest(
|
||||
id=provider.id,
|
||||
name=provider_name,
|
||||
provider=LlmProviderNames.OPENAI,
|
||||
api_key=None,
|
||||
api_key_changed=False,
|
||||
custom_config=None, # Not providing new config
|
||||
custom_config_changed=False, # Using existing config
|
||||
model="gpt-4o-mini",
|
||||
default_model_name="gpt-4o-mini",
|
||||
model_configurations=[
|
||||
ModelConfigurationUpsertRequest(
|
||||
name="gpt-4o-mini", is_visible=True
|
||||
)
|
||||
],
|
||||
),
|
||||
_=_create_mock_admin(),
|
||||
db_session=db_session,
|
||||
@@ -346,11 +368,17 @@ class TestLLMConfigurationEndpoint:
|
||||
for model_name in test_models:
|
||||
run_test_llm_configuration(
|
||||
test_llm_request=LLMTestRequest(
|
||||
name=None,
|
||||
provider=LlmProviderNames.OPENAI,
|
||||
api_key="sk-test-key-00000000000000000000000000000000000",
|
||||
api_key_changed=True,
|
||||
custom_config_changed=False,
|
||||
model=model_name,
|
||||
default_model_name=model_name,
|
||||
model_configurations=[
|
||||
ModelConfigurationUpsertRequest(
|
||||
name=model_name, is_visible=True
|
||||
)
|
||||
],
|
||||
),
|
||||
_=_create_mock_admin(),
|
||||
db_session=db_session,
|
||||
@@ -424,7 +452,7 @@ class TestDefaultProviderEndpoint:
|
||||
)
|
||||
|
||||
# Set provider 1 as the default provider explicitly
|
||||
update_default_provider(provider_1.id, provider_1_initial_model, db_session)
|
||||
update_default_provider(provider_1.id, db_session)
|
||||
|
||||
# Step 2: Call run_test_default_provider - should use provider 1's default model
|
||||
with patch(
|
||||
@@ -484,9 +512,6 @@ class TestDefaultProviderEndpoint:
|
||||
db_session=db_session,
|
||||
)
|
||||
|
||||
# Set provider 1's default model to the updated model
|
||||
update_default_provider(provider_1.id, provider_1_updated_model, db_session)
|
||||
|
||||
# Step 6: Call run_test_default_provider - should use new model on provider 1
|
||||
with patch(
|
||||
"onyx.server.manage.llm.api.test_llm", side_effect=mock_test_llm_capture
|
||||
@@ -499,7 +524,7 @@ class TestDefaultProviderEndpoint:
|
||||
captured_llms.clear()
|
||||
|
||||
# Step 7: Change the default provider to provider 2
|
||||
update_default_provider(provider_2.id, provider_2_default_model, db_session)
|
||||
update_default_provider(provider_2.id, db_session)
|
||||
|
||||
# Step 8: Call run_test_default_provider - should use provider 2
|
||||
with patch(
|
||||
@@ -580,7 +605,7 @@ class TestDefaultProviderEndpoint:
|
||||
),
|
||||
db_session=db_session,
|
||||
)
|
||||
update_default_provider(provider.id, "gpt-4o-mini", db_session)
|
||||
update_default_provider(provider.id, db_session)
|
||||
|
||||
# Test should fail
|
||||
with patch(
|
||||
|
||||
@@ -530,8 +530,14 @@ def test_upload_with_custom_config_then_change(
|
||||
with patch("onyx.server.manage.llm.api.test_llm", side_effect=capture_test_llm):
|
||||
run_llm_config_test(
|
||||
LLMTestRequest(
|
||||
name=name,
|
||||
provider=provider_name,
|
||||
model=default_model_name,
|
||||
default_model_name=default_model_name,
|
||||
model_configurations=[
|
||||
ModelConfigurationUpsertRequest(
|
||||
name=default_model_name, is_visible=True
|
||||
)
|
||||
],
|
||||
api_key_changed=False,
|
||||
custom_config_changed=True,
|
||||
custom_config=custom_config,
|
||||
@@ -540,7 +546,7 @@ def test_upload_with_custom_config_then_change(
|
||||
db_session=db_session,
|
||||
)
|
||||
|
||||
provider = put_llm_provider(
|
||||
put_llm_provider(
|
||||
llm_provider_upsert_request=LLMProviderUpsertRequest(
|
||||
name=name,
|
||||
provider=provider_name,
|
||||
@@ -563,9 +569,14 @@ def test_upload_with_custom_config_then_change(
|
||||
# Turn auto mode off
|
||||
run_llm_config_test(
|
||||
LLMTestRequest(
|
||||
id=provider.id,
|
||||
name=name,
|
||||
provider=provider_name,
|
||||
model=default_model_name,
|
||||
default_model_name=default_model_name,
|
||||
model_configurations=[
|
||||
ModelConfigurationUpsertRequest(
|
||||
name=default_model_name, is_visible=True
|
||||
)
|
||||
],
|
||||
api_key_changed=False,
|
||||
custom_config_changed=False,
|
||||
),
|
||||
@@ -605,13 +616,13 @@ def test_upload_with_custom_config_then_change(
|
||||
)
|
||||
|
||||
# Check inside the database and check that custom_config is the same as the original
|
||||
db_provider = fetch_existing_llm_provider(name=name, db_session=db_session)
|
||||
if not db_provider:
|
||||
provider = fetch_existing_llm_provider(name=name, db_session=db_session)
|
||||
if not provider:
|
||||
assert False, "Provider not found in the database"
|
||||
|
||||
assert db_provider.custom_config == custom_config, (
|
||||
assert provider.custom_config == custom_config, (
|
||||
f"Expected custom_config {custom_config}, "
|
||||
f"but got {db_provider.custom_config}"
|
||||
f"but got {provider.custom_config}"
|
||||
)
|
||||
finally:
|
||||
db_session.rollback()
|
||||
@@ -695,7 +706,7 @@ def test_preserves_masked_sensitive_custom_config_on_test_request(
|
||||
) -> None:
|
||||
"""LLM test should restore masked sensitive custom config values before invocation."""
|
||||
name = f"test-provider-vertex-test-{uuid4().hex[:8]}"
|
||||
provider_name = LlmProviderNames.VERTEX_AI.value
|
||||
provider = LlmProviderNames.VERTEX_AI.value
|
||||
default_model_name = "gemini-2.5-pro"
|
||||
original_custom_config = {
|
||||
"vertex_credentials": '{"type":"service_account","private_key":"REAL_PRIVATE_KEY"}',
|
||||
@@ -708,10 +719,10 @@ def test_preserves_masked_sensitive_custom_config_on_test_request(
|
||||
return ""
|
||||
|
||||
try:
|
||||
provider = put_llm_provider(
|
||||
put_llm_provider(
|
||||
llm_provider_upsert_request=LLMProviderUpsertRequest(
|
||||
name=name,
|
||||
provider=provider_name,
|
||||
provider=provider,
|
||||
default_model_name=default_model_name,
|
||||
custom_config=original_custom_config,
|
||||
model_configurations=[
|
||||
@@ -731,9 +742,14 @@ def test_preserves_masked_sensitive_custom_config_on_test_request(
|
||||
with patch("onyx.server.manage.llm.api.test_llm", side_effect=capture_test_llm):
|
||||
run_llm_config_test(
|
||||
LLMTestRequest(
|
||||
id=provider.id,
|
||||
provider=provider_name,
|
||||
model=default_model_name,
|
||||
name=name,
|
||||
provider=provider,
|
||||
default_model_name=default_model_name,
|
||||
model_configurations=[
|
||||
ModelConfigurationUpsertRequest(
|
||||
name=default_model_name, is_visible=True
|
||||
)
|
||||
],
|
||||
api_key_changed=False,
|
||||
custom_config_changed=True,
|
||||
custom_config={
|
||||
|
||||
@@ -169,7 +169,7 @@ class TestAutoModeSyncFeature:
|
||||
), f"Default model should be '{expected_default_model}'"
|
||||
|
||||
# Step 4: Set the provider as default
|
||||
update_default_provider(provider.id, expected_default_model, db_session)
|
||||
update_default_provider(provider.id, db_session)
|
||||
|
||||
# Step 5: Fetch the default provider and verify
|
||||
default_model = fetch_default_llm_model(db_session)
|
||||
@@ -549,7 +549,7 @@ class TestAutoModeSyncFeature:
|
||||
name=provider_1_name, db_session=db_session
|
||||
)
|
||||
assert provider_1 is not None
|
||||
update_default_provider(provider_1.id, provider_1_default_model, db_session)
|
||||
update_default_provider(provider_1.id, db_session)
|
||||
|
||||
with patch(
|
||||
"onyx.server.manage.llm.api.fetch_llm_recommendations_from_github",
|
||||
@@ -584,7 +584,7 @@ class TestAutoModeSyncFeature:
|
||||
name=provider_2_name, db_session=db_session
|
||||
)
|
||||
assert provider_2 is not None
|
||||
update_default_provider(provider_2.id, provider_2_default_model, db_session)
|
||||
update_default_provider(provider_2.id, db_session)
|
||||
|
||||
# Step 5: Verify provider 2 is now the default
|
||||
db_session.expire_all()
|
||||
|
||||
@@ -154,9 +154,7 @@ def test_user_sends_message_to_private_provider(
|
||||
)
|
||||
_create_provider(db_session, LlmProviderNames.GOOGLE, "private-provider", False)
|
||||
|
||||
update_default_provider(
|
||||
public_provider_id, "claude-3-5-sonnet-20240620", db_session
|
||||
)
|
||||
update_default_provider(public_provider_id, db_session)
|
||||
|
||||
try:
|
||||
# Create chat session
|
||||
|
||||
@@ -448,7 +448,7 @@ class TestSlackBotFederatedSearch:
|
||||
db_session=db_session,
|
||||
)
|
||||
|
||||
update_default_provider(provider_view.id, "gpt-4o", db_session)
|
||||
update_default_provider(provider_view.id, db_session)
|
||||
|
||||
def _teardown_common_mocks(self, patches: list) -> None:
|
||||
"""Stop all patches"""
|
||||
|
||||
@@ -990,27 +990,6 @@ class _MockCIHandler(BaseHTTPRequestHandler):
|
||||
self._respond_json(
|
||||
200, {"file_id": f"mock-ci-file-{self.server._file_counter}"}
|
||||
)
|
||||
elif self.path == "/v1/execute/stream":
|
||||
if self.server.streaming_enabled:
|
||||
self._respond_sse(
|
||||
[
|
||||
(
|
||||
"output",
|
||||
{"stream": "stdout", "data": "mock output\n"},
|
||||
),
|
||||
(
|
||||
"result",
|
||||
{
|
||||
"exit_code": 0,
|
||||
"timed_out": False,
|
||||
"duration_ms": 50,
|
||||
"files": [],
|
||||
},
|
||||
),
|
||||
]
|
||||
)
|
||||
else:
|
||||
self._respond_json(404, {"error": "not found"})
|
||||
elif self.path == "/v1/execute":
|
||||
self._respond_json(
|
||||
200,
|
||||
@@ -1048,17 +1027,6 @@ class _MockCIHandler(BaseHTTPRequestHandler):
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
|
||||
def _respond_sse(self, events: list[tuple[str, dict[str, Any]]]) -> None:
|
||||
frames = []
|
||||
for event_type, data in events:
|
||||
frames.append(f"event: {event_type}\ndata: {json.dumps(data)}\n\n")
|
||||
payload = "".join(frames).encode()
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/event-stream")
|
||||
self.send_header("Content-Length", str(len(payload)))
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
|
||||
def log_message(self, format: str, *args: Any) -> None: # noqa: A002
|
||||
pass
|
||||
|
||||
@@ -1070,7 +1038,6 @@ class MockCodeInterpreterServer(HTTPServer):
|
||||
super().__init__(("localhost", 0), _MockCIHandler)
|
||||
self.captured_requests: list[CapturedRequest] = []
|
||||
self._file_counter = 0
|
||||
self.streaming_enabled: bool = True
|
||||
|
||||
@property
|
||||
def url(self) -> str:
|
||||
@@ -1201,19 +1168,17 @@ def test_code_interpreter_receives_chat_files(
|
||||
finally:
|
||||
ci_mod.CodeInterpreterClient.__init__.__defaults__ = original_defaults
|
||||
|
||||
# Verify: file uploaded, code executed via streaming, staged file cleaned up
|
||||
# Verify: file uploaded, code executed, staged file cleaned up
|
||||
assert len(mock_ci_server.get_requests(method="POST", path="/v1/files")) == 1
|
||||
assert (
|
||||
len(mock_ci_server.get_requests(method="POST", path="/v1/execute/stream")) == 1
|
||||
)
|
||||
assert len(mock_ci_server.get_requests(method="POST", path="/v1/execute")) == 1
|
||||
|
||||
delete_requests = mock_ci_server.get_requests(method="DELETE")
|
||||
assert len(delete_requests) == 1
|
||||
assert delete_requests[0].path.startswith("/v1/files/")
|
||||
|
||||
execute_body = mock_ci_server.get_requests(
|
||||
method="POST", path="/v1/execute/stream"
|
||||
)[0].json_body()
|
||||
execute_body = mock_ci_server.get_requests(method="POST", path="/v1/execute")[
|
||||
0
|
||||
].json_body()
|
||||
assert execute_body["code"] == code
|
||||
assert len(execute_body["files"]) == 1
|
||||
assert execute_body["files"][0]["path"] == "data.csv"
|
||||
@@ -1319,9 +1284,7 @@ def test_code_interpreter_replay_packets_include_code_and_output(
|
||||
db_session=db_session,
|
||||
)
|
||||
|
||||
assert (
|
||||
len(mock_ci_server.get_requests(method="POST", path="/v1/execute/stream")) == 1
|
||||
)
|
||||
assert len(mock_ci_server.get_requests(method="POST", path="/v1/execute")) == 1
|
||||
|
||||
# The response contains `packets` — a list of packet-lists, one per
|
||||
# assistant message. We should have exactly one assistant message.
|
||||
@@ -1350,76 +1313,3 @@ def test_code_interpreter_replay_packets_include_code_and_output(
|
||||
delta_obj = delta_packets[0].obj
|
||||
assert isinstance(delta_obj, PythonToolDelta)
|
||||
assert "mock output" in delta_obj.stdout
|
||||
|
||||
|
||||
def test_code_interpreter_streaming_fallback_to_batch(
|
||||
db_session: Session,
|
||||
mock_ci_server: MockCodeInterpreterServer,
|
||||
_attach_python_tool_to_default_persona: None,
|
||||
initialize_file_store: None, # noqa: ARG001
|
||||
) -> None:
|
||||
"""When the streaming endpoint is not available (older code-interpreter),
|
||||
execute_streaming should fall back to the batch /v1/execute endpoint."""
|
||||
mock_ci_server.captured_requests.clear()
|
||||
mock_ci_server._file_counter = 0
|
||||
mock_ci_server.streaming_enabled = False
|
||||
mock_url = mock_ci_server.url
|
||||
|
||||
user = create_test_user(db_session, "ci_fallback_test")
|
||||
chat_session = create_chat_session(db_session=db_session, user=user)
|
||||
|
||||
code = 'print("fallback test")'
|
||||
msg_req = SendMessageRequest(
|
||||
message="Print fallback test",
|
||||
chat_session_id=chat_session.id,
|
||||
stream=True,
|
||||
)
|
||||
|
||||
original_defaults = ci_mod.CodeInterpreterClient.__init__.__defaults__
|
||||
with (
|
||||
use_mock_llm() as mock_llm,
|
||||
patch(
|
||||
"onyx.tools.tool_implementations.python.python_tool.CODE_INTERPRETER_BASE_URL",
|
||||
mock_url,
|
||||
),
|
||||
patch(
|
||||
"onyx.tools.tool_implementations.python.code_interpreter_client.CODE_INTERPRETER_BASE_URL",
|
||||
mock_url,
|
||||
),
|
||||
):
|
||||
mock_llm.add_response(
|
||||
LLMToolCallResponse(
|
||||
tool_name="python",
|
||||
tool_call_id="call_fallback",
|
||||
tool_call_argument_tokens=[json.dumps({"code": code})],
|
||||
)
|
||||
)
|
||||
mock_llm.forward_till_end()
|
||||
|
||||
ci_mod.CodeInterpreterClient.__init__.__defaults__ = (mock_url,)
|
||||
try:
|
||||
packets = list(
|
||||
handle_stream_message_objects(
|
||||
new_msg_req=msg_req, user=user, db_session=db_session
|
||||
)
|
||||
)
|
||||
finally:
|
||||
ci_mod.CodeInterpreterClient.__init__.__defaults__ = original_defaults
|
||||
mock_ci_server.streaming_enabled = True
|
||||
|
||||
# Streaming was attempted first (returned 404), then fell back to batch
|
||||
assert (
|
||||
len(mock_ci_server.get_requests(method="POST", path="/v1/execute/stream")) == 1
|
||||
)
|
||||
assert len(mock_ci_server.get_requests(method="POST", path="/v1/execute")) == 1
|
||||
|
||||
# Verify output still made it through
|
||||
delta_packets = [
|
||||
p
|
||||
for p in packets
|
||||
if isinstance(p, Packet) and isinstance(p.obj, PythonToolDelta)
|
||||
]
|
||||
assert len(delta_packets) >= 1
|
||||
first_delta = delta_packets[0].obj
|
||||
assert isinstance(first_delta, PythonToolDelta)
|
||||
assert "mock output" in first_delta.stdout
|
||||
|
||||
@@ -38,5 +38,5 @@ COPY --from=openapi-client /local/onyx_openapi_client /app/generated/onyx_openap
|
||||
|
||||
ENV PYTHONPATH=/app
|
||||
|
||||
ENTRYPOINT ["pytest", "-s", "-rs"]
|
||||
ENTRYPOINT ["pytest", "-s"]
|
||||
CMD ["/app/tests/integration", "--ignore=/app/tests/integration/multitenant_tests"]
|
||||
|
||||
@@ -4,12 +4,10 @@ from uuid import uuid4
|
||||
import requests
|
||||
|
||||
from onyx.llm.constants import LlmProviderNames
|
||||
from onyx.server.manage.llm.models import DefaultModel
|
||||
from onyx.server.manage.llm.models import LLMProviderUpsertRequest
|
||||
from onyx.server.manage.llm.models import LLMProviderView
|
||||
from onyx.server.manage.llm.models import ModelConfigurationUpsertRequest
|
||||
from tests.integration.common_utils.constants import API_SERVER_URL
|
||||
from tests.integration.common_utils.constants import GENERAL_HEADERS
|
||||
from tests.integration.common_utils.test_models import DATestLLMProvider
|
||||
from tests.integration.common_utils.test_models import DATestUser
|
||||
|
||||
@@ -77,19 +75,9 @@ class LLMProviderManager:
|
||||
)
|
||||
|
||||
if set_as_default:
|
||||
if default_model_name is None:
|
||||
default_model_name = "gpt-4o-mini"
|
||||
set_default_response = requests.post(
|
||||
f"{API_SERVER_URL}/admin/llm/default",
|
||||
json={
|
||||
"provider_id": response_data["id"],
|
||||
"model_name": default_model_name,
|
||||
},
|
||||
headers=(
|
||||
user_performing_action.headers
|
||||
if user_performing_action
|
||||
else GENERAL_HEADERS
|
||||
),
|
||||
f"{API_SERVER_URL}/admin/llm/provider/{llm_response.json()['id']}/default",
|
||||
headers=user_performing_action.headers,
|
||||
)
|
||||
set_default_response.raise_for_status()
|
||||
|
||||
@@ -116,7 +104,7 @@ class LLMProviderManager:
|
||||
headers=user_performing_action.headers,
|
||||
)
|
||||
response.raise_for_status()
|
||||
return [LLMProviderView(**p) for p in response.json()["providers"]]
|
||||
return [LLMProviderView(**ug) for ug in response.json()]
|
||||
|
||||
@staticmethod
|
||||
def verify(
|
||||
@@ -125,11 +113,7 @@ class LLMProviderManager:
|
||||
verify_deleted: bool = False,
|
||||
) -> None:
|
||||
all_llm_providers = LLMProviderManager.get_all(user_performing_action)
|
||||
default_model = LLMProviderManager.get_default_model(user_performing_action)
|
||||
for fetched_llm_provider in all_llm_providers:
|
||||
model_names = [
|
||||
model.name for model in fetched_llm_provider.model_configurations
|
||||
]
|
||||
if llm_provider.id == fetched_llm_provider.id:
|
||||
if verify_deleted:
|
||||
raise ValueError(
|
||||
@@ -142,30 +126,11 @@ class LLMProviderManager:
|
||||
if (
|
||||
fetched_llm_groups == llm_provider_groups
|
||||
and llm_provider.provider == fetched_llm_provider.provider
|
||||
and (
|
||||
default_model is None or default_model.model_name in model_names
|
||||
)
|
||||
and llm_provider.default_model_name
|
||||
== fetched_llm_provider.default_model_name
|
||||
and llm_provider.is_public == fetched_llm_provider.is_public
|
||||
and set(fetched_llm_provider.personas) == set(llm_provider.personas)
|
||||
):
|
||||
return
|
||||
if not verify_deleted:
|
||||
raise ValueError(f"LLM Provider {llm_provider.id} not found")
|
||||
|
||||
@staticmethod
|
||||
def get_default_model(
|
||||
user_performing_action: DATestUser | None = None,
|
||||
) -> DefaultModel | None:
|
||||
response = requests.get(
|
||||
f"{API_SERVER_URL}/admin/llm/provider",
|
||||
headers=(
|
||||
user_performing_action.headers
|
||||
if user_performing_action
|
||||
else GENERAL_HEADERS
|
||||
),
|
||||
)
|
||||
response.raise_for_status()
|
||||
default_text = response.json().get("default_text")
|
||||
if default_text is None:
|
||||
return None
|
||||
return DefaultModel(**default_text)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import time
|
||||
from datetime import datetime
|
||||
from urllib.parse import urlencode
|
||||
from uuid import UUID
|
||||
@@ -9,10 +8,8 @@ from requests.models import CaseInsensitiveDict
|
||||
from ee.onyx.server.query_history.models import ChatSessionMinimal
|
||||
from ee.onyx.server.query_history.models import ChatSessionSnapshot
|
||||
from onyx.configs.constants import QAFeedbackType
|
||||
from onyx.db.enums import TaskStatus
|
||||
from onyx.server.documents.models import PaginatedReturn
|
||||
from tests.integration.common_utils.constants import API_SERVER_URL
|
||||
from tests.integration.common_utils.constants import MAX_DELAY
|
||||
from tests.integration.common_utils.test_models import DATestUser
|
||||
|
||||
|
||||
@@ -72,42 +69,9 @@ class QueryHistoryManager:
|
||||
if end_time:
|
||||
query_params["end"] = end_time.isoformat()
|
||||
|
||||
start_response = requests.post(
|
||||
url=f"{API_SERVER_URL}/admin/query-history/start-export?{urlencode(query_params, doseq=True)}",
|
||||
response = requests.get(
|
||||
url=f"{API_SERVER_URL}/admin/query-history-csv?{urlencode(query_params, doseq=True)}",
|
||||
headers=user_performing_action.headers,
|
||||
)
|
||||
start_response.raise_for_status()
|
||||
request_id = start_response.json()["request_id"]
|
||||
|
||||
deadline = time.time() + MAX_DELAY
|
||||
while time.time() < deadline:
|
||||
status_response = requests.get(
|
||||
url=f"{API_SERVER_URL}/admin/query-history/export-status",
|
||||
params={"request_id": request_id},
|
||||
headers=user_performing_action.headers,
|
||||
)
|
||||
status_response.raise_for_status()
|
||||
status = status_response.json()["status"]
|
||||
if status == TaskStatus.SUCCESS:
|
||||
break
|
||||
if status == TaskStatus.FAILURE:
|
||||
raise RuntimeError("Query history export task failed")
|
||||
time.sleep(2)
|
||||
else:
|
||||
raise TimeoutError(
|
||||
f"Query history export not completed within {MAX_DELAY} seconds"
|
||||
)
|
||||
|
||||
download_response = requests.get(
|
||||
url=f"{API_SERVER_URL}/admin/query-history/download",
|
||||
params={"request_id": request_id},
|
||||
headers=user_performing_action.headers,
|
||||
)
|
||||
download_response.raise_for_status()
|
||||
|
||||
if not download_response.content:
|
||||
raise RuntimeError(
|
||||
"Query history CSV download returned zero-length content"
|
||||
)
|
||||
|
||||
return download_response.headers, download_response.content.decode()
|
||||
response.raise_for_status()
|
||||
return response.headers, response.content.decode()
|
||||
|
||||
@@ -6,26 +6,16 @@ import pytest
|
||||
from onyx.connectors.slack.models import ChannelType
|
||||
from tests.integration.connector_job_tests.slack.slack_api_utils import SlackManager
|
||||
|
||||
SLACK_ADMIN_EMAIL = os.environ.get("SLACK_ADMIN_EMAIL", "evan@onyx.app")
|
||||
SLACK_TEST_USER_1_EMAIL = os.environ.get("SLACK_TEST_USER_1_EMAIL", "evan+1@onyx.app")
|
||||
SLACK_TEST_USER_2_EMAIL = os.environ.get("SLACK_TEST_USER_2_EMAIL", "justin@onyx.app")
|
||||
# from tests.load_env_vars import load_env_vars
|
||||
|
||||
# load_env_vars()
|
||||
|
||||
|
||||
def _provision_slack_channels(
|
||||
bot_token: str,
|
||||
) -> Generator[tuple[ChannelType, ChannelType], None, None]:
|
||||
slack_client = SlackManager.get_slack_client(bot_token)
|
||||
|
||||
auth_info = slack_client.auth_test()
|
||||
print(f"\nSlack workspace: {auth_info.get('team')} ({auth_info.get('url')})")
|
||||
|
||||
@pytest.fixture()
|
||||
def slack_test_setup() -> Generator[tuple[ChannelType, ChannelType], None, None]:
|
||||
slack_client = SlackManager.get_slack_client(os.environ["SLACK_BOT_TOKEN"])
|
||||
user_map = SlackManager.build_slack_user_email_id_map(slack_client)
|
||||
if SLACK_ADMIN_EMAIL not in user_map:
|
||||
raise KeyError(
|
||||
f"'{SLACK_ADMIN_EMAIL}' not found in Slack workspace. "
|
||||
f"Available emails: {sorted(user_map.keys())}"
|
||||
)
|
||||
admin_user_id = user_map[SLACK_ADMIN_EMAIL]
|
||||
admin_user_id = user_map["admin@example.com"]
|
||||
|
||||
(
|
||||
public_channel,
|
||||
@@ -37,16 +27,5 @@ def _provision_slack_channels(
|
||||
|
||||
yield public_channel, private_channel
|
||||
|
||||
# This part will always run after the test, even if it fails
|
||||
SlackManager.cleanup_after_test(slack_client=slack_client, test_id=run_id)
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def slack_test_setup() -> Generator[tuple[ChannelType, ChannelType], None, None]:
|
||||
yield from _provision_slack_channels(os.environ["SLACK_BOT_TOKEN"])
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def slack_perm_sync_test_setup() -> (
|
||||
Generator[tuple[ChannelType, ChannelType], None, None]
|
||||
):
|
||||
yield from _provision_slack_channels(os.environ["SLACK_BOT_TOKEN_TEST_SPACE"])
|
||||
|
||||
@@ -22,9 +22,6 @@ from tests.integration.common_utils.test_models import DATestConnector
|
||||
from tests.integration.common_utils.test_models import DATestCredential
|
||||
from tests.integration.common_utils.test_models import DATestUser
|
||||
from tests.integration.common_utils.vespa import vespa_fixture
|
||||
from tests.integration.connector_job_tests.slack.conftest import SLACK_ADMIN_EMAIL
|
||||
from tests.integration.connector_job_tests.slack.conftest import SLACK_TEST_USER_1_EMAIL
|
||||
from tests.integration.connector_job_tests.slack.conftest import SLACK_TEST_USER_2_EMAIL
|
||||
from tests.integration.connector_job_tests.slack.slack_api_utils import SlackManager
|
||||
|
||||
|
||||
@@ -37,24 +34,26 @@ from tests.integration.connector_job_tests.slack.slack_api_utils import SlackMan
|
||||
def test_slack_permission_sync(
|
||||
reset: None, # noqa: ARG001
|
||||
vespa_client: vespa_fixture, # noqa: ARG001
|
||||
slack_perm_sync_test_setup: tuple[ChannelType, ChannelType],
|
||||
slack_test_setup: tuple[ChannelType, ChannelType],
|
||||
) -> None:
|
||||
public_channel, private_channel = slack_perm_sync_test_setup
|
||||
public_channel, private_channel = slack_test_setup
|
||||
|
||||
# Creating an admin user (first user created is automatically an admin)
|
||||
admin_user: DATestUser = UserManager.create(
|
||||
email=SLACK_ADMIN_EMAIL,
|
||||
email="admin@example.com",
|
||||
)
|
||||
|
||||
# Creating a non-admin user
|
||||
test_user_1: DATestUser = UserManager.create(
|
||||
email=SLACK_TEST_USER_1_EMAIL,
|
||||
email="test_user_1@example.com",
|
||||
)
|
||||
|
||||
# Creating a non-admin user
|
||||
test_user_2: DATestUser = UserManager.create(
|
||||
email=SLACK_TEST_USER_2_EMAIL,
|
||||
email="test_user_2@example.com",
|
||||
)
|
||||
|
||||
bot_token = os.environ["SLACK_BOT_TOKEN_TEST_SPACE"]
|
||||
slack_client = SlackManager.get_slack_client(bot_token)
|
||||
slack_client = SlackManager.get_slack_client(os.environ["SLACK_BOT_TOKEN"])
|
||||
email_id_map = SlackManager.build_slack_user_email_id_map(slack_client)
|
||||
admin_user_id = email_id_map[admin_user.email]
|
||||
|
||||
@@ -64,7 +63,7 @@ def test_slack_permission_sync(
|
||||
credential: DATestCredential = CredentialManager.create(
|
||||
source=DocumentSource.SLACK,
|
||||
credential_json={
|
||||
"slack_bot_token": bot_token,
|
||||
"slack_bot_token": os.environ["SLACK_BOT_TOKEN"],
|
||||
},
|
||||
user_performing_action=admin_user,
|
||||
)
|
||||
@@ -74,7 +73,6 @@ def test_slack_permission_sync(
|
||||
source=DocumentSource.SLACK,
|
||||
connector_specific_config={
|
||||
"channels": [public_channel["name"], private_channel["name"]],
|
||||
"include_bot_messages": True,
|
||||
},
|
||||
access_type=AccessType.SYNC,
|
||||
groups=[],
|
||||
@@ -104,11 +102,14 @@ def test_slack_permission_sync(
|
||||
public_message = "Steve's favorite number is 809752"
|
||||
private_message = "Sara's favorite number is 346794"
|
||||
|
||||
# Add messages to channels
|
||||
print(f"\n Adding public message to channel: {public_message}")
|
||||
SlackManager.add_message_to_channel(
|
||||
slack_client=slack_client,
|
||||
channel=public_channel,
|
||||
message=public_message,
|
||||
)
|
||||
print(f"\n Adding private message to channel: {private_message}")
|
||||
SlackManager.add_message_to_channel(
|
||||
slack_client=slack_client,
|
||||
channel=private_channel,
|
||||
@@ -126,9 +127,7 @@ def test_slack_permission_sync(
|
||||
user_performing_action=admin_user,
|
||||
)
|
||||
|
||||
# Run permission sync. Since initial_index_should_sync=True for Slack,
|
||||
# permissions were already set during indexing above — the explicit sync
|
||||
# should find no changes to apply.
|
||||
# Run permission sync
|
||||
CCPairManager.sync(
|
||||
cc_pair=cc_pair,
|
||||
user_performing_action=admin_user,
|
||||
@@ -136,38 +135,59 @@ def test_slack_permission_sync(
|
||||
CCPairManager.wait_for_sync(
|
||||
cc_pair=cc_pair,
|
||||
after=before,
|
||||
number_of_updated_docs=0,
|
||||
number_of_updated_docs=2,
|
||||
user_performing_action=admin_user,
|
||||
should_wait_for_group_sync=False,
|
||||
should_wait_for_vespa_sync=False,
|
||||
)
|
||||
|
||||
# Verify admin can see messages from both channels
|
||||
admin_docs = DocumentSearchManager.search_documents(
|
||||
# Search as admin with access to both channels
|
||||
print("\nSearching as admin user")
|
||||
onyx_doc_message_strings = DocumentSearchManager.search_documents(
|
||||
query="favorite number",
|
||||
user_performing_action=admin_user,
|
||||
)
|
||||
assert public_message in admin_docs
|
||||
assert private_message in admin_docs
|
||||
print(
|
||||
"\n documents retrieved by admin user: ",
|
||||
onyx_doc_message_strings,
|
||||
)
|
||||
|
||||
# Verify test_user_2 can only see public channel messages
|
||||
user_2_docs = DocumentSearchManager.search_documents(
|
||||
# Ensure admin user can see messages from both channels
|
||||
assert public_message in onyx_doc_message_strings
|
||||
assert private_message in onyx_doc_message_strings
|
||||
|
||||
# Search as test_user_2 with access to only the public channel
|
||||
print("\n Searching as test_user_2")
|
||||
onyx_doc_message_strings = DocumentSearchManager.search_documents(
|
||||
query="favorite number",
|
||||
user_performing_action=test_user_2,
|
||||
)
|
||||
assert public_message in user_2_docs
|
||||
assert private_message not in user_2_docs
|
||||
print(
|
||||
"\n documents retrieved by test_user_2: ",
|
||||
onyx_doc_message_strings,
|
||||
)
|
||||
|
||||
# Verify test_user_1 can see both channels (member of private channel)
|
||||
user_1_docs = DocumentSearchManager.search_documents(
|
||||
# Ensure test_user_2 can only see messages from the public channel
|
||||
assert public_message in onyx_doc_message_strings
|
||||
assert private_message not in onyx_doc_message_strings
|
||||
|
||||
# Search as test_user_1 with access to both channels
|
||||
print("\n Searching as test_user_1")
|
||||
onyx_doc_message_strings = DocumentSearchManager.search_documents(
|
||||
query="favorite number",
|
||||
user_performing_action=test_user_1,
|
||||
)
|
||||
assert public_message in user_1_docs
|
||||
assert private_message in user_1_docs
|
||||
print(
|
||||
"\n documents retrieved by test_user_1 before being removed from private channel: ",
|
||||
onyx_doc_message_strings,
|
||||
)
|
||||
|
||||
# Remove test_user_1 from the private channel
|
||||
# Ensure test_user_1 can see messages from both channels
|
||||
assert public_message in onyx_doc_message_strings
|
||||
assert private_message in onyx_doc_message_strings
|
||||
|
||||
# ----------------------MAKE THE CHANGES--------------------------
|
||||
print("\n Removing test_user_1 from the private channel")
|
||||
before = datetime.now(timezone.utc)
|
||||
# Remove test_user_1 from the private channel
|
||||
desired_channel_members = [admin_user]
|
||||
SlackManager.set_channel_members(
|
||||
slack_client=slack_client,
|
||||
@@ -186,16 +206,24 @@ def test_slack_permission_sync(
|
||||
after=before,
|
||||
number_of_updated_docs=1,
|
||||
user_performing_action=admin_user,
|
||||
should_wait_for_group_sync=False,
|
||||
)
|
||||
|
||||
# Verify test_user_1 can no longer see private channel after removal
|
||||
user_1_docs = DocumentSearchManager.search_documents(
|
||||
# ----------------------------VERIFY THE CHANGES---------------------------
|
||||
# Ensure test_user_1 can no longer see messages from the private channel
|
||||
# Search as test_user_1 with access to only the public channel
|
||||
|
||||
onyx_doc_message_strings = DocumentSearchManager.search_documents(
|
||||
query="favorite number",
|
||||
user_performing_action=test_user_1,
|
||||
)
|
||||
assert public_message in user_1_docs
|
||||
assert private_message not in user_1_docs
|
||||
print(
|
||||
"\n documents retrieved by test_user_1 after being removed from private channel: ",
|
||||
onyx_doc_message_strings,
|
||||
)
|
||||
|
||||
# Ensure test_user_1 can only see messages from the public channel
|
||||
assert public_message in onyx_doc_message_strings
|
||||
assert private_message not in onyx_doc_message_strings
|
||||
|
||||
|
||||
# NOTE(rkuo): it isn't yet clear if the reason these were previously xfail'd
|
||||
@@ -207,19 +235,21 @@ def test_slack_permission_sync(
|
||||
def test_slack_group_permission_sync(
|
||||
reset: None, # noqa: ARG001
|
||||
vespa_client: vespa_fixture, # noqa: ARG001
|
||||
slack_perm_sync_test_setup: tuple[ChannelType, ChannelType],
|
||||
slack_test_setup: tuple[ChannelType, ChannelType],
|
||||
) -> None:
|
||||
"""
|
||||
This test ensures that permission sync overrides onyx group access.
|
||||
"""
|
||||
public_channel, private_channel = slack_perm_sync_test_setup
|
||||
public_channel, private_channel = slack_test_setup
|
||||
|
||||
# Creating an admin user (first user created is automatically an admin)
|
||||
admin_user: DATestUser = UserManager.create(
|
||||
email=SLACK_ADMIN_EMAIL,
|
||||
email="admin@example.com",
|
||||
)
|
||||
|
||||
# Creating a non-admin user
|
||||
test_user_1: DATestUser = UserManager.create(
|
||||
email=SLACK_TEST_USER_1_EMAIL,
|
||||
email="test_user_1@example.com",
|
||||
)
|
||||
|
||||
# Create a user group and adding the non-admin user to it
|
||||
@@ -234,8 +264,7 @@ def test_slack_group_permission_sync(
|
||||
user_performing_action=admin_user,
|
||||
)
|
||||
|
||||
bot_token = os.environ["SLACK_BOT_TOKEN_TEST_SPACE"]
|
||||
slack_client = SlackManager.get_slack_client(bot_token)
|
||||
slack_client = SlackManager.get_slack_client(os.environ["SLACK_BOT_TOKEN"])
|
||||
email_id_map = SlackManager.build_slack_user_email_id_map(slack_client)
|
||||
admin_user_id = email_id_map[admin_user.email]
|
||||
|
||||
@@ -253,7 +282,7 @@ def test_slack_group_permission_sync(
|
||||
credential = CredentialManager.create(
|
||||
source=DocumentSource.SLACK,
|
||||
credential_json={
|
||||
"slack_bot_token": bot_token,
|
||||
"slack_bot_token": os.environ["SLACK_BOT_TOKEN"],
|
||||
},
|
||||
user_performing_action=admin_user,
|
||||
)
|
||||
@@ -265,7 +294,6 @@ def test_slack_group_permission_sync(
|
||||
source=DocumentSource.SLACK,
|
||||
connector_specific_config={
|
||||
"channels": [private_channel["name"]],
|
||||
"include_bot_messages": True,
|
||||
},
|
||||
access_type=AccessType.SYNC,
|
||||
groups=[user_group.id],
|
||||
@@ -298,8 +326,7 @@ def test_slack_group_permission_sync(
|
||||
user_performing_action=admin_user,
|
||||
)
|
||||
|
||||
# Run permission sync. Since initial_index_should_sync=True for Slack,
|
||||
# permissions were already set during indexing — no changes expected.
|
||||
# Run permission sync
|
||||
CCPairManager.sync(
|
||||
cc_pair=cc_pair,
|
||||
user_performing_action=admin_user,
|
||||
@@ -307,10 +334,8 @@ def test_slack_group_permission_sync(
|
||||
CCPairManager.wait_for_sync(
|
||||
cc_pair=cc_pair,
|
||||
after=before,
|
||||
number_of_updated_docs=0,
|
||||
number_of_updated_docs=1,
|
||||
user_performing_action=admin_user,
|
||||
should_wait_for_group_sync=False,
|
||||
should_wait_for_vespa_sync=False,
|
||||
)
|
||||
|
||||
# Verify admin can see the message
|
||||
|
||||
@@ -5,17 +5,22 @@ from fastapi import FastAPI
|
||||
from fastapi.responses import PlainTextResponse
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth import StaticTokenVerifier
|
||||
from fastmcp.server.server import FunctionTool
|
||||
|
||||
|
||||
def make_many_tools(mcp: FastMCP) -> None:
|
||||
def make_tool(i: int) -> None:
|
||||
def make_many_tools(mcp: FastMCP) -> list[FunctionTool]:
|
||||
def make_tool(i: int) -> FunctionTool:
|
||||
@mcp.tool(name=f"tool_{i}", description=f"Get secret value {i}")
|
||||
def tool_name(name: str) -> str: # noqa: ARG001
|
||||
"""Get secret value."""
|
||||
return f"Secret value {200 - i}!"
|
||||
|
||||
return tool_name
|
||||
|
||||
tools = []
|
||||
for i in range(100):
|
||||
make_tool(i)
|
||||
tools.append(make_tool(i))
|
||||
return tools
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -28,6 +28,7 @@ from fastmcp import FastMCP
|
||||
from fastmcp.server.auth import AccessToken
|
||||
from fastmcp.server.auth import TokenVerifier
|
||||
from fastmcp.server.dependencies import get_access_token
|
||||
from fastmcp.server.server import FunctionTool
|
||||
|
||||
# Google's tokeninfo endpoint for validating access tokens
|
||||
GOOGLE_TOKENINFO_URL = "https://oauth2.googleapis.com/tokeninfo"
|
||||
@@ -147,19 +148,24 @@ class GoogleOAuthTokenVerifier(TokenVerifier):
|
||||
await self._http_client.aclose()
|
||||
|
||||
|
||||
def make_tools(mcp: FastMCP) -> None:
|
||||
def make_tools(mcp: FastMCP) -> list[FunctionTool]:
|
||||
"""Create test tools for the MCP server."""
|
||||
tools: list[FunctionTool] = []
|
||||
|
||||
@mcp.tool(name="echo", description="Echo back the input message")
|
||||
def echo(message: str) -> str:
|
||||
"""Echo the message back to the caller."""
|
||||
return f"You said: {message}"
|
||||
|
||||
tools.append(echo)
|
||||
|
||||
@mcp.tool(name="get_secret", description="Get a secret value (requires auth)")
|
||||
def get_secret(secret_name: str) -> str:
|
||||
"""Get a secret value. This proves the token was validated."""
|
||||
return f"Secret value for '{secret_name}': super-secret-value-12345"
|
||||
|
||||
tools.append(get_secret)
|
||||
|
||||
@mcp.tool(name="whoami", description="Get information about the authenticated user")
|
||||
async def whoami() -> dict[str, Any]:
|
||||
"""Get information about the authenticated user from their Google token."""
|
||||
@@ -176,6 +182,9 @@ def make_tools(mcp: FastMCP) -> None:
|
||||
"access_type": tok.claims.get("access_type"),
|
||||
}
|
||||
|
||||
tools.append(whoami)
|
||||
|
||||
# Add some numbered tools for testing tool discovery
|
||||
for i in range(5):
|
||||
|
||||
@mcp.tool(name=f"oauth_tool_{i}", description=f"Test tool number {i}")
|
||||
@@ -183,6 +192,10 @@ def make_tools(mcp: FastMCP) -> None:
|
||||
"""A numbered test tool."""
|
||||
return f"Tool {_i} says hello to {name}!"
|
||||
|
||||
tools.append(numbered_tool)
|
||||
|
||||
return tools
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
port = int(sys.argv[1] if len(sys.argv) > 1 else "8006")
|
||||
|
||||
@@ -2,6 +2,7 @@ import os
|
||||
import sys
|
||||
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.server import FunctionTool
|
||||
|
||||
mcp = FastMCP("My HTTP MCP")
|
||||
|
||||
@@ -12,15 +13,19 @@ def hello(name: str) -> str:
|
||||
return f"Hello, {name}!"
|
||||
|
||||
|
||||
def make_many_tools() -> None:
|
||||
def make_tool(i: int) -> None:
|
||||
def make_many_tools() -> list[FunctionTool]:
|
||||
def make_tool(i: int) -> FunctionTool:
|
||||
@mcp.tool(name=f"tool_{i}", description=f"Get secret value {i}")
|
||||
def tool_name(name: str) -> str: # noqa: ARG001
|
||||
"""Get secret value."""
|
||||
return f"Secret value {100 - i}!"
|
||||
|
||||
return tool_name
|
||||
|
||||
tools = []
|
||||
for i in range(100):
|
||||
make_tool(i)
|
||||
tools.append(make_tool(i))
|
||||
return tools
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -15,6 +15,7 @@ from fastapi.responses import Response
|
||||
from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.providers.jwt import JWTVerifier
|
||||
from fastmcp.server.dependencies import get_access_token
|
||||
from fastmcp.server.server import FunctionTool
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
|
||||
# uncomment for debug logs
|
||||
@@ -36,15 +37,18 @@ Enable authorization code and store the client id and secret.
|
||||
"""
|
||||
|
||||
|
||||
def make_many_tools(mcp: FastMCP) -> None:
|
||||
def make_tool(i: int) -> None:
|
||||
def make_many_tools(mcp: FastMCP) -> list[FunctionTool]:
|
||||
def make_tool(i: int) -> FunctionTool:
|
||||
@mcp.tool(name=f"tool_{i}", description=f"Get secret value {i}")
|
||||
def tool_name(name: str) -> str: # noqa: ARG001
|
||||
"""Get secret value."""
|
||||
return f"Secret value {500 - i}!"
|
||||
|
||||
return tool_name
|
||||
|
||||
tools = []
|
||||
for i in range(100):
|
||||
make_tool(i)
|
||||
tools.append(make_tool(i))
|
||||
|
||||
@mcp.tool
|
||||
async def whoami() -> dict[str, Any]:
|
||||
@@ -55,6 +59,9 @@ def make_many_tools(mcp: FastMCP) -> None:
|
||||
"claims": tok.claims if tok else {},
|
||||
}
|
||||
|
||||
tools.append(whoami)
|
||||
return tools
|
||||
|
||||
|
||||
# ---------- FASTAPI APP ----------
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from fastmcp import FastMCP
|
||||
from fastmcp.server.auth.auth import AccessToken
|
||||
from fastmcp.server.auth.auth import TokenVerifier
|
||||
from fastmcp.server.dependencies import get_access_token
|
||||
from fastmcp.server.server import FunctionTool
|
||||
|
||||
# pip install fastmcp bcrypt
|
||||
|
||||
@@ -92,15 +93,19 @@ class ApiKeyVerifier(TokenVerifier):
|
||||
# ---- server -----------------------------------------------------------------
|
||||
|
||||
|
||||
def make_many_tools(mcp: FastMCP) -> None:
|
||||
def make_tool(i: int) -> None:
|
||||
def make_many_tools(mcp: FastMCP) -> list[FunctionTool]:
|
||||
def make_tool(i: int) -> FunctionTool:
|
||||
@mcp.tool(name=f"tool_{i}", description=f"Get secret value {i}")
|
||||
def tool_name(name: str) -> str: # noqa: ARG001
|
||||
"""Get secret value."""
|
||||
return f"Secret value {400 - i}!"
|
||||
|
||||
return tool_name
|
||||
|
||||
tools = []
|
||||
for i in range(100):
|
||||
make_tool(i)
|
||||
tools.append(make_tool(i))
|
||||
return tools
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -4,84 +4,75 @@ import time
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from onyx.db.chat import delete_chat_session
|
||||
from onyx.db.chat import get_chat_sessions_older_than
|
||||
from onyx.db.engine.sql_engine import get_session_with_current_tenant
|
||||
from tests.integration.common_utils.managers.chat import ChatSessionManager
|
||||
from tests.integration.common_utils.managers.settings import SettingsManager
|
||||
from tests.integration.common_utils.test_models import DATestLLMProvider
|
||||
from tests.integration.common_utils.test_models import DATestSettings
|
||||
from tests.integration.common_utils.test_models import DATestUser
|
||||
|
||||
RETENTION_SECONDS = 10
|
||||
|
||||
|
||||
def _run_ttl_cleanup(retention_days: int) -> None:
|
||||
"""Directly execute TTL cleanup logic, bypassing Celery task infrastructure."""
|
||||
with get_session_with_current_tenant() as db_session:
|
||||
old_chat_sessions = get_chat_sessions_older_than(retention_days, db_session)
|
||||
|
||||
for user_id, session_id in old_chat_sessions:
|
||||
with get_session_with_current_tenant() as db_session:
|
||||
delete_chat_session(
|
||||
user_id,
|
||||
session_id,
|
||||
db_session,
|
||||
include_deleted=True,
|
||||
hard_delete=True,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
os.environ.get("ENABLE_PAID_ENTERPRISE_EDITION_FEATURES", "").lower() != "true",
|
||||
reason="Chat retention tests are enterprise only",
|
||||
)
|
||||
def test_chat_retention(
|
||||
reset: None, admin_user: DATestUser, llm_provider: DATestLLMProvider # noqa: ARG001
|
||||
) -> None: # noqa: ARG001
|
||||
def test_chat_retention(reset: None, admin_user: DATestUser) -> None: # noqa: ARG001
|
||||
"""Test that chat sessions are deleted after the retention period expires."""
|
||||
|
||||
retention_days = RETENTION_SECONDS // 86400
|
||||
# Set chat retention period to 10 seconds
|
||||
retention_days = 10 / 86400 # 10 seconds in days (10 / 24 / 60 / 60)
|
||||
settings = DATestSettings(maximum_chat_retention_days=retention_days)
|
||||
SettingsManager.update_settings(settings, user_performing_action=admin_user)
|
||||
|
||||
# Create a chat session
|
||||
chat_session = ChatSessionManager.create(
|
||||
persona_id=0,
|
||||
description="Test chat retention",
|
||||
user_performing_action=admin_user,
|
||||
)
|
||||
|
||||
response = ChatSessionManager.send_message(
|
||||
# Send a message
|
||||
ChatSessionManager.send_message(
|
||||
chat_session_id=chat_session.id,
|
||||
message="This message should be deleted soon",
|
||||
user_performing_action=admin_user,
|
||||
)
|
||||
assert (
|
||||
response.error is None
|
||||
), f"Chat response should not have an error: {response.error}"
|
||||
|
||||
# Verify the chat session exists
|
||||
chat_history = ChatSessionManager.get_chat_history(
|
||||
chat_session=chat_session,
|
||||
user_performing_action=admin_user,
|
||||
)
|
||||
assert len(chat_history) > 0, "Chat session should have messages"
|
||||
|
||||
# Wait for the retention period to elapse, then directly run TTL cleanup
|
||||
time.sleep(RETENTION_SECONDS + 2)
|
||||
_run_ttl_cleanup(retention_days)
|
||||
|
||||
# Verify the chat session was deleted
|
||||
# Wait for TTL task to run (give it ~60 seconds)
|
||||
print("Waiting for chat retention TTL task to run...")
|
||||
max_wait_time = 60 # maximum time to wait in seconds
|
||||
start_time = time.time()
|
||||
session_deleted = False
|
||||
try:
|
||||
chat_history = ChatSessionManager.get_chat_history(
|
||||
chat_session=chat_session,
|
||||
user_performing_action=admin_user,
|
||||
)
|
||||
session_deleted = len(chat_history) == 0
|
||||
except requests.exceptions.HTTPError as e:
|
||||
if e.response.status_code in (404, 400):
|
||||
session_deleted = True
|
||||
else:
|
||||
raise
|
||||
|
||||
assert session_deleted, "Chat session was not deleted after retention period"
|
||||
while not session_deleted and (time.time() - start_time < max_wait_time):
|
||||
# Check if chat session is deleted
|
||||
try:
|
||||
# Attempt to get chat history - this should 404
|
||||
chat_history = ChatSessionManager.get_chat_history(
|
||||
chat_session=chat_session,
|
||||
user_performing_action=admin_user,
|
||||
)
|
||||
|
||||
# If we got no messages or an empty response, session might be deleted
|
||||
if not chat_history:
|
||||
session_deleted = True
|
||||
break
|
||||
|
||||
except requests.exceptions.HTTPError as e:
|
||||
# If we get a 404 or other error, the session is gone
|
||||
if e.response.status_code in (404, 400):
|
||||
session_deleted = True
|
||||
break
|
||||
raise # Re-raise other errors
|
||||
|
||||
# Wait a bit before checking again
|
||||
time.sleep(5)
|
||||
print(f"Waited {time.time() - start_time:.1f} seconds for chat deletion...")
|
||||
|
||||
# Assert that the chat session was deleted
|
||||
assert session_deleted, "Chat session was not deleted within the expected time"
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
import requests
|
||||
|
||||
from tests.integration.common_utils.constants import API_SERVER_URL
|
||||
from tests.integration.common_utils.test_models import DATestUser
|
||||
|
||||
CODE_INTERPRETER_URL = f"{API_SERVER_URL}/admin/code-interpreter"
|
||||
CODE_INTERPRETER_HEALTH_URL = f"{CODE_INTERPRETER_URL}/health"
|
||||
|
||||
|
||||
def test_get_code_interpreter_health_as_admin(
|
||||
admin_user: DATestUser,
|
||||
) -> None:
|
||||
"""Health endpoint should return a JSON object with a 'healthy' boolean."""
|
||||
response = requests.get(
|
||||
CODE_INTERPRETER_HEALTH_URL,
|
||||
headers=admin_user.headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "healthy" in data
|
||||
assert isinstance(data["healthy"], bool)
|
||||
|
||||
|
||||
def test_get_code_interpreter_status_as_admin(
|
||||
admin_user: DATestUser,
|
||||
) -> None:
|
||||
"""GET endpoint should return a JSON object with an 'enabled' boolean."""
|
||||
response = requests.get(
|
||||
CODE_INTERPRETER_URL,
|
||||
headers=admin_user.headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "enabled" in data
|
||||
assert isinstance(data["enabled"], bool)
|
||||
|
||||
|
||||
def test_update_code_interpreter_disable_and_enable(
|
||||
admin_user: DATestUser,
|
||||
) -> None:
|
||||
"""PUT endpoint should update the enabled flag and persist across reads."""
|
||||
# Disable
|
||||
response = requests.put(
|
||||
CODE_INTERPRETER_URL,
|
||||
json={"enabled": False},
|
||||
headers=admin_user.headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify disabled
|
||||
response = requests.get(
|
||||
CODE_INTERPRETER_URL,
|
||||
headers=admin_user.headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["enabled"] is False
|
||||
|
||||
# Re-enable
|
||||
response = requests.put(
|
||||
CODE_INTERPRETER_URL,
|
||||
json={"enabled": True},
|
||||
headers=admin_user.headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Verify enabled
|
||||
response = requests.get(
|
||||
CODE_INTERPRETER_URL,
|
||||
headers=admin_user.headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["enabled"] is True
|
||||
|
||||
|
||||
def test_code_interpreter_endpoints_require_admin(
|
||||
basic_user: DATestUser,
|
||||
) -> None:
|
||||
"""All code interpreter endpoints should reject non-admin users."""
|
||||
health_response = requests.get(
|
||||
CODE_INTERPRETER_HEALTH_URL,
|
||||
headers=basic_user.headers,
|
||||
)
|
||||
assert health_response.status_code == 403
|
||||
|
||||
get_response = requests.get(
|
||||
CODE_INTERPRETER_URL,
|
||||
headers=basic_user.headers,
|
||||
)
|
||||
assert get_response.status_code == 403
|
||||
|
||||
put_response = requests.put(
|
||||
CODE_INTERPRETER_URL,
|
||||
json={"enabled": True},
|
||||
headers=basic_user.headers,
|
||||
)
|
||||
assert put_response.status_code == 403
|
||||
195
backend/tests/integration/tests/dev_apis/test_knowledge_chat.py
Normal file
195
backend/tests/integration/tests/dev_apis/test_knowledge_chat.py
Normal file
@@ -0,0 +1,195 @@
|
||||
import os
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from onyx.configs.constants import MessageType
|
||||
from tests.integration.common_utils.constants import API_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.llm_provider import LLMProviderManager
|
||||
from tests.integration.common_utils.managers.user import UserManager
|
||||
from tests.integration.common_utils.test_models import DATestAPIKey
|
||||
from tests.integration.common_utils.test_models import DATestCCPair
|
||||
from tests.integration.common_utils.test_models import DATestUser
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
os.environ.get("ENABLE_PAID_ENTERPRISE_EDITION_FEATURES", "").lower() != "true",
|
||||
reason="/chat/send-message-simple-with-history is enterprise only",
|
||||
)
|
||||
def test_all_stream_chat_message_objects_outputs(reset: None) -> None: # noqa: ARG001
|
||||
# Creating an admin user (first user created is automatically an admin)
|
||||
admin_user: DATestUser = UserManager.create(name="admin_user")
|
||||
|
||||
# create connector
|
||||
cc_pair_1: DATestCCPair = CCPairManager.create_from_scratch(
|
||||
user_performing_action=admin_user,
|
||||
)
|
||||
api_key: DATestAPIKey = APIKeyManager.create(
|
||||
user_performing_action=admin_user,
|
||||
)
|
||||
LLMProviderManager.create(user_performing_action=admin_user)
|
||||
|
||||
# SEEDING DOCUMENTS
|
||||
cc_pair_1.documents = []
|
||||
cc_pair_1.documents.append(
|
||||
DocumentManager.seed_doc_with_content(
|
||||
cc_pair=cc_pair_1,
|
||||
content="Pablo's favorite color is blue",
|
||||
api_key=api_key,
|
||||
)
|
||||
)
|
||||
cc_pair_1.documents.append(
|
||||
DocumentManager.seed_doc_with_content(
|
||||
cc_pair=cc_pair_1,
|
||||
content="Chris's favorite color is red",
|
||||
api_key=api_key,
|
||||
)
|
||||
)
|
||||
cc_pair_1.documents.append(
|
||||
DocumentManager.seed_doc_with_content(
|
||||
cc_pair=cc_pair_1,
|
||||
content="Pika's favorite color is green",
|
||||
api_key=api_key,
|
||||
)
|
||||
)
|
||||
|
||||
# TESTING RESPONSE FOR QUESTION 1
|
||||
response = requests.post(
|
||||
f"{API_SERVER_URL}/chat/send-message-simple-with-history",
|
||||
json={
|
||||
"messages": [
|
||||
{
|
||||
"message": "What is Pablo's favorite color?",
|
||||
"role": MessageType.USER.value,
|
||||
}
|
||||
],
|
||||
"persona_id": 0,
|
||||
},
|
||||
headers=admin_user.headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
response_json = response.json()
|
||||
|
||||
# check that the answer is correct
|
||||
answer_1 = response_json["answer"]
|
||||
assert "blue" in answer_1.lower()
|
||||
|
||||
# FLAKY - check that the llm selected a document
|
||||
# assert 0 in response_json["llm_selected_doc_indices"]
|
||||
|
||||
# check that the final context documents are correct
|
||||
# (it should contain all documents because there arent enough to exclude any)
|
||||
assert 0 in response_json["final_context_doc_indices"]
|
||||
assert 1 in response_json["final_context_doc_indices"]
|
||||
assert 2 in response_json["final_context_doc_indices"]
|
||||
|
||||
# FLAKY - check that the cited documents are correct
|
||||
# assert cc_pair_1.documents[0].id in response_json["cited_documents"].values()
|
||||
|
||||
# flakiness likely due to non-deterministic rephrasing
|
||||
# FLAKY - check that the top documents are correct
|
||||
# assert response_json["top_documents"][0]["document_id"] == cc_pair_1.documents[0].id
|
||||
print("response 1/3 passed")
|
||||
|
||||
# TESTING RESPONSE FOR QUESTION 2
|
||||
response = requests.post(
|
||||
f"{API_SERVER_URL}/chat/send-message-simple-with-history",
|
||||
json={
|
||||
"messages": [
|
||||
{
|
||||
"message": "What is Pablo's favorite color?",
|
||||
"role": MessageType.USER.value,
|
||||
},
|
||||
{
|
||||
"message": answer_1,
|
||||
"role": MessageType.ASSISTANT.value,
|
||||
},
|
||||
{
|
||||
"message": "What is Chris's favorite color?",
|
||||
"role": MessageType.USER.value,
|
||||
},
|
||||
],
|
||||
"persona_id": 0,
|
||||
},
|
||||
headers=admin_user.headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
response_json = response.json()
|
||||
|
||||
# check that the answer is correct
|
||||
answer_2 = response_json["answer"]
|
||||
assert "red" in answer_2.lower()
|
||||
|
||||
# FLAKY - check that the llm selected a document
|
||||
# assert 0 in response_json["llm_selected_doc_indices"]
|
||||
|
||||
# check that the final context documents are correct
|
||||
# (it should contain all documents because there arent enough to exclude any)
|
||||
assert 0 in response_json["final_context_doc_indices"]
|
||||
assert 1 in response_json["final_context_doc_indices"]
|
||||
assert 2 in response_json["final_context_doc_indices"]
|
||||
|
||||
# FLAKY - check that the cited documents are correct
|
||||
# assert cc_pair_1.documents[1].id in response_json["cited_documents"].values()
|
||||
|
||||
# flakiness likely due to non-deterministic rephrasing
|
||||
# FLAKY - check that the top documents are correct
|
||||
# assert response_json["top_documents"][0]["document_id"] == cc_pair_1.documents[1].id
|
||||
print("response 2/3 passed")
|
||||
|
||||
# TESTING RESPONSE FOR QUESTION 3
|
||||
response = requests.post(
|
||||
f"{API_SERVER_URL}/chat/send-message-simple-with-history",
|
||||
json={
|
||||
"messages": [
|
||||
{
|
||||
"message": "What is Pablo's favorite color?",
|
||||
"role": MessageType.USER.value,
|
||||
},
|
||||
{
|
||||
"message": answer_1,
|
||||
"role": MessageType.ASSISTANT.value,
|
||||
},
|
||||
{
|
||||
"message": "What is Chris's favorite color?",
|
||||
"role": MessageType.USER.value,
|
||||
},
|
||||
{
|
||||
"message": answer_2,
|
||||
"role": MessageType.ASSISTANT.value,
|
||||
},
|
||||
{
|
||||
"message": "What is Pika's favorite color?",
|
||||
"role": MessageType.USER.value,
|
||||
},
|
||||
],
|
||||
"persona_id": 0,
|
||||
},
|
||||
headers=admin_user.headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
response_json = response.json()
|
||||
|
||||
# check that the answer is correct
|
||||
answer_3 = response_json["answer"]
|
||||
assert "green" in answer_3.lower()
|
||||
|
||||
# FLAKY - check that the llm selected a document
|
||||
# assert 0 in response_json["llm_selected_doc_indices"]
|
||||
|
||||
# check that the final context documents are correct
|
||||
# (it should contain all documents because there arent enough to exclude any)
|
||||
assert 0 in response_json["final_context_doc_indices"]
|
||||
assert 1 in response_json["final_context_doc_indices"]
|
||||
assert 2 in response_json["final_context_doc_indices"]
|
||||
|
||||
# FLAKY - check that the cited documents are correct
|
||||
# assert cc_pair_1.documents[2].id in response_json["cited_documents"].values()
|
||||
|
||||
# flakiness likely due to non-deterministic rephrasing
|
||||
# FLAKY - check that the top documents are correct
|
||||
# assert response_json["top_documents"][0]["document_id"] == cc_pair_1.documents[2].id
|
||||
print("response 3/3 passed")
|
||||
250
backend/tests/integration/tests/dev_apis/test_simple_chat_api.py
Normal file
250
backend/tests/integration/tests/dev_apis/test_simple_chat_api.py
Normal file
@@ -0,0 +1,250 @@
|
||||
import json
|
||||
import os
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from onyx.configs.constants import MessageType
|
||||
from tests.integration.common_utils.constants import API_SERVER_URL
|
||||
from tests.integration.common_utils.constants import NUM_DOCS
|
||||
from tests.integration.common_utils.test_models import DATestLLMProvider
|
||||
from tests.integration.common_utils.test_models import DATestUser
|
||||
from tests.integration.conftest import DocumentBuilderType
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
os.environ.get("ENABLE_PAID_ENTERPRISE_EDITION_FEATURES", "").lower() != "true",
|
||||
reason="/chat/send-message-simple-with-history tests are enterprise only",
|
||||
)
|
||||
def test_send_message_simple_with_history(
|
||||
reset: None, # noqa: ARG001
|
||||
admin_user: DATestUser,
|
||||
llm_provider: DATestLLMProvider, # noqa: ARG001
|
||||
document_builder: DocumentBuilderType,
|
||||
) -> None:
|
||||
# create documents using the document builder
|
||||
# Create NUM_DOCS number of documents with dummy content
|
||||
content_list = [f"Document {i} content" for i in range(NUM_DOCS)]
|
||||
docs = document_builder(content_list)
|
||||
|
||||
response = requests.post(
|
||||
f"{API_SERVER_URL}/chat/send-message-simple-with-history",
|
||||
json={
|
||||
"messages": [
|
||||
{
|
||||
"message": docs[0].content,
|
||||
"role": MessageType.USER.value,
|
||||
}
|
||||
],
|
||||
"persona_id": 0,
|
||||
},
|
||||
headers=admin_user.headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
response_json = response.json()
|
||||
|
||||
# Check that the top document is the correct document
|
||||
assert response_json["top_documents"][0]["document_id"] == docs[0].id
|
||||
|
||||
# assert that the metadata is correct
|
||||
for doc in docs:
|
||||
found_doc = next(
|
||||
(x for x in response_json["top_documents"] if x["document_id"] == doc.id),
|
||||
None,
|
||||
)
|
||||
assert found_doc
|
||||
assert found_doc["metadata"]["document_id"] == doc.id
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
os.environ.get("ENABLE_PAID_ENTERPRISE_EDITION_FEATURES", "").lower() != "true",
|
||||
reason="/chat/send-message-simple-with-history tests are enterprise only",
|
||||
)
|
||||
def test_using_reference_docs_with_simple_with_history_api_flow(
|
||||
reset: None, # noqa: ARG001
|
||||
admin_user: DATestUser,
|
||||
llm_provider: DATestLLMProvider, # noqa: ARG001
|
||||
document_builder: DocumentBuilderType,
|
||||
) -> None:
|
||||
# SEEDING DOCUMENTS
|
||||
docs = document_builder(
|
||||
[
|
||||
"Chris's favorite color is blue",
|
||||
"Hagen's favorite color is red",
|
||||
"Pablo's favorite color is green",
|
||||
]
|
||||
)
|
||||
|
||||
# SEINDING MESSAGE 1
|
||||
response = requests.post(
|
||||
f"{API_SERVER_URL}/chat/send-message-simple-with-history",
|
||||
json={
|
||||
"messages": [
|
||||
{
|
||||
"message": "What is Pablo's favorite color?",
|
||||
"role": MessageType.USER.value,
|
||||
}
|
||||
],
|
||||
"persona_id": 0,
|
||||
},
|
||||
headers=admin_user.headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
response_json = response.json()
|
||||
|
||||
# get the db_doc_id of the top document to use as a search doc id for second message
|
||||
first_db_doc_id = response_json["top_documents"][0]["db_doc_id"]
|
||||
|
||||
# SEINDING MESSAGE 2
|
||||
response = requests.post(
|
||||
f"{API_SERVER_URL}/chat/send-message-simple-with-history",
|
||||
json={
|
||||
"messages": [
|
||||
{
|
||||
"message": "What is Pablo's favorite color?",
|
||||
"role": MessageType.USER.value,
|
||||
}
|
||||
],
|
||||
"persona_id": 0,
|
||||
"search_doc_ids": [first_db_doc_id],
|
||||
},
|
||||
headers=admin_user.headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
response_json = response.json()
|
||||
|
||||
# make sure there is an answer
|
||||
assert response_json["answer"]
|
||||
|
||||
# This ensures the the document we think we are referencing when we send the search_doc_ids in the second
|
||||
# message is the document that we expect it to be
|
||||
assert response_json["top_documents"][0]["document_id"] == docs[2].id
|
||||
|
||||
|
||||
@pytest.mark.skip(reason="We don't support this anymore with the DR flow :(")
|
||||
@pytest.mark.skipif(
|
||||
os.environ.get("ENABLE_PAID_ENTERPRISE_EDITION_FEATURES", "").lower() != "true",
|
||||
reason="/chat/send-message-simple-with-history tests are enterprise only",
|
||||
)
|
||||
def test_send_message_simple_with_history_strict_json(
|
||||
reset: None, # noqa: ARG001
|
||||
admin_user: DATestUser,
|
||||
llm_provider: DATestLLMProvider, # noqa: ARG001
|
||||
) -> None:
|
||||
|
||||
response = requests.post(
|
||||
f"{API_SERVER_URL}/chat/send-message-simple-with-history",
|
||||
json={
|
||||
# intentionally not relevant prompt to ensure that the
|
||||
# structured response format is actually used
|
||||
"messages": [
|
||||
{
|
||||
"message": "What is green?",
|
||||
"role": MessageType.USER.value,
|
||||
}
|
||||
],
|
||||
"persona_id": 0,
|
||||
"structured_response_format": {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": "presidents",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"presidents": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "List of the first three US presidents",
|
||||
}
|
||||
},
|
||||
"required": ["presidents"],
|
||||
"additionalProperties": False,
|
||||
},
|
||||
"strict": True,
|
||||
},
|
||||
},
|
||||
},
|
||||
headers=admin_user.headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
response_json = response.json()
|
||||
|
||||
# Check that the answer is present
|
||||
assert "answer" in response_json
|
||||
assert response_json["answer"] is not None
|
||||
|
||||
# helper
|
||||
def clean_json_string(json_string: str) -> str:
|
||||
return json_string.strip().removeprefix("```json").removesuffix("```").strip()
|
||||
|
||||
# Attempt to parse the answer as JSON
|
||||
try:
|
||||
clean_answer = clean_json_string(response_json["answer"])
|
||||
parsed_answer = json.loads(clean_answer)
|
||||
|
||||
# NOTE: do not check content, just the structure
|
||||
assert isinstance(parsed_answer, dict)
|
||||
assert "presidents" in parsed_answer
|
||||
assert isinstance(parsed_answer["presidents"], list)
|
||||
for president in parsed_answer["presidents"]:
|
||||
assert isinstance(president, str)
|
||||
except json.JSONDecodeError:
|
||||
assert (
|
||||
False
|
||||
), f"The answer is not a valid JSON object - '{response_json['answer']}'"
|
||||
|
||||
# Check that the answer_citationless is also valid JSON
|
||||
assert "answer_citationless" in response_json
|
||||
assert response_json["answer_citationless"] is not None
|
||||
try:
|
||||
clean_answer_citationless = clean_json_string(
|
||||
response_json["answer_citationless"]
|
||||
)
|
||||
parsed_answer_citationless = json.loads(clean_answer_citationless)
|
||||
assert isinstance(parsed_answer_citationless, dict)
|
||||
except json.JSONDecodeError:
|
||||
assert False, "The answer_citationless is not a valid JSON object"
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
os.environ.get("ENABLE_PAID_ENTERPRISE_EDITION_FEATURES", "").lower() != "true",
|
||||
reason="/query/answer-with-citation tests are enterprise only",
|
||||
)
|
||||
def test_answer_with_citation_api(
|
||||
reset: None, # noqa: ARG001
|
||||
admin_user: DATestUser,
|
||||
llm_provider: DATestLLMProvider, # noqa: ARG001
|
||||
document_builder: DocumentBuilderType,
|
||||
) -> None:
|
||||
|
||||
# create docs
|
||||
docs = document_builder(["Chris' favorite color is green"])
|
||||
|
||||
# send a message
|
||||
response = requests.post(
|
||||
f"{API_SERVER_URL}/query/answer-with-citation",
|
||||
json={
|
||||
"messages": [
|
||||
{
|
||||
"message": "What is Chris' favorite color? Make sure to cite the document.",
|
||||
"role": MessageType.USER.value,
|
||||
}
|
||||
],
|
||||
"persona_id": 0,
|
||||
},
|
||||
headers=admin_user.headers,
|
||||
cookies=admin_user.cookies,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
response_json = response.json()
|
||||
assert response_json["answer"]
|
||||
|
||||
has_correct_citation = False
|
||||
for citation in response_json["citations"]:
|
||||
if citation["document_id"] == docs[0].id:
|
||||
has_correct_citation = True
|
||||
break
|
||||
|
||||
assert has_correct_citation
|
||||
@@ -2,6 +2,7 @@ import os
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from datetime import timezone
|
||||
from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
@@ -11,7 +12,6 @@ from onyx.configs.constants import DocumentSource
|
||||
from onyx.connectors.mock_connector.connector import EXTERNAL_USER_EMAILS
|
||||
from onyx.connectors.mock_connector.connector import EXTERNAL_USER_GROUP_IDS
|
||||
from onyx.connectors.mock_connector.connector import MockConnectorCheckpoint
|
||||
from onyx.connectors.models import Document
|
||||
from onyx.connectors.models import InputType
|
||||
from onyx.db.document import get_documents_by_ids
|
||||
from onyx.db.engine.sql_engine import get_session_with_current_tenant
|
||||
@@ -25,16 +25,128 @@ 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.index_attempt import IndexAttemptManager
|
||||
from tests.integration.common_utils.test_document_utils import create_test_document
|
||||
from tests.integration.common_utils.test_models import DATestCCPair
|
||||
from tests.integration.common_utils.test_models import DATestUser
|
||||
from tests.integration.common_utils.vespa import vespa_fixture
|
||||
|
||||
|
||||
def _setup_mock_connector(
|
||||
@pytest.mark.skipif(
|
||||
os.environ.get("ENABLE_PAID_ENTERPRISE_EDITION_FEATURES", "").lower() != "true",
|
||||
reason="Permission sync is enterprise only",
|
||||
)
|
||||
def test_mock_connector_initial_permission_sync(
|
||||
mock_server_client: httpx.Client,
|
||||
vespa_client: vespa_fixture,
|
||||
admin_user: DATestUser,
|
||||
) -> tuple[DATestCCPair, Document]:
|
||||
"""Common setup: create a test doc, configure mock server, create cc_pair, wait for indexing."""
|
||||
) -> None:
|
||||
"""Test that the MockConnector fetches and sets permissions during initial indexing when AccessType.SYNC is used"""
|
||||
|
||||
# Set up mock server behavior
|
||||
doc_uuid = uuid.uuid4()
|
||||
test_doc = create_test_document(doc_id=f"test-doc-{doc_uuid}")
|
||||
|
||||
response = mock_server_client.post(
|
||||
"/set-behavior",
|
||||
json=[
|
||||
{
|
||||
"documents": [test_doc.model_dump(mode="json")],
|
||||
"checkpoint": MockConnectorCheckpoint(has_more=False).model_dump(
|
||||
mode="json"
|
||||
),
|
||||
"failures": [],
|
||||
}
|
||||
],
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
# Create CC Pair with SYNC access type to enable permissions during indexing
|
||||
cc_pair = CCPairManager.create_from_scratch(
|
||||
name=f"mock-connector-permissions-{uuid.uuid4()}",
|
||||
source=DocumentSource.MOCK_CONNECTOR,
|
||||
input_type=InputType.POLL,
|
||||
connector_specific_config={
|
||||
"mock_server_host": MOCK_CONNECTOR_SERVER_HOST,
|
||||
"mock_server_port": MOCK_CONNECTOR_SERVER_PORT,
|
||||
},
|
||||
access_type=AccessType.SYNC, # This enables permissions during indexing
|
||||
user_performing_action=admin_user,
|
||||
)
|
||||
|
||||
# Wait for index attempt to start
|
||||
index_attempt = IndexAttemptManager.wait_for_index_attempt_start(
|
||||
cc_pair_id=cc_pair.id,
|
||||
user_performing_action=admin_user,
|
||||
)
|
||||
|
||||
# Wait for index attempt to finish
|
||||
IndexAttemptManager.wait_for_index_attempt_completion(
|
||||
index_attempt_id=index_attempt.id,
|
||||
cc_pair_id=cc_pair.id,
|
||||
user_performing_action=admin_user,
|
||||
)
|
||||
|
||||
# Validate status
|
||||
finished_index_attempt = IndexAttemptManager.get_index_attempt_by_id(
|
||||
index_attempt_id=index_attempt.id,
|
||||
cc_pair_id=cc_pair.id,
|
||||
user_performing_action=admin_user,
|
||||
)
|
||||
assert finished_index_attempt.status == IndexingStatus.SUCCESS
|
||||
|
||||
# Verify document was indexed
|
||||
with get_session_with_current_tenant() as db_session:
|
||||
documents = DocumentManager.fetch_documents_for_cc_pair(
|
||||
cc_pair_id=cc_pair.id,
|
||||
db_session=db_session,
|
||||
vespa_client=vespa_client,
|
||||
)
|
||||
assert len(documents) == 1
|
||||
assert documents[0].id == test_doc.id
|
||||
|
||||
# Verify no errors occurred
|
||||
errors = IndexAttemptManager.get_index_attempt_errors_for_cc_pair(
|
||||
cc_pair_id=cc_pair.id,
|
||||
user_performing_action=admin_user,
|
||||
)
|
||||
assert len(errors) == 0
|
||||
|
||||
# Verify permissions were set during indexing by checking the document in the database
|
||||
with get_session_with_current_tenant() as db_session:
|
||||
db_docs = get_documents_by_ids(
|
||||
db_session=db_session,
|
||||
document_ids=[test_doc.id],
|
||||
)
|
||||
assert len(db_docs) == 1
|
||||
db_doc = db_docs[0]
|
||||
|
||||
assert db_doc.external_user_emails is not None
|
||||
assert db_doc.external_user_group_ids is not None
|
||||
|
||||
# Check the specific permissions that MockConnector sets
|
||||
assert set(db_doc.external_user_emails) == EXTERNAL_USER_EMAILS
|
||||
assert set(db_doc.external_user_group_ids) == EXTERNAL_USER_GROUP_IDS
|
||||
|
||||
# Verify the document is not public (as set by MockConnector)
|
||||
assert db_doc.is_public is False
|
||||
|
||||
# Verify that the cc_pair was marked as permissions synced
|
||||
updated_cc_pair_info = CCPairManager.get_single(
|
||||
cc_pair.id, user_performing_action=admin_user
|
||||
)
|
||||
assert updated_cc_pair_info is not None
|
||||
assert updated_cc_pair_info.last_full_permission_sync is not None
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
os.environ.get("ENABLE_PAID_ENTERPRISE_EDITION_FEATURES", "").lower() != "true",
|
||||
reason="Permission sync attempt tracking is enterprise only",
|
||||
)
|
||||
def test_permission_sync_attempt_tracking_integration(
|
||||
mock_server_client: httpx.Client,
|
||||
vespa_client: vespa_fixture, # noqa: ARG001
|
||||
admin_user: DATestUser,
|
||||
) -> None:
|
||||
"""Test that permission sync attempts are properly tracked during real sync workflows."""
|
||||
|
||||
doc_uuid = uuid.uuid4()
|
||||
test_doc = create_test_document(doc_id=f"test-doc-{doc_uuid}")
|
||||
|
||||
@@ -53,7 +165,7 @@ def _setup_mock_connector(
|
||||
assert response.status_code == 200
|
||||
|
||||
cc_pair = CCPairManager.create_from_scratch(
|
||||
name=f"mock-connector-{uuid.uuid4()}",
|
||||
name=f"mock-connector-attempt-tracking-{uuid.uuid4()}",
|
||||
source=DocumentSource.MOCK_CONNECTOR,
|
||||
input_type=InputType.POLL,
|
||||
connector_specific_config={
|
||||
@@ -75,95 +187,6 @@ def _setup_mock_connector(
|
||||
user_performing_action=admin_user,
|
||||
)
|
||||
|
||||
finished = IndexAttemptManager.get_index_attempt_by_id(
|
||||
index_attempt_id=index_attempt.id,
|
||||
cc_pair_id=cc_pair.id,
|
||||
user_performing_action=admin_user,
|
||||
)
|
||||
assert finished.status == IndexingStatus.SUCCESS
|
||||
return cc_pair, test_doc
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
os.environ.get("ENABLE_PAID_ENTERPRISE_EDITION_FEATURES", "").lower() != "true",
|
||||
reason="Permission sync is enterprise only",
|
||||
)
|
||||
def test_mock_connector_initial_permission_sync(
|
||||
mock_server_client: httpx.Client,
|
||||
vespa_client: vespa_fixture,
|
||||
admin_user: DATestUser,
|
||||
) -> None:
|
||||
"""Test that the MockConnector fetches and sets permissions during initial indexing
|
||||
when AccessType.SYNC is used."""
|
||||
|
||||
cc_pair, test_doc = _setup_mock_connector(mock_server_client, admin_user)
|
||||
|
||||
with get_session_with_current_tenant() as db_session:
|
||||
documents = DocumentManager.fetch_documents_for_cc_pair(
|
||||
cc_pair_id=cc_pair.id,
|
||||
db_session=db_session,
|
||||
vespa_client=vespa_client,
|
||||
)
|
||||
assert len(documents) == 1
|
||||
assert documents[0].id == test_doc.id
|
||||
|
||||
errors = IndexAttemptManager.get_index_attempt_errors_for_cc_pair(
|
||||
cc_pair_id=cc_pair.id,
|
||||
user_performing_action=admin_user,
|
||||
)
|
||||
assert len(errors) == 0
|
||||
|
||||
with get_session_with_current_tenant() as db_session:
|
||||
db_docs = get_documents_by_ids(
|
||||
db_session=db_session,
|
||||
document_ids=[test_doc.id],
|
||||
)
|
||||
assert len(db_docs) == 1
|
||||
db_doc = db_docs[0]
|
||||
|
||||
assert db_doc.external_user_emails is not None
|
||||
assert db_doc.external_user_group_ids is not None
|
||||
assert set(db_doc.external_user_emails) == EXTERNAL_USER_EMAILS
|
||||
assert set(db_doc.external_user_group_ids) == EXTERNAL_USER_GROUP_IDS
|
||||
assert db_doc.is_public is False
|
||||
|
||||
# After initial indexing, the beat task detects last_time_perm_sync is None
|
||||
# and triggers a doc permission sync. Explicitly trigger it to avoid
|
||||
# waiting for the 30s beat interval.
|
||||
before = datetime.now(timezone.utc)
|
||||
CCPairManager.sync(
|
||||
cc_pair=cc_pair,
|
||||
user_performing_action=admin_user,
|
||||
)
|
||||
CCPairManager.wait_for_sync(
|
||||
cc_pair=cc_pair,
|
||||
after=before,
|
||||
number_of_updated_docs=1,
|
||||
user_performing_action=admin_user,
|
||||
should_wait_for_group_sync=False,
|
||||
should_wait_for_vespa_sync=False,
|
||||
)
|
||||
|
||||
updated_cc_pair_info = CCPairManager.get_single(
|
||||
cc_pair.id, user_performing_action=admin_user
|
||||
)
|
||||
assert updated_cc_pair_info is not None
|
||||
assert updated_cc_pair_info.last_full_permission_sync is not None
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
os.environ.get("ENABLE_PAID_ENTERPRISE_EDITION_FEATURES", "").lower() != "true",
|
||||
reason="Permission sync attempt tracking is enterprise only",
|
||||
)
|
||||
def test_permission_sync_attempt_tracking_integration(
|
||||
mock_server_client: httpx.Client,
|
||||
vespa_client: vespa_fixture, # noqa: ARG001
|
||||
admin_user: DATestUser,
|
||||
) -> None:
|
||||
"""Test that permission sync attempts are properly tracked during real sync workflows."""
|
||||
|
||||
cc_pair, _test_doc = _setup_mock_connector(mock_server_client, admin_user)
|
||||
|
||||
before = datetime.now(timezone.utc)
|
||||
CCPairManager.sync(
|
||||
cc_pair=cc_pair,
|
||||
@@ -175,8 +198,6 @@ def test_permission_sync_attempt_tracking_integration(
|
||||
after=before,
|
||||
number_of_updated_docs=1,
|
||||
user_performing_action=admin_user,
|
||||
should_wait_for_group_sync=False,
|
||||
should_wait_for_vespa_sync=False,
|
||||
)
|
||||
|
||||
with get_session_with_current_tenant() as db_session:
|
||||
@@ -198,6 +219,88 @@ def test_permission_sync_attempt_tracking_integration(
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
os.environ.get("ENABLE_PAID_ENTERPRISE_EDITION_FEATURES", "").lower() != "true",
|
||||
reason="Permission sync attempt tracking is enterprise only",
|
||||
)
|
||||
def test_permission_sync_attempt_tracking_with_mocked_failure(
|
||||
mock_server_client: httpx.Client,
|
||||
vespa_client: vespa_fixture, # noqa: ARG001
|
||||
admin_user: DATestUser,
|
||||
) -> None:
|
||||
"""Test that permission sync attempts are properly tracked when sync fails."""
|
||||
|
||||
doc_uuid = uuid.uuid4()
|
||||
test_doc = create_test_document(doc_id=f"test-doc-{doc_uuid}")
|
||||
|
||||
response = mock_server_client.post(
|
||||
"/set-behavior",
|
||||
json=[
|
||||
{
|
||||
"documents": [test_doc.model_dump(mode="json")],
|
||||
"checkpoint": MockConnectorCheckpoint(has_more=False).model_dump(
|
||||
mode="json"
|
||||
),
|
||||
"failures": [],
|
||||
}
|
||||
],
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
cc_pair = CCPairManager.create_from_scratch(
|
||||
name=f"mock-connector-attempt-failure-{uuid.uuid4()}",
|
||||
source=DocumentSource.MOCK_CONNECTOR,
|
||||
input_type=InputType.POLL,
|
||||
connector_specific_config={
|
||||
"mock_server_host": MOCK_CONNECTOR_SERVER_HOST,
|
||||
"mock_server_port": MOCK_CONNECTOR_SERVER_PORT,
|
||||
},
|
||||
access_type=AccessType.SYNC,
|
||||
user_performing_action=admin_user,
|
||||
)
|
||||
|
||||
index_attempt = IndexAttemptManager.wait_for_index_attempt_start(
|
||||
cc_pair_id=cc_pair.id,
|
||||
user_performing_action=admin_user,
|
||||
)
|
||||
|
||||
IndexAttemptManager.wait_for_index_attempt_completion(
|
||||
index_attempt_id=index_attempt.id,
|
||||
cc_pair_id=cc_pair.id,
|
||||
user_performing_action=admin_user,
|
||||
)
|
||||
|
||||
# Mock the permission sync to force a failure and verify attempt tracking
|
||||
with patch(
|
||||
"ee.onyx.background.celery.tasks.doc_permission_syncing.tasks.validate_ccpair_for_user"
|
||||
) as mock_validate:
|
||||
mock_validate.side_effect = Exception("Validation failed for testing")
|
||||
|
||||
try:
|
||||
before = datetime.now(timezone.utc)
|
||||
CCPairManager.sync(
|
||||
cc_pair=cc_pair,
|
||||
user_performing_action=admin_user,
|
||||
)
|
||||
CCPairManager.wait_for_sync(
|
||||
cc_pair=cc_pair,
|
||||
after=before,
|
||||
number_of_updated_docs=0,
|
||||
user_performing_action=admin_user,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
with get_session_with_current_tenant() as db_session:
|
||||
attempt = db_session.execute(
|
||||
select(DocPermissionSyncAttempt).where(
|
||||
DocPermissionSyncAttempt.connector_credential_pair_id == cc_pair.id
|
||||
)
|
||||
).scalar_one()
|
||||
|
||||
assert attempt.status == PermissionSyncStatus.FAILED
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
os.environ.get("ENABLE_PAID_ENTERPRISE_EDITION_FEATURES", "").lower() != "true",
|
||||
reason="Permission sync attempt tracking is enterprise only",
|
||||
@@ -208,8 +311,45 @@ def test_permission_sync_attempt_status_success(
|
||||
admin_user: DATestUser,
|
||||
) -> None:
|
||||
"""Test that permission sync attempts are marked as SUCCESS when sync completes without errors."""
|
||||
doc_uuid = uuid.uuid4()
|
||||
test_doc = create_test_document(doc_id=f"test-doc-{doc_uuid}")
|
||||
|
||||
cc_pair, _test_doc = _setup_mock_connector(mock_server_client, admin_user)
|
||||
response = mock_server_client.post(
|
||||
"/set-behavior",
|
||||
json=[
|
||||
{
|
||||
"documents": [test_doc.model_dump(mode="json")],
|
||||
"checkpoint": MockConnectorCheckpoint(has_more=False).model_dump(
|
||||
mode="json"
|
||||
),
|
||||
"failures": [],
|
||||
}
|
||||
],
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
cc_pair = CCPairManager.create_from_scratch(
|
||||
name=f"mock-connector-success-{uuid.uuid4()}",
|
||||
source=DocumentSource.MOCK_CONNECTOR,
|
||||
input_type=InputType.POLL,
|
||||
connector_specific_config={
|
||||
"mock_server_host": MOCK_CONNECTOR_SERVER_HOST,
|
||||
"mock_server_port": MOCK_CONNECTOR_SERVER_PORT,
|
||||
},
|
||||
access_type=AccessType.SYNC,
|
||||
user_performing_action=admin_user,
|
||||
)
|
||||
|
||||
index_attempt = IndexAttemptManager.wait_for_index_attempt_start(
|
||||
cc_pair_id=cc_pair.id,
|
||||
user_performing_action=admin_user,
|
||||
)
|
||||
|
||||
IndexAttemptManager.wait_for_index_attempt_completion(
|
||||
index_attempt_id=index_attempt.id,
|
||||
cc_pair_id=cc_pair.id,
|
||||
user_performing_action=admin_user,
|
||||
)
|
||||
|
||||
before = datetime.now(timezone.utc)
|
||||
CCPairManager.sync(
|
||||
@@ -222,8 +362,6 @@ def test_permission_sync_attempt_status_success(
|
||||
after=before,
|
||||
number_of_updated_docs=1,
|
||||
user_performing_action=admin_user,
|
||||
should_wait_for_group_sync=False,
|
||||
should_wait_for_vespa_sync=False,
|
||||
)
|
||||
|
||||
with get_session_with_current_tenant() as db_session:
|
||||
|
||||
@@ -72,7 +72,7 @@ def _get_provider_by_id(admin_user: DATestUser, provider_id: int) -> dict:
|
||||
headers=admin_user.headers,
|
||||
)
|
||||
response.raise_for_status()
|
||||
for provider in response.json()["providers"]:
|
||||
for provider in response.json():
|
||||
if provider["id"] == provider_id:
|
||||
return provider
|
||||
raise ValueError(f"Provider with id {provider_id} not found")
|
||||
|
||||
@@ -23,7 +23,7 @@ def _get_provider_by_id(admin_user: DATestUser, provider_id: str) -> dict | None
|
||||
headers=admin_user.headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
providers = response.json()["providers"]
|
||||
providers = response.json()
|
||||
return next((p for p in providers if p["id"] == provider_id), None)
|
||||
|
||||
|
||||
@@ -302,16 +302,6 @@ def test_update_model_configurations(
|
||||
initial_expected,
|
||||
)
|
||||
|
||||
response = requests.post(
|
||||
f"{API_SERVER_URL}/admin/llm/default",
|
||||
headers=admin_user.headers,
|
||||
json={
|
||||
"provider_id": created_provider["id"],
|
||||
"model_name": updated_default_model_name,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
response = requests.put(
|
||||
f"{API_SERVER_URL}/admin/llm/provider",
|
||||
headers=admin_user.headers,
|
||||
@@ -337,16 +327,6 @@ def test_update_model_configurations(
|
||||
"sk-0****0000",
|
||||
)
|
||||
|
||||
response = requests.post(
|
||||
f"{API_SERVER_URL}/admin/llm/default",
|
||||
headers=admin_user.headers,
|
||||
json={
|
||||
"provider_id": created_provider["id"],
|
||||
"model_name": updated_default_model_name,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
response = requests.put(
|
||||
f"{API_SERVER_URL}/admin/llm/provider",
|
||||
headers=admin_user.headers,
|
||||
@@ -598,57 +578,6 @@ def test_model_visibility_preserved_on_edit(reset: None) -> None: # noqa: ARG00
|
||||
assert visible_models[0]["name"] == "gpt-4o"
|
||||
|
||||
|
||||
def _get_provider_by_name(providers: list[dict], provider_name: str) -> dict | None:
|
||||
return next((p for p in providers if p["name"] == provider_name), None)
|
||||
|
||||
|
||||
def _get_providers_admin(
|
||||
admin_user: DATestUser,
|
||||
) -> dict | None:
|
||||
response = requests.get(
|
||||
f"{API_SERVER_URL}/admin/llm/provider",
|
||||
headers=admin_user.headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
resp_json = response.json()
|
||||
|
||||
return resp_json
|
||||
|
||||
|
||||
def _unpack_data(data: dict) -> tuple[list[dict], dict | None, dict | None]:
|
||||
providers = data["providers"]
|
||||
text_default = data.get("default_text")
|
||||
vision_default = data.get("default_vision")
|
||||
|
||||
return providers, text_default, vision_default
|
||||
|
||||
|
||||
def _get_providers_basic(
|
||||
user: DATestUser,
|
||||
) -> dict | None:
|
||||
response = requests.get(
|
||||
f"{API_SERVER_URL}/llm/provider",
|
||||
headers=user.headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
resp_json = response.json()
|
||||
|
||||
return resp_json
|
||||
|
||||
|
||||
def _validate_default_model(
|
||||
default: dict | None,
|
||||
provider_id: int | None = None,
|
||||
model_name: str | None = None,
|
||||
) -> None:
|
||||
if default is None:
|
||||
assert provider_id is None and model_name is None
|
||||
return
|
||||
|
||||
assert default["provider_id"] == provider_id
|
||||
assert default["model_name"] == model_name
|
||||
|
||||
|
||||
def _get_provider_by_name_admin(
|
||||
admin_user: DATestUser, provider_name: str
|
||||
) -> dict | None:
|
||||
@@ -669,7 +598,7 @@ def _get_provider_by_name_basic(user: DATestUser, provider_name: str) -> dict |
|
||||
headers=user.headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
providers = response.json()["providers"]
|
||||
providers = response.json()
|
||||
return next((p for p in providers if p["name"] == provider_name), None)
|
||||
|
||||
|
||||
@@ -853,22 +782,9 @@ def test_default_model_persistence_and_update(reset: None) -> None: # noqa: ARG
|
||||
)
|
||||
assert create_response.status_code == 200
|
||||
|
||||
# Capture initial defaults (setup_postgres may have created a DevEnvPresetOpenAI default)
|
||||
initial_data = _get_providers_admin(admin_user)
|
||||
assert initial_data is not None
|
||||
_, initial_text_default, initial_vision_default = _unpack_data(initial_data)
|
||||
|
||||
# Step 2: Verify via admin endpoint that all provider data is correct
|
||||
admin_data = _get_providers_admin(admin_user)
|
||||
assert admin_data is not None
|
||||
providers, text_default, vision_default = _unpack_data(admin_data)
|
||||
# Defaults should be unchanged from initial state (new provider not set as default)
|
||||
assert text_default == initial_text_default
|
||||
assert vision_default == initial_vision_default
|
||||
|
||||
admin_provider_data = _get_provider_by_name(providers, provider_name)
|
||||
admin_provider_data = _get_provider_by_name_admin(admin_user, provider_name)
|
||||
assert admin_provider_data is not None
|
||||
|
||||
_validate_provider_data(
|
||||
admin_provider_data,
|
||||
expected_name=provider_name,
|
||||
@@ -881,13 +797,7 @@ def test_default_model_persistence_and_update(reset: None) -> None: # noqa: ARG
|
||||
)
|
||||
|
||||
# Step 3: Verify via basic endpoint (admin user) that all provider data is correct
|
||||
admin_basic_data = _get_providers_basic(admin_user)
|
||||
assert admin_basic_data is not None
|
||||
providers, text_default, vision_default = _unpack_data(admin_basic_data)
|
||||
assert text_default == initial_text_default
|
||||
assert vision_default == initial_vision_default
|
||||
|
||||
admin_basic_provider_data = _get_provider_by_name(providers, provider_name)
|
||||
admin_basic_provider_data = _get_provider_by_name_basic(admin_user, provider_name)
|
||||
assert admin_basic_provider_data is not None
|
||||
_validate_provider_data(
|
||||
admin_basic_provider_data,
|
||||
@@ -900,13 +810,7 @@ def test_default_model_persistence_and_update(reset: None) -> None: # noqa: ARG
|
||||
)
|
||||
|
||||
# Step 4: Verify non-admin user sees the same provider data via basic endpoint
|
||||
basic_user_data = _get_providers_basic(basic_user)
|
||||
assert basic_user_data is not None
|
||||
providers, text_default, vision_default = _unpack_data(basic_user_data)
|
||||
assert text_default == initial_text_default
|
||||
assert vision_default == initial_vision_default
|
||||
|
||||
basic_user_provider_data = _get_provider_by_name(providers, provider_name)
|
||||
basic_user_provider_data = _get_provider_by_name_basic(basic_user, provider_name)
|
||||
assert basic_user_provider_data is not None
|
||||
_validate_provider_data(
|
||||
basic_user_provider_data,
|
||||
@@ -936,27 +840,13 @@ def test_default_model_persistence_and_update(reset: None) -> None: # noqa: ARG
|
||||
assert update_response.status_code == 200
|
||||
|
||||
default_provider_response = requests.post(
|
||||
f"{API_SERVER_URL}/admin/llm/default",
|
||||
json={
|
||||
"provider_id": update_response.json()["id"],
|
||||
"model_name": updated_default_model,
|
||||
},
|
||||
f"{API_SERVER_URL}/admin/llm/provider/{update_response.json()['id']}/default",
|
||||
headers=admin_user.headers,
|
||||
)
|
||||
assert default_provider_response.status_code == 200
|
||||
|
||||
# Step 6a: Verify the updated provider data via admin endpoint
|
||||
admin_data = _get_providers_admin(admin_user)
|
||||
assert admin_data is not None
|
||||
providers, text_default, vision_default = _unpack_data(admin_data)
|
||||
_validate_default_model(
|
||||
text_default,
|
||||
provider_id=update_response.json()["id"],
|
||||
model_name=updated_default_model,
|
||||
)
|
||||
_validate_default_model(vision_default) # None
|
||||
|
||||
admin_provider_data = _get_provider_by_name(providers, provider_name)
|
||||
admin_provider_data = _get_provider_by_name_admin(admin_user, provider_name)
|
||||
assert admin_provider_data is not None
|
||||
_validate_provider_data(
|
||||
admin_provider_data,
|
||||
@@ -970,17 +860,7 @@ def test_default_model_persistence_and_update(reset: None) -> None: # noqa: ARG
|
||||
)
|
||||
|
||||
# Step 6b: Verify the updated provider data via basic endpoint (admin user)
|
||||
admin_basic_data = _get_providers_basic(admin_user)
|
||||
assert admin_basic_data is not None
|
||||
providers, text_default, vision_default = _unpack_data(admin_basic_data)
|
||||
_validate_default_model(
|
||||
text_default,
|
||||
provider_id=update_response.json()["id"],
|
||||
model_name=updated_default_model,
|
||||
)
|
||||
_validate_default_model(vision_default) # None
|
||||
|
||||
admin_basic_provider_data = _get_provider_by_name(providers, provider_name)
|
||||
admin_basic_provider_data = _get_provider_by_name_basic(admin_user, provider_name)
|
||||
assert admin_basic_provider_data is not None
|
||||
_validate_provider_data(
|
||||
admin_basic_provider_data,
|
||||
@@ -993,17 +873,7 @@ def test_default_model_persistence_and_update(reset: None) -> None: # noqa: ARG
|
||||
)
|
||||
|
||||
# Step 7: Verify non-admin user sees the updated provider data
|
||||
basic_user_data = _get_providers_basic(basic_user)
|
||||
assert basic_user_data is not None
|
||||
providers, text_default, vision_default = _unpack_data(basic_user_data)
|
||||
_validate_default_model(
|
||||
text_default,
|
||||
provider_id=update_response.json()["id"],
|
||||
model_name=updated_default_model,
|
||||
)
|
||||
_validate_default_model(vision_default) # None
|
||||
|
||||
basic_user_provider_data = _get_provider_by_name(providers, provider_name)
|
||||
basic_user_provider_data = _get_provider_by_name_basic(basic_user, provider_name)
|
||||
assert basic_user_provider_data is not None
|
||||
_validate_provider_data(
|
||||
basic_user_provider_data,
|
||||
@@ -1023,7 +893,7 @@ def _get_all_providers_basic(user: DATestUser) -> list[dict]:
|
||||
headers=user.headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
return response.json()["providers"]
|
||||
return response.json()
|
||||
|
||||
|
||||
def _get_all_providers_admin(admin_user: DATestUser) -> list[dict]:
|
||||
@@ -1033,19 +903,13 @@ def _get_all_providers_admin(admin_user: DATestUser) -> list[dict]:
|
||||
headers=admin_user.headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
return response.json()["providers"]
|
||||
return response.json()
|
||||
|
||||
|
||||
def _set_default_provider(
|
||||
admin_user: DATestUser, provider_id: int, model_name: str
|
||||
) -> None:
|
||||
def _set_default_provider(admin_user: DATestUser, provider_id: int) -> None:
|
||||
"""Utility function to set a provider as the default."""
|
||||
response = requests.post(
|
||||
f"{API_SERVER_URL}/admin/llm/default",
|
||||
json={
|
||||
"provider_id": provider_id,
|
||||
"model_name": model_name,
|
||||
},
|
||||
f"{API_SERVER_URL}/admin/llm/provider/{provider_id}/default",
|
||||
headers=admin_user.headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
@@ -1055,17 +919,18 @@ def _set_default_vision_provider(
|
||||
admin_user: DATestUser, provider_id: int, vision_model: str | None = None
|
||||
) -> None:
|
||||
"""Utility function to set a provider as the default vision provider."""
|
||||
response = requests.post(
|
||||
f"{API_SERVER_URL}/admin/llm/default-vision",
|
||||
json={
|
||||
"provider_id": provider_id,
|
||||
"model_name": vision_model,
|
||||
},
|
||||
headers=admin_user.headers,
|
||||
)
|
||||
url = f"{API_SERVER_URL}/admin/llm/provider/{provider_id}/default-vision"
|
||||
if vision_model:
|
||||
url += f"?vision_model={vision_model}"
|
||||
response = requests.post(url, headers=admin_user.headers)
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
def _find_default_provider(providers: list[dict]) -> dict | None:
|
||||
"""Find the default provider from a list of providers."""
|
||||
return next((p for p in providers if p.get("is_default_provider")), None)
|
||||
|
||||
|
||||
def _find_default_vision_provider(providers: list[dict]) -> dict | None:
|
||||
"""Find the default vision provider from a list of providers."""
|
||||
return next((p for p in providers if p.get("is_default_vision_provider")), None)
|
||||
@@ -1151,8 +1016,6 @@ def test_multiple_providers_default_switching(reset: None) -> None: # noqa: ARG
|
||||
assert create_response_1.status_code == 200
|
||||
provider_1 = create_response_1.json()
|
||||
|
||||
_set_default_provider(admin_user, provider_1["id"], shared_model_name)
|
||||
|
||||
# Create provider 2 with provider_2_unique_model as default initially
|
||||
create_response_2 = requests.put(
|
||||
f"{API_SERVER_URL}/admin/llm/provider?is_creation=true",
|
||||
@@ -1172,21 +1035,15 @@ def test_multiple_providers_default_switching(reset: None) -> None: # noqa: ARG
|
||||
provider_2 = create_response_2.json()
|
||||
|
||||
# Step 2: Set provider 1 as the default provider
|
||||
_set_default_provider(admin_user, provider_1["id"], shared_model_name)
|
||||
_set_default_provider(admin_user, provider_1["id"])
|
||||
|
||||
# Step 3: Both admin and basic_user query and verify they see the same default
|
||||
# Validate via admin endpoint
|
||||
admin_data = _get_providers_admin(admin_user)
|
||||
assert admin_data is not None
|
||||
providers, text_default, vision_default = _unpack_data(admin_data)
|
||||
_validate_default_model(
|
||||
text_default, provider_id=provider_1["id"], model_name=shared_model_name
|
||||
)
|
||||
_validate_default_model(vision_default) # None
|
||||
admin_provider_data = _get_provider_by_name(providers, provider_1_name)
|
||||
assert admin_provider_data is not None
|
||||
admin_providers = _get_all_providers_admin(admin_user)
|
||||
admin_default = _find_default_provider(admin_providers)
|
||||
assert admin_default is not None
|
||||
_validate_provider_data(
|
||||
admin_provider_data,
|
||||
admin_default,
|
||||
expected_name=provider_1_name,
|
||||
expected_provider=LlmProviderNames.OPENAI,
|
||||
expected_default_model=shared_model_name,
|
||||
@@ -1197,7 +1054,9 @@ def test_multiple_providers_default_switching(reset: None) -> None: # noqa: ARG
|
||||
)
|
||||
|
||||
# Validate provider 2 via admin endpoint (should not be default)
|
||||
admin_provider_2 = _get_provider_by_name(providers, provider_2_name)
|
||||
admin_provider_2 = next(
|
||||
(p for p in admin_providers if p["name"] == provider_2_name), None
|
||||
)
|
||||
assert admin_provider_2 is not None
|
||||
_validate_provider_data(
|
||||
admin_provider_2,
|
||||
@@ -1211,17 +1070,11 @@ def test_multiple_providers_default_switching(reset: None) -> None: # noqa: ARG
|
||||
)
|
||||
|
||||
# Validate via basic endpoint (basic_user)
|
||||
basic_data = _get_providers_basic(basic_user)
|
||||
assert basic_data is not None
|
||||
providers, text_default, vision_default = _unpack_data(basic_data)
|
||||
_validate_default_model(
|
||||
text_default, provider_id=provider_1["id"], model_name=shared_model_name
|
||||
)
|
||||
_validate_default_model(vision_default) # None
|
||||
basic_provider_data = _get_provider_by_name(providers, provider_1_name)
|
||||
assert basic_provider_data is not None
|
||||
basic_providers = _get_all_providers_basic(basic_user)
|
||||
basic_default = _find_default_provider(basic_providers)
|
||||
assert basic_default is not None
|
||||
_validate_provider_data(
|
||||
basic_provider_data,
|
||||
basic_default,
|
||||
expected_name=provider_1_name,
|
||||
expected_provider=LlmProviderNames.OPENAI,
|
||||
expected_default_model=shared_model_name,
|
||||
@@ -1231,17 +1084,11 @@ def test_multiple_providers_default_switching(reset: None) -> None: # noqa: ARG
|
||||
)
|
||||
|
||||
# Also verify admin sees the same via basic endpoint
|
||||
admin_basic_data = _get_providers_basic(admin_user)
|
||||
assert admin_basic_data is not None
|
||||
providers, text_default, vision_default = _unpack_data(admin_basic_data)
|
||||
_validate_default_model(
|
||||
text_default, provider_id=provider_1["id"], model_name=shared_model_name
|
||||
)
|
||||
_validate_default_model(vision_default) # None
|
||||
admin_basic_provider_data = _get_provider_by_name(providers, provider_1_name)
|
||||
assert admin_basic_provider_data is not None
|
||||
admin_basic_providers = _get_all_providers_basic(admin_user)
|
||||
admin_basic_default = _find_default_provider(admin_basic_providers)
|
||||
assert admin_basic_default is not None
|
||||
_validate_provider_data(
|
||||
admin_basic_provider_data,
|
||||
admin_basic_default,
|
||||
expected_name=provider_1_name,
|
||||
expected_provider=LlmProviderNames.OPENAI,
|
||||
expected_default_model=shared_model_name,
|
||||
@@ -1270,21 +1117,15 @@ def test_multiple_providers_default_switching(reset: None) -> None: # noqa: ARG
|
||||
assert update_response.status_code == 200
|
||||
|
||||
# Now set provider 2 as the default
|
||||
_set_default_provider(admin_user, provider_2["id"], provider_2_unique_model)
|
||||
_set_default_provider(admin_user, provider_2["id"])
|
||||
|
||||
# Step 5: Both admin and basic_user verify they see the updated default
|
||||
# Validate via admin endpoint
|
||||
admin_data = _get_providers_admin(admin_user)
|
||||
assert admin_data is not None
|
||||
providers, text_default, vision_default = _unpack_data(admin_data)
|
||||
_validate_default_model(
|
||||
text_default, provider_id=provider_2["id"], model_name=provider_2_unique_model
|
||||
)
|
||||
_validate_default_model(vision_default) # None
|
||||
admin_provider_data = _get_provider_by_name(providers, provider_2_name)
|
||||
assert admin_provider_data is not None
|
||||
admin_providers = _get_all_providers_admin(admin_user)
|
||||
admin_default = _find_default_provider(admin_providers)
|
||||
assert admin_default is not None
|
||||
_validate_provider_data(
|
||||
admin_provider_data,
|
||||
admin_default,
|
||||
expected_name=provider_2_name,
|
||||
expected_provider=LlmProviderNames.OPENAI,
|
||||
expected_default_model=provider_2_unique_model,
|
||||
@@ -1295,7 +1136,9 @@ def test_multiple_providers_default_switching(reset: None) -> None: # noqa: ARG
|
||||
)
|
||||
|
||||
# Validate provider 1 via admin endpoint (should no longer be default)
|
||||
admin_provider_1 = _get_provider_by_name(providers, provider_1_name)
|
||||
admin_provider_1 = next(
|
||||
(p for p in admin_providers if p["name"] == provider_1_name), None
|
||||
)
|
||||
assert admin_provider_1 is not None
|
||||
_validate_provider_data(
|
||||
admin_provider_1,
|
||||
@@ -1309,17 +1152,11 @@ def test_multiple_providers_default_switching(reset: None) -> None: # noqa: ARG
|
||||
)
|
||||
|
||||
# Validate via basic endpoint (basic_user)
|
||||
basic_data = _get_providers_basic(basic_user)
|
||||
assert basic_data is not None
|
||||
providers, text_default, vision_default = _unpack_data(basic_data)
|
||||
_validate_default_model(
|
||||
text_default, provider_id=provider_2["id"], model_name=provider_2_unique_model
|
||||
)
|
||||
_validate_default_model(vision_default) # None
|
||||
basic_provider_data = _get_provider_by_name(providers, provider_2_name)
|
||||
assert basic_provider_data is not None
|
||||
basic_providers = _get_all_providers_basic(basic_user)
|
||||
basic_default = _find_default_provider(basic_providers)
|
||||
assert basic_default is not None
|
||||
_validate_provider_data(
|
||||
basic_provider_data,
|
||||
basic_default,
|
||||
expected_name=provider_2_name,
|
||||
expected_provider=LlmProviderNames.OPENAI,
|
||||
expected_default_model=provider_2_unique_model,
|
||||
@@ -1329,17 +1166,11 @@ def test_multiple_providers_default_switching(reset: None) -> None: # noqa: ARG
|
||||
)
|
||||
|
||||
# Validate via basic endpoint (admin_user)
|
||||
admin_basic_data = _get_providers_basic(admin_user)
|
||||
assert admin_basic_data is not None
|
||||
providers, text_default, vision_default = _unpack_data(admin_basic_data)
|
||||
_validate_default_model(
|
||||
text_default, provider_id=provider_2["id"], model_name=provider_2_unique_model
|
||||
)
|
||||
_validate_default_model(vision_default) # None
|
||||
admin_basic_provider_data = _get_provider_by_name(providers, provider_2_name)
|
||||
assert admin_basic_provider_data is not None
|
||||
admin_basic_providers = _get_all_providers_basic(admin_user)
|
||||
admin_basic_default = _find_default_provider(admin_basic_providers)
|
||||
assert admin_basic_default is not None
|
||||
_validate_provider_data(
|
||||
admin_basic_provider_data,
|
||||
admin_basic_default,
|
||||
expected_name=provider_2_name,
|
||||
expected_provider=LlmProviderNames.OPENAI,
|
||||
expected_default_model=provider_2_unique_model,
|
||||
@@ -1367,23 +1198,13 @@ def test_multiple_providers_default_switching(reset: None) -> None: # noqa: ARG
|
||||
)
|
||||
assert update_response.status_code == 200
|
||||
|
||||
_set_default_provider(
|
||||
admin_user, provider_2["id"], shared_model_name
|
||||
) # Same name as provider 1's model
|
||||
|
||||
# Step 7: Both users verify they see provider 2 as default with the shared model name
|
||||
# Validate via admin endpoint
|
||||
admin_data = _get_providers_admin(admin_user)
|
||||
assert admin_data is not None
|
||||
providers, text_default, vision_default = _unpack_data(admin_data)
|
||||
_validate_default_model(
|
||||
text_default, provider_id=provider_2["id"], model_name=shared_model_name
|
||||
)
|
||||
_validate_default_model(vision_default) # None
|
||||
admin_provider_data = _get_provider_by_name(providers, provider_2_name)
|
||||
assert admin_provider_data is not None
|
||||
admin_providers = _get_all_providers_admin(admin_user)
|
||||
admin_default = _find_default_provider(admin_providers)
|
||||
assert admin_default is not None
|
||||
_validate_provider_data(
|
||||
admin_provider_data,
|
||||
admin_default,
|
||||
expected_name=provider_2_name,
|
||||
expected_provider=LlmProviderNames.OPENAI,
|
||||
expected_default_model=shared_model_name,
|
||||
@@ -1394,17 +1215,11 @@ def test_multiple_providers_default_switching(reset: None) -> None: # noqa: ARG
|
||||
)
|
||||
|
||||
# Validate via basic endpoint (basic_user)
|
||||
basic_data = _get_providers_basic(basic_user)
|
||||
assert basic_data is not None
|
||||
providers, text_default, vision_default = _unpack_data(basic_data)
|
||||
_validate_default_model(
|
||||
text_default, provider_id=provider_2["id"], model_name=shared_model_name
|
||||
)
|
||||
_validate_default_model(vision_default) # None
|
||||
basic_provider_data = _get_provider_by_name(providers, provider_2_name)
|
||||
assert basic_provider_data is not None
|
||||
basic_providers = _get_all_providers_basic(basic_user)
|
||||
basic_default = _find_default_provider(basic_providers)
|
||||
assert basic_default is not None
|
||||
_validate_provider_data(
|
||||
basic_provider_data,
|
||||
basic_default,
|
||||
expected_name=provider_2_name,
|
||||
expected_provider=LlmProviderNames.OPENAI,
|
||||
expected_default_model=shared_model_name,
|
||||
@@ -1414,17 +1229,11 @@ def test_multiple_providers_default_switching(reset: None) -> None: # noqa: ARG
|
||||
)
|
||||
|
||||
# Validate via basic endpoint (admin_user)
|
||||
admin_basic_data = _get_providers_basic(admin_user)
|
||||
assert admin_basic_data is not None
|
||||
providers, text_default, vision_default = _unpack_data(admin_basic_data)
|
||||
_validate_default_model(
|
||||
text_default, provider_id=provider_2["id"], model_name=shared_model_name
|
||||
)
|
||||
_validate_default_model(vision_default) # None
|
||||
admin_basic_provider_data = _get_provider_by_name(providers, provider_2_name)
|
||||
assert admin_basic_provider_data is not None
|
||||
admin_basic_providers = _get_all_providers_basic(admin_user)
|
||||
admin_basic_default = _find_default_provider(admin_basic_providers)
|
||||
assert admin_basic_default is not None
|
||||
_validate_provider_data(
|
||||
admin_basic_provider_data,
|
||||
admin_basic_default,
|
||||
expected_name=provider_2_name,
|
||||
expected_provider=LlmProviderNames.OPENAI,
|
||||
expected_default_model=shared_model_name,
|
||||
@@ -1434,10 +1243,12 @@ def test_multiple_providers_default_switching(reset: None) -> None: # noqa: ARG
|
||||
)
|
||||
|
||||
# Verify provider 1 is no longer the default and has correct data
|
||||
admin_provider_1 = _get_provider_by_name(providers, provider_1_name)
|
||||
assert admin_provider_1 is not None
|
||||
provider_1_admin = next(
|
||||
(p for p in admin_providers if p["name"] == provider_1_name), None
|
||||
)
|
||||
assert provider_1_admin is not None
|
||||
_validate_provider_data(
|
||||
admin_provider_1,
|
||||
provider_1_admin,
|
||||
expected_name=provider_1_name,
|
||||
expected_provider=LlmProviderNames.OPENAI,
|
||||
expected_default_model=shared_model_name,
|
||||
@@ -1447,10 +1258,12 @@ def test_multiple_providers_default_switching(reset: None) -> None: # noqa: ARG
|
||||
expected_is_public=True,
|
||||
)
|
||||
|
||||
basic_provider_1 = _get_provider_by_name(providers, provider_1_name)
|
||||
assert basic_provider_1 is not None
|
||||
provider_1_basic = next(
|
||||
(p for p in basic_providers if p["name"] == provider_1_name), None
|
||||
)
|
||||
assert provider_1_basic is not None
|
||||
_validate_provider_data(
|
||||
basic_provider_1,
|
||||
provider_1_basic,
|
||||
expected_name=provider_1_name,
|
||||
expected_provider=LlmProviderNames.OPENAI,
|
||||
expected_default_model=shared_model_name,
|
||||
@@ -1570,7 +1383,7 @@ def test_default_provider_and_vision_provider_selection(
|
||||
provider_2 = create_response_2.json()
|
||||
|
||||
# Step 3: Set provider 1 as the general default provider
|
||||
_set_default_provider(admin_user, provider_1["id"], provider_1_non_vision_model)
|
||||
_set_default_provider(admin_user, provider_1["id"])
|
||||
|
||||
# Step 4: Set provider 2 with a specific vision model as the default vision provider
|
||||
_set_default_vision_provider(
|
||||
@@ -1578,22 +1391,10 @@ def test_default_provider_and_vision_provider_selection(
|
||||
)
|
||||
|
||||
# Step 5: Verify via admin endpoint
|
||||
admin_data = _get_providers_admin(admin_user)
|
||||
assert admin_data is not None
|
||||
admin_providers = _get_all_providers_admin(admin_user)
|
||||
|
||||
# Find and validate the default provider (provider 1)
|
||||
providers, text_default, vision_default = _unpack_data(admin_data)
|
||||
_validate_default_model(
|
||||
text_default,
|
||||
provider_id=provider_1["id"],
|
||||
model_name=provider_1_non_vision_model,
|
||||
)
|
||||
_validate_default_model(
|
||||
vision_default,
|
||||
provider_id=provider_2["id"],
|
||||
model_name=provider_2_vision_model_1,
|
||||
)
|
||||
admin_default = _get_provider_by_name(providers, provider_1_name)
|
||||
admin_default = _find_default_provider(admin_providers)
|
||||
assert admin_default is not None
|
||||
_validate_provider_data(
|
||||
admin_default,
|
||||
@@ -1608,7 +1409,7 @@ def test_default_provider_and_vision_provider_selection(
|
||||
)
|
||||
|
||||
# Find and validate the default vision provider (provider 2)
|
||||
admin_vision_default = _get_provider_by_name(providers, provider_2_name)
|
||||
admin_vision_default = _find_default_vision_provider(admin_providers)
|
||||
assert admin_vision_default is not None
|
||||
_validate_provider_data(
|
||||
admin_vision_default,
|
||||
@@ -1624,21 +1425,10 @@ def test_default_provider_and_vision_provider_selection(
|
||||
)
|
||||
|
||||
# Step 6: Verify via basic endpoint (basic_user)
|
||||
basic_providers = _get_all_providers_basic(basic_user)
|
||||
|
||||
# Find and validate the default provider (provider 1)
|
||||
basic_data = _get_providers_basic(basic_user)
|
||||
assert basic_data is not None
|
||||
providers, text_default, vision_default = _unpack_data(basic_data)
|
||||
_validate_default_model(
|
||||
text_default,
|
||||
provider_id=provider_1["id"],
|
||||
model_name=provider_1_non_vision_model,
|
||||
)
|
||||
_validate_default_model(
|
||||
vision_default,
|
||||
provider_id=provider_2["id"],
|
||||
model_name=provider_2_vision_model_1,
|
||||
)
|
||||
basic_default = _get_provider_by_name(providers, provider_1_name)
|
||||
basic_default = _find_default_provider(basic_providers)
|
||||
assert basic_default is not None
|
||||
_validate_provider_data(
|
||||
basic_default,
|
||||
@@ -1652,7 +1442,7 @@ def test_default_provider_and_vision_provider_selection(
|
||||
)
|
||||
|
||||
# Find and validate the default vision provider (provider 2)
|
||||
basic_vision_default = _get_provider_by_name(providers, provider_2_name)
|
||||
basic_vision_default = _find_default_vision_provider(basic_providers)
|
||||
assert basic_vision_default is not None
|
||||
_validate_provider_data(
|
||||
basic_vision_default,
|
||||
@@ -1667,20 +1457,9 @@ def test_default_provider_and_vision_provider_selection(
|
||||
)
|
||||
|
||||
# Step 7: Verify via basic endpoint (admin_user sees same as basic_user)
|
||||
admin_basic_data = _get_providers_basic(admin_user)
|
||||
assert admin_basic_data is not None
|
||||
providers, text_default, vision_default = _unpack_data(admin_basic_data)
|
||||
_validate_default_model(
|
||||
text_default,
|
||||
provider_id=provider_1["id"],
|
||||
model_name=provider_1_non_vision_model,
|
||||
)
|
||||
_validate_default_model(
|
||||
vision_default,
|
||||
provider_id=provider_2["id"],
|
||||
model_name=provider_2_vision_model_1,
|
||||
)
|
||||
admin_basic_default = _get_provider_by_name(providers, provider_1_name)
|
||||
admin_basic_providers = _get_all_providers_basic(admin_user)
|
||||
|
||||
admin_basic_default = _find_default_provider(admin_basic_providers)
|
||||
assert admin_basic_default is not None
|
||||
_validate_provider_data(
|
||||
admin_basic_default,
|
||||
@@ -1693,7 +1472,7 @@ def test_default_provider_and_vision_provider_selection(
|
||||
expected_is_default_vision=None,
|
||||
)
|
||||
|
||||
admin_basic_vision_default = _get_provider_by_name(providers, provider_2_name)
|
||||
admin_basic_vision_default = _find_default_vision_provider(admin_basic_providers)
|
||||
assert admin_basic_vision_default is not None
|
||||
_validate_provider_data(
|
||||
admin_basic_vision_default,
|
||||
@@ -1767,17 +1546,10 @@ def test_default_provider_is_not_default_vision_provider(
|
||||
created_provider = create_response.json()
|
||||
|
||||
# Step 2: Set it as the default provider
|
||||
_set_default_provider(admin_user, created_provider["id"], "gpt-4")
|
||||
_set_default_provider(admin_user, created_provider["id"])
|
||||
|
||||
# Step 3 & 4: Verify via admin endpoint
|
||||
admin_data = _get_providers_admin(admin_user)
|
||||
assert admin_data is not None
|
||||
providers, text_default, vision_default = _unpack_data(admin_data)
|
||||
_validate_default_model(
|
||||
text_default, provider_id=created_provider["id"], model_name="gpt-4"
|
||||
)
|
||||
_validate_default_model(vision_default) # None
|
||||
admin_provider_data = _get_provider_by_name(providers, provider_name)
|
||||
admin_provider_data = _get_provider_by_name_admin(admin_user, provider_name)
|
||||
assert admin_provider_data is not None
|
||||
|
||||
# Verify it IS the default provider
|
||||
@@ -1812,14 +1584,7 @@ def test_default_provider_is_not_default_vision_provider(
|
||||
)
|
||||
|
||||
# Also verify via basic endpoint
|
||||
basic_data = _get_providers_basic(admin_user)
|
||||
assert basic_data is not None
|
||||
providers, text_default, vision_default = _unpack_data(basic_data)
|
||||
_validate_default_model(
|
||||
text_default, provider_id=created_provider["id"], model_name="gpt-4"
|
||||
)
|
||||
_validate_default_model(vision_default) # None
|
||||
basic_provider_data = _get_provider_by_name(providers, provider_name)
|
||||
basic_provider_data = _get_provider_by_name_basic(admin_user, provider_name)
|
||||
assert basic_provider_data is not None
|
||||
|
||||
assert (
|
||||
@@ -1967,7 +1732,7 @@ def test_all_three_provider_types_no_mixup(reset: None) -> None: # noqa: ARG001
|
||||
regular_provider = create_regular_response.json()
|
||||
|
||||
# Set as default provider
|
||||
_set_default_provider(admin_user, regular_provider["id"], "gpt-4")
|
||||
_set_default_provider(admin_user, regular_provider["id"])
|
||||
|
||||
# Step 2: Create vision LLM provider
|
||||
create_vision_response = requests.put(
|
||||
@@ -2004,52 +1769,37 @@ def test_all_three_provider_types_no_mixup(reset: None) -> None: # noqa: ARG001
|
||||
# Step 4: Verify all three types are correctly tracked
|
||||
|
||||
# Get all LLM providers (via admin endpoint)
|
||||
admin_data = _get_providers_admin(admin_user)
|
||||
assert admin_data is not None
|
||||
providers, text_default, vision_default = _unpack_data(admin_data)
|
||||
_validate_default_model(
|
||||
text_default, provider_id=regular_provider["id"], model_name="gpt-4"
|
||||
)
|
||||
_validate_default_model(
|
||||
vision_default,
|
||||
provider_id=vision_provider["id"],
|
||||
model_name="gpt-4-vision-preview",
|
||||
)
|
||||
_validate_default_model(
|
||||
vision_default, vision_provider["id"], "gpt-4-vision-preview"
|
||||
)
|
||||
_get_provider_by_name(providers, regular_provider_name)
|
||||
admin_providers = _get_all_providers_admin(admin_user)
|
||||
|
||||
# Get all image generation configs
|
||||
image_gen_configs = _get_all_image_gen_configs(admin_user)
|
||||
|
||||
# Verify the regular provider is the default provider
|
||||
admin_regular_provider_data = _get_provider_by_name(
|
||||
providers, regular_provider_name
|
||||
regular_provider_data = next(
|
||||
(p for p in admin_providers if p["name"] == regular_provider_name), None
|
||||
)
|
||||
assert admin_regular_provider_data is not None
|
||||
_validate_provider_data(
|
||||
admin_regular_provider_data,
|
||||
expected_name=regular_provider_name,
|
||||
expected_provider=LlmProviderNames.OPENAI,
|
||||
expected_model_names=[c.name for c in regular_model_configs],
|
||||
expected_visible={c.name: True for c in regular_model_configs},
|
||||
expected_default_model="gpt-4",
|
||||
expected_is_default=True,
|
||||
)
|
||||
admin_vision_provider_data = _get_provider_by_name(providers, vision_provider_name)
|
||||
assert admin_vision_provider_data is not None
|
||||
_validate_provider_data(
|
||||
admin_vision_provider_data,
|
||||
expected_name=vision_provider_name,
|
||||
expected_provider=LlmProviderNames.OPENAI,
|
||||
expected_model_names=[c.name for c in vision_model_configs],
|
||||
expected_visible={c.name: True for c in vision_model_configs},
|
||||
expected_default_model="gpt-4-vision-preview",
|
||||
expected_is_default=False,
|
||||
expected_default_vision_model="gpt-4-vision-preview",
|
||||
expected_is_default_vision=True,
|
||||
assert regular_provider_data is not None, "Regular provider not found"
|
||||
assert (
|
||||
regular_provider_data["is_default_provider"] is True
|
||||
), "Regular provider should be the default provider"
|
||||
assert (
|
||||
regular_provider_data.get("is_default_vision_provider") is not True
|
||||
), "Regular provider should NOT be the default vision provider"
|
||||
|
||||
# Verify the vision provider is the default vision provider
|
||||
vision_provider_data = next(
|
||||
(p for p in admin_providers if p["name"] == vision_provider_name), None
|
||||
)
|
||||
assert vision_provider_data is not None, "Vision provider not found"
|
||||
assert (
|
||||
vision_provider_data.get("is_default_provider") is not True
|
||||
), "Vision provider should NOT be the default provider"
|
||||
assert (
|
||||
vision_provider_data["is_default_vision_provider"] is True
|
||||
), "Vision provider should be the default vision provider"
|
||||
assert (
|
||||
vision_provider_data["default_vision_model"] == "gpt-4-vision-preview"
|
||||
), "Vision provider should have correct default vision model"
|
||||
|
||||
# Verify the image gen config is the default image generation config
|
||||
image_gen_config_data = next(
|
||||
@@ -2069,53 +1819,97 @@ def test_all_three_provider_types_no_mixup(reset: None) -> None: # noqa: ARG001
|
||||
), "Image gen config should have correct model name"
|
||||
|
||||
# Step 5: Verify no mixup - image gen providers don't appear in LLM provider lists
|
||||
# Image gen provider should not appear in the list
|
||||
assert image_gen_provider_id not in [p["name"] for p in providers]
|
||||
|
||||
# The image gen config creates an LLM provider with name "Image Gen - {image_provider_id}"
|
||||
# This should NOT be returned by the regular LLM provider endpoints
|
||||
[p["name"] for p in admin_providers]
|
||||
image_gen_llm_provider_name = f"Image Gen - {image_gen_provider_id}"
|
||||
|
||||
# Note: The image gen provider IS an LLM provider internally, so it may appear in the list
|
||||
# But it should NOT be marked as default provider or default vision provider
|
||||
image_gen_llm_provider = next(
|
||||
(p for p in admin_providers if p["name"] == image_gen_llm_provider_name), None
|
||||
)
|
||||
if image_gen_llm_provider:
|
||||
# If it appears, verify it's not marked as default for either type
|
||||
assert (
|
||||
image_gen_llm_provider.get("is_default_provider") is not True
|
||||
), "Image gen's internal LLM provider should NOT be the default provider"
|
||||
assert (
|
||||
image_gen_llm_provider.get("is_default_vision_provider") is not True
|
||||
), "Image gen's internal LLM provider should NOT be the default vision provider"
|
||||
|
||||
# Step 6: Verify via basic endpoint (non-admin user)
|
||||
basic_data = _get_providers_basic(basic_user)
|
||||
assert basic_data is not None
|
||||
providers, text_default, vision_default = _unpack_data(basic_data)
|
||||
_validate_default_model(
|
||||
text_default, provider_id=regular_provider["id"], model_name="gpt-4"
|
||||
basic_providers = _get_all_providers_basic(basic_user)
|
||||
|
||||
# Verify regular provider is default for basic user
|
||||
basic_regular = next(
|
||||
(p for p in basic_providers if p["name"] == regular_provider_name), None
|
||||
)
|
||||
_validate_default_model(
|
||||
vision_default,
|
||||
provider_id=vision_provider["id"],
|
||||
model_name="gpt-4-vision-preview",
|
||||
)
|
||||
_validate_default_model(
|
||||
vision_default, vision_provider["id"], "gpt-4-vision-preview"
|
||||
)
|
||||
basic_provider_data = _get_provider_by_name(providers, regular_provider_name)
|
||||
assert basic_provider_data is not None
|
||||
_validate_provider_data(
|
||||
basic_provider_data,
|
||||
expected_name=regular_provider_name,
|
||||
expected_provider=LlmProviderNames.OPENAI,
|
||||
expected_model_names=[c.name for c in regular_model_configs],
|
||||
expected_visible={c.name: True for c in regular_model_configs},
|
||||
expected_is_default=True,
|
||||
expected_default_model="gpt-4",
|
||||
)
|
||||
basic_vision_provider_data = _get_provider_by_name(providers, vision_provider_name)
|
||||
assert basic_vision_provider_data is not None
|
||||
_validate_provider_data(
|
||||
basic_vision_provider_data,
|
||||
expected_name=vision_provider_name,
|
||||
expected_provider=LlmProviderNames.OPENAI,
|
||||
expected_model_names=[c.name for c in vision_model_configs],
|
||||
expected_visible={c.name: True for c in vision_model_configs},
|
||||
expected_is_default=False,
|
||||
expected_default_model="gpt-4-vision-preview",
|
||||
expected_default_vision_model="gpt-4-vision-preview",
|
||||
expected_is_default_vision=True,
|
||||
assert basic_regular is not None, "Regular provider not visible to basic user"
|
||||
assert (
|
||||
basic_regular["is_default_provider"] is True
|
||||
), "Regular provider should be default for basic user"
|
||||
|
||||
# Verify vision provider is default vision for basic user
|
||||
basic_vision = next(
|
||||
(p for p in basic_providers if p["name"] == vision_provider_name), None
|
||||
)
|
||||
assert basic_vision is not None, "Vision provider not visible to basic user"
|
||||
assert (
|
||||
basic_vision["is_default_vision_provider"] is True
|
||||
), "Vision provider should be default vision for basic user"
|
||||
|
||||
# Step 7: Verify the counts are as expected
|
||||
# We should have at least 2 user-created providers (setup_postgres may add more)
|
||||
assert len(providers) >= 2
|
||||
assert len(image_gen_configs) == 1
|
||||
# We should have at least 2 user-created providers plus the image gen internal provider
|
||||
user_created_providers = [
|
||||
p
|
||||
for p in admin_providers
|
||||
if p["name"] in [regular_provider_name, vision_provider_name]
|
||||
]
|
||||
assert (
|
||||
len(user_created_providers) == 2
|
||||
), f"Expected 2 user-created providers, got {len(user_created_providers)}"
|
||||
|
||||
# We should have exactly 1 image gen config
|
||||
assert (
|
||||
len(
|
||||
[
|
||||
c
|
||||
for c in image_gen_configs
|
||||
if c["image_provider_id"] == image_gen_provider_id
|
||||
]
|
||||
)
|
||||
== 1
|
||||
), "Expected exactly 1 image gen config with our ID"
|
||||
|
||||
# Verify that our explicitly created providers are tracked correctly:
|
||||
# - Only ONE provider has is_default_provider=True
|
||||
default_providers = [
|
||||
p for p in admin_providers if p.get("is_default_provider") is True
|
||||
]
|
||||
assert (
|
||||
len(default_providers) == 1
|
||||
), f"Expected exactly 1 default provider, got {len(default_providers)}"
|
||||
assert default_providers[0]["name"] == regular_provider_name
|
||||
|
||||
# - Only ONE provider has is_default_vision_provider=True
|
||||
default_vision_providers = [
|
||||
p for p in admin_providers if p.get("is_default_vision_provider") is True
|
||||
]
|
||||
assert (
|
||||
len(default_vision_providers) == 1
|
||||
), f"Expected exactly 1 default vision provider, got {len(default_vision_providers)}"
|
||||
assert default_vision_providers[0]["name"] == vision_provider_name
|
||||
|
||||
# - Only ONE image gen config has is_default=True
|
||||
default_image_gen_configs = [
|
||||
c for c in image_gen_configs if c.get("is_default") is True
|
||||
]
|
||||
assert (
|
||||
len(default_image_gen_configs) == 1
|
||||
), f"Expected exactly 1 default image gen config, got {len(default_image_gen_configs)}"
|
||||
assert default_image_gen_configs[0]["image_provider_id"] == image_gen_provider_id
|
||||
|
||||
# Clean up: Delete the image gen config (to clean up the internal LLM provider)
|
||||
_delete_image_gen_config(admin_user, image_gen_provider_id)
|
||||
|
||||
@@ -6,14 +6,11 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from onyx.context.search.enums import RecencyBiasSetting
|
||||
from onyx.db.engine.sql_engine import get_session_with_current_tenant
|
||||
from onyx.db.enums import LLMModelFlowType
|
||||
from onyx.db.llm import can_user_access_llm_provider
|
||||
from onyx.db.llm import fetch_user_group_ids
|
||||
from onyx.db.models import LLMModelFlow
|
||||
from onyx.db.models import LLMProvider as LLMProviderModel
|
||||
from onyx.db.models import LLMProvider__Persona
|
||||
from onyx.db.models import LLMProvider__UserGroup
|
||||
from onyx.db.models import ModelConfiguration
|
||||
from onyx.db.models import Persona
|
||||
from onyx.db.models import User
|
||||
from onyx.db.models import User__UserGroup
|
||||
@@ -270,37 +267,6 @@ def test_get_llm_for_persona_falls_back_when_access_denied(
|
||||
provider_name=restricted_provider.name,
|
||||
)
|
||||
|
||||
# Set up ModelConfiguration + LLMModelFlow so get_default_llm() can
|
||||
# resolve the default provider when the fallback path is triggered.
|
||||
# First, clear any existing CHAT defaults (setup_postgres may have created one)
|
||||
existing_defaults = (
|
||||
db_session.query(LLMModelFlow)
|
||||
.filter(
|
||||
LLMModelFlow.llm_model_flow_type == LLMModelFlowType.CHAT,
|
||||
LLMModelFlow.is_default == True, # noqa: E712
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for existing in existing_defaults:
|
||||
existing.is_default = False
|
||||
db_session.flush()
|
||||
|
||||
default_model_config = ModelConfiguration(
|
||||
llm_provider_id=default_provider.id,
|
||||
name=default_provider.default_model_name,
|
||||
is_visible=True,
|
||||
)
|
||||
db_session.add(default_model_config)
|
||||
db_session.flush()
|
||||
db_session.add(
|
||||
LLMModelFlow(
|
||||
model_configuration_id=default_model_config.id,
|
||||
llm_model_flow_type=LLMModelFlowType.CHAT,
|
||||
is_default=True,
|
||||
)
|
||||
)
|
||||
db_session.flush()
|
||||
|
||||
access_group = UserGroup(name="persona-group")
|
||||
db_session.add(access_group)
|
||||
db_session.flush()
|
||||
@@ -378,7 +344,7 @@ def test_list_llm_provider_basics_excludes_non_public_unrestricted(
|
||||
headers=basic_user.headers,
|
||||
)
|
||||
assert response.status_code == 200
|
||||
providers = response.json()["providers"]
|
||||
providers = response.json()
|
||||
provider_names = [p["name"] for p in providers]
|
||||
|
||||
# Public provider should be visible
|
||||
@@ -393,7 +359,7 @@ def test_list_llm_provider_basics_excludes_non_public_unrestricted(
|
||||
headers=admin_user.headers,
|
||||
)
|
||||
assert admin_response.status_code == 200
|
||||
admin_providers = admin_response.json()["providers"]
|
||||
admin_providers = admin_response.json()
|
||||
admin_provider_names = [p["name"] for p in admin_providers]
|
||||
|
||||
assert public_provider.name in admin_provider_names
|
||||
|
||||
@@ -1,322 +0,0 @@
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from pydantic import BaseModel
|
||||
from pydantic import ConfigDict
|
||||
|
||||
from onyx.configs import app_configs
|
||||
from onyx.configs.constants import DocumentSource
|
||||
from onyx.tools.constants import SEARCH_TOOL_ID
|
||||
from tests.integration.common_utils.constants import API_SERVER_URL
|
||||
from tests.integration.common_utils.managers.cc_pair import CCPairManager
|
||||
from tests.integration.common_utils.managers.chat import ChatSessionManager
|
||||
from tests.integration.common_utils.managers.tool import ToolManager
|
||||
from tests.integration.common_utils.test_models import DATestUser
|
||||
from tests.integration.common_utils.test_models import ToolName
|
||||
|
||||
|
||||
_ENV_PROVIDER = "NIGHTLY_LLM_PROVIDER"
|
||||
_ENV_MODELS = "NIGHTLY_LLM_MODELS"
|
||||
_ENV_API_KEY = "NIGHTLY_LLM_API_KEY"
|
||||
_ENV_API_BASE = "NIGHTLY_LLM_API_BASE"
|
||||
_ENV_CUSTOM_CONFIG_JSON = "NIGHTLY_LLM_CUSTOM_CONFIG_JSON"
|
||||
_ENV_STRICT = "NIGHTLY_LLM_STRICT"
|
||||
|
||||
|
||||
class NightlyProviderConfig(BaseModel):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
|
||||
provider: str
|
||||
model_names: list[str]
|
||||
api_key: str | None
|
||||
api_base: str | None
|
||||
custom_config: dict[str, str] | None
|
||||
strict: bool
|
||||
|
||||
|
||||
def _env_true(env_var: str, default: bool = False) -> bool:
|
||||
value = os.environ.get(env_var)
|
||||
if value is None:
|
||||
return default
|
||||
return value.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _split_csv_env(env_var: str) -> list[str]:
|
||||
return [
|
||||
part.strip() for part in os.environ.get(env_var, "").split(",") if part.strip()
|
||||
]
|
||||
|
||||
|
||||
def _load_provider_config() -> NightlyProviderConfig:
|
||||
provider = os.environ.get(_ENV_PROVIDER, "").strip().lower()
|
||||
model_names = _split_csv_env(_ENV_MODELS)
|
||||
api_key = os.environ.get(_ENV_API_KEY) or None
|
||||
api_base = os.environ.get(_ENV_API_BASE) or None
|
||||
strict = _env_true(_ENV_STRICT, default=False)
|
||||
|
||||
custom_config: dict[str, str] | None = None
|
||||
custom_config_json = os.environ.get(_ENV_CUSTOM_CONFIG_JSON, "").strip()
|
||||
if custom_config_json:
|
||||
parsed = json.loads(custom_config_json)
|
||||
if not isinstance(parsed, dict):
|
||||
raise ValueError(f"{_ENV_CUSTOM_CONFIG_JSON} must be a JSON object")
|
||||
custom_config = {str(key): str(value) for key, value in parsed.items()}
|
||||
|
||||
if provider == "ollama_chat" and api_key and not custom_config:
|
||||
custom_config = {"OLLAMA_API_KEY": api_key}
|
||||
|
||||
return NightlyProviderConfig(
|
||||
provider=provider,
|
||||
model_names=model_names,
|
||||
api_key=api_key,
|
||||
api_base=api_base,
|
||||
custom_config=custom_config,
|
||||
strict=strict,
|
||||
)
|
||||
|
||||
|
||||
def _skip_or_fail(strict: bool, message: str) -> None:
|
||||
if strict:
|
||||
pytest.fail(message)
|
||||
pytest.skip(message)
|
||||
|
||||
|
||||
def _validate_provider_config(config: NightlyProviderConfig) -> None:
|
||||
if not config.provider:
|
||||
_skip_or_fail(strict=config.strict, message=f"{_ENV_PROVIDER} must be set")
|
||||
|
||||
if not config.model_names:
|
||||
_skip_or_fail(
|
||||
strict=config.strict,
|
||||
message=f"{_ENV_MODELS} must include at least one model",
|
||||
)
|
||||
|
||||
if config.provider != "ollama_chat" and not config.api_key:
|
||||
_skip_or_fail(
|
||||
strict=config.strict,
|
||||
message=(f"{_ENV_API_KEY} is required for provider '{config.provider}'"),
|
||||
)
|
||||
|
||||
if config.provider == "ollama_chat" and not (
|
||||
config.api_base or _default_api_base_for_provider(config.provider)
|
||||
):
|
||||
_skip_or_fail(
|
||||
strict=config.strict,
|
||||
message=(f"{_ENV_API_BASE} is required for provider '{config.provider}'"),
|
||||
)
|
||||
|
||||
|
||||
def _assert_integration_mode_enabled() -> None:
|
||||
assert (
|
||||
app_configs.INTEGRATION_TESTS_MODE is True
|
||||
), "Integration tests require INTEGRATION_TESTS_MODE=true."
|
||||
|
||||
|
||||
def _seed_connector_for_search_tool(admin_user: DATestUser) -> None:
|
||||
# SearchTool is only exposed when at least one non-default connector exists.
|
||||
CCPairManager.create_from_scratch(
|
||||
source=DocumentSource.INGESTION_API,
|
||||
user_performing_action=admin_user,
|
||||
)
|
||||
|
||||
|
||||
def _get_internal_search_tool_id(admin_user: DATestUser) -> int:
|
||||
tools = ToolManager.list_tools(user_performing_action=admin_user)
|
||||
for tool in tools:
|
||||
if tool.in_code_tool_id == SEARCH_TOOL_ID:
|
||||
return tool.id
|
||||
raise AssertionError("SearchTool must exist for this test")
|
||||
|
||||
|
||||
def _default_api_base_for_provider(provider: str) -> str | None:
|
||||
if provider == "openrouter":
|
||||
return "https://openrouter.ai/api/v1"
|
||||
if provider == "ollama_chat":
|
||||
# host.docker.internal works when tests are running inside the integration test container.
|
||||
return "http://host.docker.internal:11434"
|
||||
return None
|
||||
|
||||
|
||||
def _create_provider_payload(
|
||||
provider: str,
|
||||
provider_name: str,
|
||||
model_name: str,
|
||||
api_key: str | None,
|
||||
api_base: str | None,
|
||||
custom_config: dict[str, str] | None,
|
||||
) -> dict:
|
||||
return {
|
||||
"name": provider_name,
|
||||
"provider": provider,
|
||||
"api_key": api_key,
|
||||
"api_base": api_base,
|
||||
"custom_config": custom_config,
|
||||
"default_model_name": model_name,
|
||||
"is_public": True,
|
||||
"groups": [],
|
||||
"personas": [],
|
||||
"model_configurations": [{"name": model_name, "is_visible": True}],
|
||||
"api_key_changed": bool(api_key),
|
||||
"custom_config_changed": bool(custom_config),
|
||||
}
|
||||
|
||||
|
||||
def _ensure_provider_is_default(provider_id: int, admin_user: DATestUser) -> None:
|
||||
list_response = requests.get(
|
||||
f"{API_SERVER_URL}/admin/llm/provider",
|
||||
headers=admin_user.headers,
|
||||
)
|
||||
list_response.raise_for_status()
|
||||
providers = list_response.json()
|
||||
|
||||
current_default = next(
|
||||
(provider for provider in providers if provider.get("is_default_provider")),
|
||||
None,
|
||||
)
|
||||
assert (
|
||||
current_default is not None
|
||||
), "Expected a default provider after setting provider as default"
|
||||
assert (
|
||||
current_default["id"] == provider_id
|
||||
), f"Expected provider {provider_id} to be default, found {current_default['id']}"
|
||||
|
||||
|
||||
def _run_chat_assertions(
|
||||
admin_user: DATestUser,
|
||||
search_tool_id: int,
|
||||
provider: str,
|
||||
model_name: str,
|
||||
) -> None:
|
||||
last_error: str | None = None
|
||||
# Retry once to reduce transient nightly flakes due provider-side blips.
|
||||
for attempt in range(1, 3):
|
||||
chat_session = ChatSessionManager.create(user_performing_action=admin_user)
|
||||
|
||||
response = ChatSessionManager.send_message(
|
||||
chat_session_id=chat_session.id,
|
||||
message=(
|
||||
"Use internal_search to search for 'nightly-provider-regression-sentinel', "
|
||||
"then summarize the result in one short sentence."
|
||||
),
|
||||
user_performing_action=admin_user,
|
||||
forced_tool_ids=[search_tool_id],
|
||||
)
|
||||
|
||||
if response.error is None:
|
||||
used_internal_search = any(
|
||||
used_tool.tool_name == ToolName.INTERNAL_SEARCH
|
||||
for used_tool in response.used_tools
|
||||
)
|
||||
debug_has_internal_search = any(
|
||||
debug_tool_call.tool_name == "internal_search"
|
||||
for debug_tool_call in response.tool_call_debug
|
||||
)
|
||||
has_answer = bool(response.full_message.strip())
|
||||
|
||||
if used_internal_search and debug_has_internal_search and has_answer:
|
||||
return
|
||||
|
||||
last_error = (
|
||||
f"attempt={attempt} provider={provider} model={model_name} "
|
||||
f"used_internal_search={used_internal_search} "
|
||||
f"debug_internal_search={debug_has_internal_search} "
|
||||
f"has_answer={has_answer} "
|
||||
f"tool_call_debug={response.tool_call_debug}"
|
||||
)
|
||||
else:
|
||||
last_error = (
|
||||
f"attempt={attempt} provider={provider} model={model_name} "
|
||||
f"stream_error={response.error.error}"
|
||||
)
|
||||
|
||||
time.sleep(attempt)
|
||||
|
||||
pytest.fail(f"Chat/tool-call assertions failed: {last_error}")
|
||||
|
||||
|
||||
def _create_and_test_provider_for_model(
|
||||
admin_user: DATestUser,
|
||||
config: NightlyProviderConfig,
|
||||
model_name: str,
|
||||
search_tool_id: int,
|
||||
) -> None:
|
||||
provider_name = f"nightly-{config.provider}-{uuid4().hex[:12]}"
|
||||
resolved_api_base = config.api_base or _default_api_base_for_provider(
|
||||
config.provider
|
||||
)
|
||||
|
||||
provider_payload = _create_provider_payload(
|
||||
provider=config.provider,
|
||||
provider_name=provider_name,
|
||||
model_name=model_name,
|
||||
api_key=config.api_key,
|
||||
api_base=resolved_api_base,
|
||||
custom_config=config.custom_config,
|
||||
)
|
||||
|
||||
test_response = requests.post(
|
||||
f"{API_SERVER_URL}/admin/llm/test",
|
||||
headers=admin_user.headers,
|
||||
json=provider_payload,
|
||||
)
|
||||
assert test_response.status_code == 200, (
|
||||
f"Provider test endpoint failed for provider={config.provider} "
|
||||
f"model={model_name}: {test_response.status_code} {test_response.text}"
|
||||
)
|
||||
|
||||
create_response = requests.put(
|
||||
f"{API_SERVER_URL}/admin/llm/provider?is_creation=true",
|
||||
headers=admin_user.headers,
|
||||
json=provider_payload,
|
||||
)
|
||||
assert create_response.status_code == 200, (
|
||||
f"Provider creation failed for provider={config.provider} "
|
||||
f"model={model_name}: {create_response.status_code} {create_response.text}"
|
||||
)
|
||||
provider_id = create_response.json()["id"]
|
||||
|
||||
try:
|
||||
set_default_response = requests.post(
|
||||
f"{API_SERVER_URL}/admin/llm/provider/{provider_id}/default",
|
||||
headers=admin_user.headers,
|
||||
)
|
||||
assert set_default_response.status_code == 200, (
|
||||
f"Setting default provider failed for provider={config.provider} "
|
||||
f"model={model_name}: {set_default_response.status_code} "
|
||||
f"{set_default_response.text}"
|
||||
)
|
||||
|
||||
_ensure_provider_is_default(provider_id=provider_id, admin_user=admin_user)
|
||||
_run_chat_assertions(
|
||||
admin_user=admin_user,
|
||||
search_tool_id=search_tool_id,
|
||||
provider=config.provider,
|
||||
model_name=model_name,
|
||||
)
|
||||
finally:
|
||||
requests.delete(
|
||||
f"{API_SERVER_URL}/admin/llm/provider/{provider_id}",
|
||||
headers=admin_user.headers,
|
||||
)
|
||||
|
||||
|
||||
def test_nightly_provider_chat_workflow(admin_user: DATestUser) -> None:
|
||||
"""Nightly regression test for provider setup + default selection + chat tool calls."""
|
||||
_assert_integration_mode_enabled()
|
||||
config = _load_provider_config()
|
||||
_validate_provider_config(config)
|
||||
|
||||
_seed_connector_for_search_tool(admin_user)
|
||||
search_tool_id = _get_internal_search_tool_id(admin_user)
|
||||
|
||||
for model_name in config.model_names:
|
||||
_create_and_test_provider_for_model(
|
||||
admin_user=admin_user,
|
||||
config=config,
|
||||
model_name=model_name,
|
||||
search_tool_id=search_tool_id,
|
||||
)
|
||||
@@ -6,7 +6,7 @@ the permissions of the curator manipulating connector-credential pairs.
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from onyx_openapi_client.exceptions import ApiException # type: ignore[import-untyped,unused-ignore,import-not-found]
|
||||
from requests.exceptions import HTTPError
|
||||
|
||||
from onyx.db.enums import AccessType
|
||||
from onyx.server.documents.models import DocumentSource
|
||||
@@ -93,9 +93,20 @@ def test_cc_pair_permissions(reset: None) -> None: # noqa: ARG001
|
||||
|
||||
"""Tests for things Curators should not be able to do"""
|
||||
|
||||
# Curators should not be able to create a public cc pair
|
||||
with pytest.raises(HTTPError):
|
||||
CCPairManager.create(
|
||||
connector_id=connector_1.id,
|
||||
credential_id=credential_1.id,
|
||||
name="invalid_cc_pair_1",
|
||||
access_type=AccessType.PUBLIC,
|
||||
groups=[user_group_1.id],
|
||||
user_performing_action=curator,
|
||||
)
|
||||
|
||||
# Curators should not be able to create a cc
|
||||
# pair for a user group they are not a curator of
|
||||
with pytest.raises(ApiException):
|
||||
with pytest.raises(HTTPError):
|
||||
CCPairManager.create(
|
||||
connector_id=connector_1.id,
|
||||
credential_id=credential_1.id,
|
||||
@@ -107,7 +118,7 @@ def test_cc_pair_permissions(reset: None) -> None: # noqa: ARG001
|
||||
|
||||
# Curators should not be able to create a cc
|
||||
# pair without an attached user group
|
||||
with pytest.raises(ApiException):
|
||||
with pytest.raises(HTTPError):
|
||||
CCPairManager.create(
|
||||
connector_id=connector_1.id,
|
||||
credential_id=credential_1.id,
|
||||
@@ -133,7 +144,7 @@ def test_cc_pair_permissions(reset: None) -> None: # noqa: ARG001
|
||||
|
||||
# Curators should not be able to create a cc
|
||||
# pair for a user group that the credential does not belong to
|
||||
with pytest.raises(ApiException):
|
||||
with pytest.raises(HTTPError):
|
||||
CCPairManager.create(
|
||||
connector_id=connector_1.id,
|
||||
credential_id=credential_2.id,
|
||||
@@ -145,16 +156,6 @@ def test_cc_pair_permissions(reset: None) -> None: # noqa: ARG001
|
||||
|
||||
"""Tests for things Curators should be able to do"""
|
||||
|
||||
# Re-create connector since the credential_2 validation error above
|
||||
# triggers connector deletion in the exception handler
|
||||
connector_1 = ConnectorManager.create(
|
||||
name="admin_owned_connector_2",
|
||||
source=DocumentSource.CONFLUENCE,
|
||||
groups=[user_group_1.id],
|
||||
access_type=AccessType.PRIVATE,
|
||||
user_performing_action=admin_user,
|
||||
)
|
||||
|
||||
# Curators should be able to create a private
|
||||
# cc pair for a user group they are a curator of
|
||||
valid_cc_pair = CCPairManager.create(
|
||||
|
||||
@@ -59,7 +59,17 @@ def test_connector_permissions(reset: None) -> None: # noqa: ARG001
|
||||
|
||||
"""Tests for things Curators should not be able to do"""
|
||||
|
||||
# Curators should not be able to create a connector for a
|
||||
# Curators should not be able to create a public connector
|
||||
with pytest.raises(HTTPError):
|
||||
ConnectorManager.create(
|
||||
name="invalid_connector_1",
|
||||
source=DocumentSource.CONFLUENCE,
|
||||
groups=[user_group_1.id],
|
||||
access_type=AccessType.PUBLIC,
|
||||
user_performing_action=curator,
|
||||
)
|
||||
|
||||
# Curators should not be able to create a cc pair for a
|
||||
# user group they are not a curator of
|
||||
with pytest.raises(HTTPError):
|
||||
ConnectorManager.create(
|
||||
@@ -123,12 +133,12 @@ def test_connector_permissions(reset: None) -> None: # noqa: ARG001
|
||||
user_performing_action=curator,
|
||||
)
|
||||
|
||||
# Curators should be able to create a public connector
|
||||
public_connector = ConnectorManager.create(
|
||||
name="curator_public_connector",
|
||||
source=DocumentSource.CONFLUENCE,
|
||||
groups=[user_group_1.id],
|
||||
access_type=AccessType.PUBLIC,
|
||||
user_performing_action=curator,
|
||||
)
|
||||
assert public_connector.id is not None
|
||||
# Test that curator cannot create a public connector
|
||||
with pytest.raises(HTTPError):
|
||||
ConnectorManager.create(
|
||||
name="invalid_connector_4",
|
||||
source=DocumentSource.CONFLUENCE,
|
||||
groups=[user_group_1.id],
|
||||
access_type=AccessType.PUBLIC,
|
||||
user_performing_action=curator,
|
||||
)
|
||||
|
||||
@@ -58,6 +58,16 @@ def test_credential_permissions(reset: None) -> None: # noqa: ARG001
|
||||
|
||||
"""Tests for things Curators should not be able to do"""
|
||||
|
||||
# Curators should not be able to create a public credential
|
||||
with pytest.raises(HTTPError):
|
||||
CredentialManager.create(
|
||||
name="invalid_credential_1",
|
||||
source=DocumentSource.CONFLUENCE,
|
||||
groups=[user_group_1.id],
|
||||
curator_public=True,
|
||||
user_performing_action=curator,
|
||||
)
|
||||
|
||||
# Curators should not be able to create a credential for a user group they are not a curator of
|
||||
with pytest.raises(HTTPError):
|
||||
CredentialManager.create(
|
||||
@@ -103,16 +113,3 @@ def test_credential_permissions(reset: None) -> None: # noqa: ARG001
|
||||
verify_deleted=True,
|
||||
user_performing_action=curator,
|
||||
)
|
||||
|
||||
# Curators should be able to create a public credential
|
||||
public_credential = CredentialManager.create(
|
||||
name="curator_public_credential",
|
||||
source=DocumentSource.CONFLUENCE,
|
||||
groups=[user_group_1.id],
|
||||
curator_public=True,
|
||||
user_performing_action=curator,
|
||||
)
|
||||
CredentialManager.verify(
|
||||
credential=public_credential,
|
||||
user_performing_action=curator,
|
||||
)
|
||||
|
||||
@@ -70,11 +70,10 @@ def test_doc_set_permissions_setup(reset: None) -> None: # noqa: ARG001
|
||||
|
||||
"""Tests for things Curators/Admins should not be able to do"""
|
||||
|
||||
# Test that curator cannot create a non-public document set for the group they don't curate
|
||||
# Test that curator cannot create a document set for the group they don't curate
|
||||
with pytest.raises(HTTPError):
|
||||
DocumentSetManager.create(
|
||||
name="Invalid Document Set 1",
|
||||
is_public=False,
|
||||
groups=[user_group_2.id],
|
||||
cc_pair_ids=[public_cc_pair.id],
|
||||
user_performing_action=curator,
|
||||
|
||||
@@ -6,14 +6,12 @@ from datetime import timedelta
|
||||
from datetime import timezone
|
||||
from io import BytesIO
|
||||
from io import StringIO
|
||||
from uuid import UUID
|
||||
from zipfile import ZipFile
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from ee.onyx.db.usage_export import UsageReportMetadata
|
||||
from onyx.configs.constants import DEFAULT_PERSONA_ID
|
||||
from onyx.db.seeding.chat_history_seeding import seed_chat_history
|
||||
from tests.integration.common_utils.constants import API_SERVER_URL
|
||||
from tests.integration.common_utils.test_models import DATestUser
|
||||
@@ -28,13 +26,7 @@ class TestUsageExportAPI:
|
||||
self, reset: None, admin_user: DATestUser # noqa: ARG002
|
||||
) -> None:
|
||||
# Seed some chat history data for the report
|
||||
seed_chat_history(
|
||||
num_sessions=10,
|
||||
num_messages=4,
|
||||
days=30,
|
||||
user_id=UUID(admin_user.id),
|
||||
persona_id=DEFAULT_PERSONA_ID,
|
||||
)
|
||||
seed_chat_history(num_sessions=10, num_messages=4, days=30)
|
||||
|
||||
# Get initial list of reports
|
||||
initial_response = requests.get(
|
||||
@@ -84,13 +76,7 @@ class TestUsageExportAPI:
|
||||
self, reset: None, admin_user: DATestUser # noqa: ARG002
|
||||
) -> None:
|
||||
# Seed some chat history data
|
||||
seed_chat_history(
|
||||
num_sessions=20,
|
||||
num_messages=4,
|
||||
days=60,
|
||||
user_id=UUID(admin_user.id),
|
||||
persona_id=DEFAULT_PERSONA_ID,
|
||||
)
|
||||
seed_chat_history(num_sessions=20, num_messages=4, days=60)
|
||||
|
||||
# Get initial list of reports
|
||||
initial_response = requests.get(
|
||||
@@ -162,13 +148,7 @@ class TestUsageExportAPI:
|
||||
self, reset: None, admin_user: DATestUser # noqa: ARG002
|
||||
) -> None:
|
||||
# First generate a report to ensure we have at least one
|
||||
seed_chat_history(
|
||||
num_sessions=5,
|
||||
num_messages=4,
|
||||
days=30,
|
||||
user_id=UUID(admin_user.id),
|
||||
persona_id=DEFAULT_PERSONA_ID,
|
||||
)
|
||||
seed_chat_history(num_sessions=5, num_messages=4, days=30)
|
||||
|
||||
# Get initial count
|
||||
initial_response = requests.get(
|
||||
@@ -224,13 +204,7 @@ class TestUsageExportAPI:
|
||||
self, reset: None, admin_user: DATestUser # noqa: ARG002
|
||||
) -> None:
|
||||
# First generate a report
|
||||
seed_chat_history(
|
||||
num_sessions=5,
|
||||
num_messages=4,
|
||||
days=30,
|
||||
user_id=UUID(admin_user.id),
|
||||
persona_id=DEFAULT_PERSONA_ID,
|
||||
)
|
||||
seed_chat_history(num_sessions=5, num_messages=4, days=30)
|
||||
|
||||
# Get initial reports count
|
||||
initial_response = requests.get(
|
||||
@@ -378,13 +352,7 @@ class TestUsageExportAPI:
|
||||
self, reset: None, admin_user: DATestUser # noqa: ARG002
|
||||
) -> None:
|
||||
# Seed some data
|
||||
seed_chat_history(
|
||||
num_sessions=10,
|
||||
num_messages=4,
|
||||
days=30,
|
||||
user_id=UUID(admin_user.id),
|
||||
persona_id=DEFAULT_PERSONA_ID,
|
||||
)
|
||||
seed_chat_history(num_sessions=10, num_messages=4, days=30)
|
||||
|
||||
# Get initial count of reports
|
||||
initial_response = requests.get(
|
||||
|
||||
@@ -25,11 +25,6 @@ def test_add_users_to_group(reset: None) -> None: # noqa: ARG001
|
||||
user_performing_action=admin_user,
|
||||
)
|
||||
|
||||
UserGroupManager.wait_for_sync(
|
||||
user_performing_action=admin_user,
|
||||
user_groups_to_check=[user_group],
|
||||
)
|
||||
|
||||
updated_user_group = UserGroupManager.add_users(
|
||||
user_group=user_group,
|
||||
user_ids=[user_to_add.id],
|
||||
|
||||
@@ -1,168 +0,0 @@
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from onyx.background.celery.tasks.user_file_processing.tasks import (
|
||||
_user_file_project_sync_queued_key,
|
||||
)
|
||||
from onyx.background.celery.tasks.user_file_processing.tasks import (
|
||||
check_for_user_file_project_sync,
|
||||
)
|
||||
from onyx.background.celery.tasks.user_file_processing.tasks import (
|
||||
enqueue_user_file_project_sync_task,
|
||||
)
|
||||
from onyx.background.celery.tasks.user_file_processing.tasks import (
|
||||
process_single_user_file_project_sync,
|
||||
)
|
||||
from onyx.configs.constants import CELERY_USER_FILE_PROJECT_SYNC_TASK_EXPIRES
|
||||
from onyx.configs.constants import OnyxCeleryPriority
|
||||
from onyx.configs.constants import OnyxCeleryQueues
|
||||
from onyx.configs.constants import OnyxCeleryTask
|
||||
from onyx.configs.constants import USER_FILE_PROJECT_SYNC_MAX_QUEUE_DEPTH
|
||||
|
||||
|
||||
def _build_redis_mock_with_lock() -> tuple[MagicMock, MagicMock]:
|
||||
redis_client = MagicMock()
|
||||
lock = MagicMock()
|
||||
lock.acquire.return_value = True
|
||||
lock.owned.return_value = True
|
||||
redis_client.lock.return_value = lock
|
||||
return redis_client, lock
|
||||
|
||||
|
||||
@patch(
|
||||
"onyx.background.celery.tasks.user_file_processing.tasks."
|
||||
"get_user_file_project_sync_queue_depth"
|
||||
)
|
||||
@patch("onyx.background.celery.tasks.user_file_processing.tasks.get_redis_client")
|
||||
def test_check_for_user_file_project_sync_applies_queue_backpressure(
|
||||
mock_get_redis_client: MagicMock,
|
||||
mock_get_queue_depth: MagicMock,
|
||||
) -> None:
|
||||
redis_client, lock = _build_redis_mock_with_lock()
|
||||
mock_get_redis_client.return_value = redis_client
|
||||
mock_get_queue_depth.return_value = USER_FILE_PROJECT_SYNC_MAX_QUEUE_DEPTH + 1
|
||||
|
||||
task_app = MagicMock()
|
||||
with patch.object(check_for_user_file_project_sync, "app", task_app):
|
||||
check_for_user_file_project_sync.run(tenant_id="test-tenant")
|
||||
|
||||
task_app.send_task.assert_not_called()
|
||||
lock.release.assert_called_once()
|
||||
|
||||
|
||||
@patch(
|
||||
"onyx.background.celery.tasks.user_file_processing.tasks."
|
||||
"enqueue_user_file_project_sync_task"
|
||||
)
|
||||
@patch(
|
||||
"onyx.background.celery.tasks.user_file_processing.tasks."
|
||||
"get_user_file_project_sync_queue_depth"
|
||||
)
|
||||
@patch(
|
||||
"onyx.background.celery.tasks.user_file_processing.tasks."
|
||||
"get_session_with_current_tenant"
|
||||
)
|
||||
@patch("onyx.background.celery.tasks.user_file_processing.tasks.get_redis_client")
|
||||
def test_check_for_user_file_project_sync_skips_duplicates(
|
||||
mock_get_redis_client: MagicMock,
|
||||
mock_get_session: MagicMock,
|
||||
mock_get_queue_depth: MagicMock,
|
||||
mock_enqueue: MagicMock,
|
||||
) -> None:
|
||||
redis_client, lock = _build_redis_mock_with_lock()
|
||||
mock_get_redis_client.return_value = redis_client
|
||||
mock_get_queue_depth.return_value = 0
|
||||
|
||||
user_file_id_one = uuid4()
|
||||
user_file_id_two = uuid4()
|
||||
|
||||
session = MagicMock()
|
||||
session.execute.return_value.scalars.return_value.all.return_value = [
|
||||
user_file_id_one,
|
||||
user_file_id_two,
|
||||
]
|
||||
mock_get_session.return_value.__enter__.return_value = session
|
||||
mock_enqueue.side_effect = [True, False]
|
||||
|
||||
task_app = MagicMock()
|
||||
with patch.object(check_for_user_file_project_sync, "app", task_app):
|
||||
check_for_user_file_project_sync.run(tenant_id="test-tenant")
|
||||
|
||||
assert mock_enqueue.call_count == 2
|
||||
lock.release.assert_called_once()
|
||||
|
||||
|
||||
def test_enqueue_user_file_project_sync_task_sets_guard_and_expiry() -> None:
|
||||
redis_client = MagicMock()
|
||||
redis_client.set.return_value = True
|
||||
celery_app = MagicMock()
|
||||
user_file_id = str(uuid4())
|
||||
|
||||
enqueued = enqueue_user_file_project_sync_task(
|
||||
celery_app=celery_app,
|
||||
redis_client=redis_client,
|
||||
user_file_id=user_file_id,
|
||||
tenant_id="test-tenant",
|
||||
priority=OnyxCeleryPriority.HIGHEST,
|
||||
)
|
||||
|
||||
assert enqueued is True
|
||||
redis_client.set.assert_called_once_with(
|
||||
_user_file_project_sync_queued_key(user_file_id),
|
||||
1,
|
||||
nx=True,
|
||||
ex=CELERY_USER_FILE_PROJECT_SYNC_TASK_EXPIRES,
|
||||
)
|
||||
celery_app.send_task.assert_called_once_with(
|
||||
OnyxCeleryTask.PROCESS_SINGLE_USER_FILE_PROJECT_SYNC,
|
||||
kwargs={"user_file_id": user_file_id, "tenant_id": "test-tenant"},
|
||||
queue=OnyxCeleryQueues.USER_FILE_PROJECT_SYNC,
|
||||
priority=OnyxCeleryPriority.HIGHEST,
|
||||
expires=CELERY_USER_FILE_PROJECT_SYNC_TASK_EXPIRES,
|
||||
)
|
||||
|
||||
|
||||
def test_enqueue_user_file_project_sync_task_rolls_back_guard_on_publish_failure() -> (
|
||||
None
|
||||
):
|
||||
redis_client = MagicMock()
|
||||
redis_client.set.return_value = True
|
||||
celery_app = MagicMock()
|
||||
celery_app.send_task.side_effect = RuntimeError("publish failed")
|
||||
|
||||
user_file_id = str(uuid4())
|
||||
with pytest.raises(RuntimeError):
|
||||
enqueue_user_file_project_sync_task(
|
||||
celery_app=celery_app,
|
||||
redis_client=redis_client,
|
||||
user_file_id=user_file_id,
|
||||
tenant_id="test-tenant",
|
||||
)
|
||||
|
||||
redis_client.delete.assert_called_once_with(
|
||||
_user_file_project_sync_queued_key(user_file_id)
|
||||
)
|
||||
|
||||
|
||||
@patch("onyx.background.celery.tasks.user_file_processing.tasks.get_redis_client")
|
||||
def test_process_single_user_file_project_sync_clears_queued_guard_on_pickup(
|
||||
mock_get_redis_client: MagicMock,
|
||||
) -> None:
|
||||
redis_client = MagicMock()
|
||||
lock = MagicMock()
|
||||
lock.acquire.return_value = False
|
||||
redis_client.lock.return_value = lock
|
||||
mock_get_redis_client.return_value = redis_client
|
||||
|
||||
user_file_id = str(uuid4())
|
||||
process_single_user_file_project_sync.run(
|
||||
user_file_id=user_file_id,
|
||||
tenant_id="test-tenant",
|
||||
)
|
||||
|
||||
redis_client.delete.assert_called_once_with(
|
||||
_user_file_project_sync_queued_key(user_file_id)
|
||||
)
|
||||
@@ -1,95 +0,0 @@
|
||||
from onyx.configs.constants import DocumentSource
|
||||
from onyx.connectors.models import Document
|
||||
from onyx.connectors.models import DocumentBase
|
||||
from onyx.connectors.models import TextSection
|
||||
|
||||
|
||||
def _minimal_doc_kwargs(metadata: dict) -> dict:
|
||||
return {
|
||||
"id": "test-doc",
|
||||
"sections": [TextSection(text="hello", link="http://example.com")],
|
||||
"source": DocumentSource.NOT_APPLICABLE,
|
||||
"semantic_identifier": "Test Doc",
|
||||
"metadata": metadata,
|
||||
}
|
||||
|
||||
|
||||
def test_int_values_coerced_to_str() -> None:
|
||||
doc = Document(**_minimal_doc_kwargs({"count": 42}))
|
||||
assert doc.metadata == {"count": "42"}
|
||||
|
||||
|
||||
def test_float_values_coerced_to_str() -> None:
|
||||
doc = Document(**_minimal_doc_kwargs({"score": 3.14}))
|
||||
assert doc.metadata == {"score": "3.14"}
|
||||
|
||||
|
||||
def test_bool_values_coerced_to_str() -> None:
|
||||
doc = Document(**_minimal_doc_kwargs({"active": True}))
|
||||
assert doc.metadata == {"active": "True"}
|
||||
|
||||
|
||||
def test_list_of_ints_coerced_to_list_of_str() -> None:
|
||||
doc = Document(**_minimal_doc_kwargs({"ids": [1, 2, 3]}))
|
||||
assert doc.metadata == {"ids": ["1", "2", "3"]}
|
||||
|
||||
|
||||
def test_list_of_mixed_types_coerced_to_list_of_str() -> None:
|
||||
doc = Document(**_minimal_doc_kwargs({"tags": ["a", 1, True, 2.5]}))
|
||||
assert doc.metadata == {"tags": ["a", "1", "True", "2.5"]}
|
||||
|
||||
|
||||
def test_list_of_dicts_coerced_to_list_of_str() -> None:
|
||||
raw = {"nested": [{"key": "val"}, {"key2": "val2"}]}
|
||||
doc = Document(**_minimal_doc_kwargs(raw))
|
||||
assert doc.metadata == {"nested": ["{'key': 'val'}", "{'key2': 'val2'}"]}
|
||||
|
||||
|
||||
def test_dict_value_coerced_to_str() -> None:
|
||||
raw = {"info": {"inner_key": "inner_val"}}
|
||||
doc = Document(**_minimal_doc_kwargs(raw))
|
||||
assert doc.metadata == {"info": "{'inner_key': 'inner_val'}"}
|
||||
|
||||
|
||||
def test_none_value_coerced_to_str() -> None:
|
||||
doc = Document(**_minimal_doc_kwargs({"empty": None}))
|
||||
assert doc.metadata == {"empty": "None"}
|
||||
|
||||
|
||||
def test_already_valid_str_values_unchanged() -> None:
|
||||
doc = Document(**_minimal_doc_kwargs({"key": "value"}))
|
||||
assert doc.metadata == {"key": "value"}
|
||||
|
||||
|
||||
def test_already_valid_list_of_str_unchanged() -> None:
|
||||
doc = Document(**_minimal_doc_kwargs({"tags": ["a", "b", "c"]}))
|
||||
assert doc.metadata == {"tags": ["a", "b", "c"]}
|
||||
|
||||
|
||||
def test_empty_metadata_unchanged() -> None:
|
||||
doc = Document(**_minimal_doc_kwargs({}))
|
||||
assert doc.metadata == {}
|
||||
|
||||
|
||||
def test_mixed_metadata_values() -> None:
|
||||
raw = {
|
||||
"str_val": "hello",
|
||||
"int_val": 99,
|
||||
"list_val": [1, "two", 3.0],
|
||||
"dict_val": {"nested": True},
|
||||
}
|
||||
doc = Document(**_minimal_doc_kwargs(raw))
|
||||
assert doc.metadata == {
|
||||
"str_val": "hello",
|
||||
"int_val": "99",
|
||||
"list_val": ["1", "two", "3.0"],
|
||||
"dict_val": "{'nested': True}",
|
||||
}
|
||||
|
||||
|
||||
def test_coercion_works_on_base_class() -> None:
|
||||
kwargs = _minimal_doc_kwargs({"count": 42})
|
||||
kwargs.pop("source")
|
||||
kwargs.pop("id")
|
||||
doc = DocumentBase(**kwargs)
|
||||
assert doc.metadata == {"count": "42"}
|
||||
@@ -1,52 +0,0 @@
|
||||
import pytest
|
||||
from office365.graph_client import AzureEnvironment # type: ignore[import-untyped]
|
||||
|
||||
from onyx.connectors.exceptions import ConnectorValidationError
|
||||
from onyx.connectors.microsoft_graph_env import resolve_microsoft_environment
|
||||
|
||||
|
||||
def test_resolve_global_defaults() -> None:
|
||||
env = resolve_microsoft_environment(
|
||||
"https://graph.microsoft.com", "https://login.microsoftonline.com"
|
||||
)
|
||||
assert env.environment == AzureEnvironment.Global
|
||||
assert env.sharepoint_domain_suffix == "sharepoint.com"
|
||||
|
||||
|
||||
def test_resolve_gcc_high() -> None:
|
||||
env = resolve_microsoft_environment(
|
||||
"https://graph.microsoft.us", "https://login.microsoftonline.us"
|
||||
)
|
||||
assert env.environment == AzureEnvironment.USGovernmentHigh
|
||||
assert env.graph_host == "https://graph.microsoft.us"
|
||||
assert env.authority_host == "https://login.microsoftonline.us"
|
||||
assert env.sharepoint_domain_suffix == "sharepoint.us"
|
||||
|
||||
|
||||
def test_resolve_dod() -> None:
|
||||
env = resolve_microsoft_environment(
|
||||
"https://dod-graph.microsoft.us", "https://login.microsoftonline.us"
|
||||
)
|
||||
assert env.environment == AzureEnvironment.USGovernmentDoD
|
||||
assert env.sharepoint_domain_suffix == "sharepoint.us"
|
||||
|
||||
|
||||
def test_trailing_slashes_are_stripped() -> None:
|
||||
env = resolve_microsoft_environment(
|
||||
"https://graph.microsoft.us/", "https://login.microsoftonline.us/"
|
||||
)
|
||||
assert env.environment == AzureEnvironment.USGovernmentHigh
|
||||
|
||||
|
||||
def test_mismatched_authority_raises() -> None:
|
||||
with pytest.raises(ConnectorValidationError, match="inconsistent"):
|
||||
resolve_microsoft_environment(
|
||||
"https://graph.microsoft.us", "https://login.microsoftonline.com"
|
||||
)
|
||||
|
||||
|
||||
def test_unknown_graph_host_raises() -> None:
|
||||
with pytest.raises(ConnectorValidationError, match="Unsupported"):
|
||||
resolve_microsoft_environment(
|
||||
"https://graph.example.com", "https://login.example.com"
|
||||
)
|
||||
@@ -1,12 +1,10 @@
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from onyx.image_gen.exceptions import ImageProviderCredentialsError
|
||||
from onyx.image_gen.factory import get_image_generation_provider
|
||||
from onyx.image_gen.interfaces import ImageGenerationProviderCredentials
|
||||
from onyx.image_gen.interfaces import ReferenceImage
|
||||
from onyx.image_gen.providers.azure_img_gen import AzureImageGenerationProvider
|
||||
from onyx.image_gen.providers.openai_img_gen import OpenAIImageGenerationProvider
|
||||
from onyx.image_gen.providers.vertex_img_gen import VertexImageGenerationProvider
|
||||
@@ -47,8 +45,6 @@ def test_build_openai_provider_from_api_key_and_base() -> None:
|
||||
assert isinstance(image_gen_provider, OpenAIImageGenerationProvider)
|
||||
assert image_gen_provider._api_key == "test"
|
||||
assert image_gen_provider._api_base == "test"
|
||||
assert image_gen_provider.supports_reference_images is True
|
||||
assert image_gen_provider.max_reference_images == 16
|
||||
|
||||
|
||||
def test_build_openai_provider_fails_no_api_key() -> None:
|
||||
@@ -77,8 +73,6 @@ def test_build_azure_provider_from_api_key_and_base_and_version() -> None:
|
||||
assert image_gen_provider._api_key == "test"
|
||||
assert image_gen_provider._api_base == "test"
|
||||
assert image_gen_provider._api_version == "test"
|
||||
assert image_gen_provider.supports_reference_images is True
|
||||
assert image_gen_provider.max_reference_images == 16
|
||||
|
||||
|
||||
def test_build_azure_provider_fails_missing_credential() -> None:
|
||||
@@ -139,195 +133,3 @@ def test_build_vertex_provider_with_missing_project_id() -> None:
|
||||
|
||||
with pytest.raises(ImageProviderCredentialsError):
|
||||
get_image_generation_provider("vertex_ai", credentials)
|
||||
|
||||
|
||||
def test_openai_provider_uses_image_generation_without_reference_images() -> None:
|
||||
provider = OpenAIImageGenerationProvider(
|
||||
api_key="test-key",
|
||||
api_base="test-base",
|
||||
)
|
||||
expected_response = object()
|
||||
|
||||
with (
|
||||
patch("litellm.image_generation", return_value=expected_response) as mock_gen,
|
||||
patch("litellm.image_edit") as mock_edit,
|
||||
):
|
||||
response = provider.generate_image(
|
||||
prompt="draw a mountain",
|
||||
model="gpt-image-1",
|
||||
size="1024x1024",
|
||||
n=1,
|
||||
quality="high",
|
||||
)
|
||||
|
||||
assert response is expected_response
|
||||
mock_gen.assert_called_once()
|
||||
mock_edit.assert_not_called()
|
||||
|
||||
|
||||
def test_openai_provider_uses_image_edit_with_reference_images() -> None:
|
||||
provider = OpenAIImageGenerationProvider(
|
||||
api_key="test-key",
|
||||
api_base="test-base",
|
||||
)
|
||||
reference_images = [
|
||||
ReferenceImage(data=b"image-1-bytes", mime_type="image/png"),
|
||||
ReferenceImage(data=b"image-2-bytes", mime_type="image/jpeg"),
|
||||
]
|
||||
expected_response = object()
|
||||
|
||||
with (
|
||||
patch("litellm.image_generation") as mock_gen,
|
||||
patch("litellm.image_edit", return_value=expected_response) as mock_edit,
|
||||
):
|
||||
response = provider.generate_image(
|
||||
prompt="make this look watercolor",
|
||||
model="gpt-image-1",
|
||||
size="1024x1024",
|
||||
n=1,
|
||||
quality="high",
|
||||
reference_images=reference_images,
|
||||
)
|
||||
|
||||
assert response is expected_response
|
||||
mock_gen.assert_not_called()
|
||||
mock_edit.assert_called_once()
|
||||
assert mock_edit.call_args.kwargs["image"] == [
|
||||
b"image-1-bytes",
|
||||
b"image-2-bytes",
|
||||
]
|
||||
|
||||
|
||||
def test_openai_provider_rejects_reference_images_for_unsupported_model() -> None:
|
||||
provider = OpenAIImageGenerationProvider(api_key="test-key")
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
provider.generate_image(
|
||||
prompt="edit this image",
|
||||
model="dall-e-3",
|
||||
size="1024x1024",
|
||||
n=1,
|
||||
reference_images=[ReferenceImage(data=b"image-1", mime_type="image/png")],
|
||||
)
|
||||
|
||||
|
||||
def test_openai_provider_rejects_multiple_reference_images_for_dalle3() -> None:
|
||||
provider = OpenAIImageGenerationProvider(api_key="test-key")
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="does not support image edits with reference images",
|
||||
):
|
||||
provider.generate_image(
|
||||
prompt="edit this image",
|
||||
model="dall-e-3",
|
||||
size="1024x1024",
|
||||
n=1,
|
||||
reference_images=[
|
||||
ReferenceImage(data=b"image-1", mime_type="image/png"),
|
||||
ReferenceImage(data=b"image-2", mime_type="image/png"),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def test_azure_provider_uses_image_generation_without_reference_images() -> None:
|
||||
provider = AzureImageGenerationProvider(
|
||||
api_key="test-key",
|
||||
api_base="https://azure.example.com",
|
||||
api_version="2024-05-01-preview",
|
||||
deployment_name="img-deployment",
|
||||
)
|
||||
expected_response = object()
|
||||
|
||||
with (
|
||||
patch("litellm.image_generation", return_value=expected_response) as mock_gen,
|
||||
patch("litellm.image_edit") as mock_edit,
|
||||
):
|
||||
response = provider.generate_image(
|
||||
prompt="draw a skyline",
|
||||
model="gpt-image-1",
|
||||
size="1024x1024",
|
||||
n=1,
|
||||
quality="high",
|
||||
)
|
||||
|
||||
assert response is expected_response
|
||||
mock_gen.assert_called_once()
|
||||
mock_edit.assert_not_called()
|
||||
assert mock_gen.call_args.kwargs["model"] == "azure/img-deployment"
|
||||
|
||||
|
||||
def test_azure_provider_uses_image_edit_with_reference_images() -> None:
|
||||
provider = AzureImageGenerationProvider(
|
||||
api_key="test-key",
|
||||
api_base="https://azure.example.com",
|
||||
api_version="2024-05-01-preview",
|
||||
deployment_name="img-deployment",
|
||||
)
|
||||
reference_images = [
|
||||
ReferenceImage(data=b"image-1-bytes", mime_type="image/png"),
|
||||
ReferenceImage(data=b"image-2-bytes", mime_type="image/jpeg"),
|
||||
]
|
||||
expected_response = object()
|
||||
|
||||
with (
|
||||
patch("litellm.image_generation") as mock_gen,
|
||||
patch("litellm.image_edit", return_value=expected_response) as mock_edit,
|
||||
):
|
||||
response = provider.generate_image(
|
||||
prompt="make this noir style",
|
||||
model="gpt-image-1",
|
||||
size="1024x1024",
|
||||
n=1,
|
||||
quality="high",
|
||||
reference_images=reference_images,
|
||||
)
|
||||
|
||||
assert response is expected_response
|
||||
mock_gen.assert_not_called()
|
||||
mock_edit.assert_called_once()
|
||||
assert mock_edit.call_args.kwargs["model"] == "azure/img-deployment"
|
||||
assert mock_edit.call_args.kwargs["image"] == [
|
||||
b"image-1-bytes",
|
||||
b"image-2-bytes",
|
||||
]
|
||||
|
||||
|
||||
def test_azure_provider_rejects_reference_images_for_unsupported_model() -> None:
|
||||
provider = AzureImageGenerationProvider(
|
||||
api_key="test-key",
|
||||
api_base="https://azure.example.com",
|
||||
api_version="2024-05-01-preview",
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
provider.generate_image(
|
||||
prompt="edit this image",
|
||||
model="dall-e-3",
|
||||
size="1024x1024",
|
||||
n=1,
|
||||
reference_images=[ReferenceImage(data=b"image-1", mime_type="image/png")],
|
||||
)
|
||||
|
||||
|
||||
def test_azure_provider_rejects_multiple_reference_images_for_dalle3() -> None:
|
||||
provider = AzureImageGenerationProvider(
|
||||
api_key="test-key",
|
||||
api_base="https://azure.example.com",
|
||||
api_version="2024-05-01-preview",
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
ValueError,
|
||||
match="does not support image edits with reference images",
|
||||
):
|
||||
provider.generate_image(
|
||||
prompt="edit this image",
|
||||
model="dall-e-3",
|
||||
size="1024x1024",
|
||||
n=1,
|
||||
reference_images=[
|
||||
ReferenceImage(data=b"image-1", mime_type="image/png"),
|
||||
ReferenceImage(data=b"image-2", mime_type="image/png"),
|
||||
],
|
||||
)
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
from pytest import MonkeyPatch
|
||||
|
||||
from onyx.access.models import ExternalAccess
|
||||
from onyx.connectors.models import BasicExpertInfo
|
||||
from onyx.connectors.models import Document
|
||||
from onyx.connectors.models import DocumentSource
|
||||
from onyx.connectors.models import HierarchyNode
|
||||
from onyx.connectors.models import IndexAttemptMetadata
|
||||
from onyx.connectors.models import TextSection
|
||||
from onyx.db.enums import HierarchyNodeType
|
||||
from onyx.indexing import indexing_pipeline
|
||||
from onyx.indexing.postgres_sanitization import sanitize_document_for_postgres
|
||||
from onyx.indexing.postgres_sanitization import sanitize_hierarchy_node_for_postgres
|
||||
|
||||
|
||||
def test_sanitize_document_for_postgres_removes_nul_bytes() -> None:
|
||||
document = Document(
|
||||
id="doc\x00-id",
|
||||
source=DocumentSource.FILE,
|
||||
semantic_identifier="sem\x00-id",
|
||||
title="ti\x00tle",
|
||||
parent_hierarchy_raw_node_id="parent\x00-id",
|
||||
sections=[TextSection(link="lin\x00k", text="te\x00xt")],
|
||||
metadata={"ke\x00y": "va\x00lue", "list\x00key": ["a\x00", "b"]},
|
||||
doc_metadata={
|
||||
"j\x00son": {
|
||||
"in\x00ner": "va\x00l",
|
||||
"arr": ["x\x00", {"dee\x00p": "y\x00"}],
|
||||
}
|
||||
},
|
||||
primary_owners=[BasicExpertInfo(display_name="Ali\x00ce", email="a\x00@x.com")],
|
||||
secondary_owners=[BasicExpertInfo(first_name="Bo\x00b", last_name="Sm\x00ith")],
|
||||
external_access=ExternalAccess(
|
||||
external_user_emails={"user\x00@example.com"},
|
||||
external_user_group_ids={"gro\x00up-1"},
|
||||
is_public=False,
|
||||
),
|
||||
)
|
||||
|
||||
sanitized = sanitize_document_for_postgres(document)
|
||||
|
||||
assert sanitized.id == "doc-id"
|
||||
assert sanitized.semantic_identifier == "sem-id"
|
||||
assert sanitized.title == "title"
|
||||
assert sanitized.parent_hierarchy_raw_node_id == "parent-id"
|
||||
assert sanitized.sections[0].link == "link"
|
||||
assert sanitized.sections[0].text == "text"
|
||||
assert sanitized.metadata == {"key": "value", "listkey": ["a", "b"]}
|
||||
assert sanitized.doc_metadata == {
|
||||
"json": {"inner": "val", "arr": ["x", {"deep": "y"}]}
|
||||
}
|
||||
assert sanitized.primary_owners is not None
|
||||
assert sanitized.primary_owners[0].display_name == "Alice"
|
||||
assert sanitized.primary_owners[0].email == "a@x.com"
|
||||
assert sanitized.secondary_owners is not None
|
||||
assert sanitized.secondary_owners[0].first_name == "Bob"
|
||||
assert sanitized.secondary_owners[0].last_name == "Smith"
|
||||
assert sanitized.external_access is not None
|
||||
assert sanitized.external_access.external_user_emails == {"user@example.com"}
|
||||
assert sanitized.external_access.external_user_group_ids == {"group-1"}
|
||||
|
||||
# Ensure original document is not mutated
|
||||
assert document.id == "doc\x00-id"
|
||||
assert document.metadata == {"ke\x00y": "va\x00lue", "list\x00key": ["a\x00", "b"]}
|
||||
|
||||
|
||||
def test_sanitize_hierarchy_node_for_postgres_removes_nul_bytes() -> None:
|
||||
node = HierarchyNode(
|
||||
raw_node_id="raw\x00-id",
|
||||
raw_parent_id="paren\x00t-id",
|
||||
display_name="fol\x00der",
|
||||
link="https://exa\x00mple.com",
|
||||
node_type=HierarchyNodeType.FOLDER,
|
||||
external_access=ExternalAccess(
|
||||
external_user_emails={"a\x00@example.com"},
|
||||
external_user_group_ids={"g\x00-1"},
|
||||
is_public=True,
|
||||
),
|
||||
)
|
||||
|
||||
sanitized = sanitize_hierarchy_node_for_postgres(node)
|
||||
|
||||
assert sanitized.raw_node_id == "raw-id"
|
||||
assert sanitized.raw_parent_id == "parent-id"
|
||||
assert sanitized.display_name == "folder"
|
||||
assert sanitized.link == "https://example.com"
|
||||
assert sanitized.external_access is not None
|
||||
assert sanitized.external_access.external_user_emails == {"a@example.com"}
|
||||
assert sanitized.external_access.external_user_group_ids == {"g-1"}
|
||||
|
||||
|
||||
def test_index_doc_batch_prepare_sanitizes_before_db_ops(
|
||||
monkeypatch: MonkeyPatch,
|
||||
) -> None:
|
||||
document = Document(
|
||||
id="doc\x00id",
|
||||
source=DocumentSource.FILE,
|
||||
semantic_identifier="sem\x00id",
|
||||
sections=[TextSection(text="content", link="li\x00nk")],
|
||||
metadata={"ke\x00y": "va\x00lue"},
|
||||
)
|
||||
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def _get_documents_by_ids(db_session: object, document_ids: list[str]) -> list:
|
||||
_ = db_session, document_ids
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(
|
||||
indexing_pipeline, "get_documents_by_ids", _get_documents_by_ids
|
||||
)
|
||||
|
||||
def _capture_upsert_documents_in_db(**kwargs: object) -> None:
|
||||
captured["upsert_documents"] = kwargs["documents"]
|
||||
|
||||
monkeypatch.setattr(
|
||||
indexing_pipeline, "_upsert_documents_in_db", _capture_upsert_documents_in_db
|
||||
)
|
||||
|
||||
def _capture_doc_cc_pair(*args: object) -> None:
|
||||
captured["cc_pair_doc_ids"] = args[3]
|
||||
|
||||
monkeypatch.setattr(
|
||||
indexing_pipeline,
|
||||
"upsert_document_by_connector_credential_pair",
|
||||
_capture_doc_cc_pair,
|
||||
)
|
||||
|
||||
def _noop_link_hierarchy_nodes_to_documents(
|
||||
db_session: object,
|
||||
document_ids: list[str],
|
||||
source: DocumentSource,
|
||||
commit: bool,
|
||||
) -> int:
|
||||
_ = db_session, document_ids, source, commit
|
||||
return 0
|
||||
|
||||
monkeypatch.setattr(
|
||||
indexing_pipeline,
|
||||
"link_hierarchy_nodes_to_documents",
|
||||
_noop_link_hierarchy_nodes_to_documents,
|
||||
)
|
||||
|
||||
context = indexing_pipeline.index_doc_batch_prepare(
|
||||
documents=[document],
|
||||
index_attempt_metadata=IndexAttemptMetadata(connector_id=1, credential_id=2),
|
||||
db_session=object(), # type: ignore[arg-type]
|
||||
ignore_time_skip=True,
|
||||
)
|
||||
|
||||
assert context is not None
|
||||
assert context.updatable_docs[0].id == "docid"
|
||||
assert context.updatable_docs[0].semantic_identifier == "semid"
|
||||
assert context.updatable_docs[0].metadata == {"key": "value"}
|
||||
assert captured["cc_pair_doc_ids"] == ["docid"]
|
||||
|
||||
upsert_documents = captured["upsert_documents"]
|
||||
assert isinstance(upsert_documents, list)
|
||||
assert upsert_documents[0].id == "docid"
|
||||
@@ -1,52 +0,0 @@
|
||||
from onyx.onyxbot.slack.formatting import _normalize_citation_link_destinations
|
||||
from onyx.onyxbot.slack.formatting import format_slack_message
|
||||
from onyx.onyxbot.slack.utils import remove_slack_text_interactions
|
||||
from onyx.utils.text_processing import decode_escapes
|
||||
|
||||
|
||||
def test_normalize_citation_link_wraps_url_with_parentheses() -> None:
|
||||
message = (
|
||||
"See [[1]](https://example.com/Access%20ID%20Card(s)%20Guide.pdf) for details."
|
||||
)
|
||||
|
||||
normalized = _normalize_citation_link_destinations(message)
|
||||
|
||||
assert (
|
||||
"See [[1]](<https://example.com/Access%20ID%20Card(s)%20Guide.pdf>) for details."
|
||||
== normalized
|
||||
)
|
||||
|
||||
|
||||
def test_normalize_citation_link_keeps_existing_angle_brackets() -> None:
|
||||
message = "[[1]](<https://example.com/Access%20ID%20Card(s)%20Guide.pdf>)"
|
||||
|
||||
normalized = _normalize_citation_link_destinations(message)
|
||||
|
||||
assert message == normalized
|
||||
|
||||
|
||||
def test_normalize_citation_link_handles_multiple_links() -> None:
|
||||
message = (
|
||||
"[[1]](https://example.com/(USA)%20Guide.pdf) "
|
||||
"[[2]](https://example.com/Plan(s)%20Overview.pdf)"
|
||||
)
|
||||
|
||||
normalized = _normalize_citation_link_destinations(message)
|
||||
|
||||
assert "[[1]](<https://example.com/(USA)%20Guide.pdf>)" in normalized
|
||||
assert "[[2]](<https://example.com/Plan(s)%20Overview.pdf>)" in normalized
|
||||
|
||||
|
||||
def test_format_slack_message_keeps_parenthesized_citation_links_intact() -> None:
|
||||
message = (
|
||||
"Download [[1]](https://example.com/(USA)%20Access%20ID%20Card(s)%20Guide.pdf)"
|
||||
)
|
||||
|
||||
formatted = format_slack_message(message)
|
||||
rendered = decode_escapes(remove_slack_text_interactions(formatted))
|
||||
|
||||
assert (
|
||||
"<https://example.com/(USA)%20Access%20ID%20Card(s)%20Guide.pdf|[1]>"
|
||||
in rendered
|
||||
)
|
||||
assert "|[1]>%20Access%20ID%20Card" not in rendered
|
||||
@@ -1,12 +1,10 @@
|
||||
"""Test bulk invite limit for free trial tenants."""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from onyx.server.manage.models import EmailInviteStatus
|
||||
from onyx.server.manage.users import bulk_invite_users
|
||||
|
||||
|
||||
@@ -35,7 +33,6 @@ def test_trial_tenant_cannot_exceed_invite_limit(*_mocks: None) -> None:
|
||||
@patch("onyx.server.manage.users.get_invited_users", return_value=[])
|
||||
@patch("onyx.server.manage.users.get_all_users", return_value=[])
|
||||
@patch("onyx.server.manage.users.write_invited_users", return_value=3)
|
||||
@patch("onyx.server.manage.users.enforce_seat_limit")
|
||||
@patch("onyx.server.manage.users.NUM_FREE_TRIAL_USER_INVITES", 5)
|
||||
@patch(
|
||||
"onyx.server.manage.users.fetch_ee_implementation_or_noop",
|
||||
@@ -47,69 +44,4 @@ def test_trial_tenant_can_invite_within_limit(*_mocks: None) -> None:
|
||||
|
||||
result = bulk_invite_users(emails=emails)
|
||||
|
||||
assert result.invited_count == 3
|
||||
assert result.email_invite_status == EmailInviteStatus.DISABLED
|
||||
|
||||
|
||||
# --- email_invite_status tests ---
|
||||
|
||||
_COMMON_PATCHES = [
|
||||
patch("onyx.server.manage.users.MULTI_TENANT", False),
|
||||
patch("onyx.server.manage.users.get_current_tenant_id", return_value="test_tenant"),
|
||||
patch("onyx.server.manage.users.get_invited_users", return_value=[]),
|
||||
patch("onyx.server.manage.users.get_all_users", return_value=[]),
|
||||
patch("onyx.server.manage.users.write_invited_users", return_value=1),
|
||||
patch("onyx.server.manage.users.enforce_seat_limit"),
|
||||
]
|
||||
|
||||
|
||||
def _with_common_patches(fn: object) -> object:
|
||||
for p in reversed(_COMMON_PATCHES):
|
||||
fn = p(fn) # type: ignore
|
||||
return fn
|
||||
|
||||
|
||||
@_with_common_patches
|
||||
@patch("onyx.server.manage.users.ENABLE_EMAIL_INVITES", False)
|
||||
def test_email_invite_status_disabled(*_mocks: None) -> None:
|
||||
"""When email invites are disabled, status is disabled."""
|
||||
result = bulk_invite_users(emails=["user@example.com"])
|
||||
|
||||
assert result.email_invite_status == EmailInviteStatus.DISABLED
|
||||
|
||||
|
||||
@_with_common_patches
|
||||
@patch("onyx.server.manage.users.ENABLE_EMAIL_INVITES", True)
|
||||
@patch("onyx.server.manage.users.EMAIL_CONFIGURED", False)
|
||||
def test_email_invite_status_not_configured(*_mocks: None) -> None:
|
||||
"""When email invites are enabled but no server is configured, status is not_configured."""
|
||||
result = bulk_invite_users(emails=["user@example.com"])
|
||||
|
||||
assert result.email_invite_status == EmailInviteStatus.NOT_CONFIGURED
|
||||
|
||||
|
||||
@_with_common_patches
|
||||
@patch("onyx.server.manage.users.ENABLE_EMAIL_INVITES", True)
|
||||
@patch("onyx.server.manage.users.EMAIL_CONFIGURED", True)
|
||||
@patch("onyx.server.manage.users.send_user_email_invite")
|
||||
def test_email_invite_status_sent(mock_send: MagicMock, *_mocks: None) -> None:
|
||||
"""When email invites are enabled and configured, status is sent."""
|
||||
result = bulk_invite_users(emails=["user@example.com"])
|
||||
|
||||
mock_send.assert_called_once()
|
||||
assert result.email_invite_status == EmailInviteStatus.SENT
|
||||
|
||||
|
||||
@_with_common_patches
|
||||
@patch("onyx.server.manage.users.ENABLE_EMAIL_INVITES", True)
|
||||
@patch("onyx.server.manage.users.EMAIL_CONFIGURED", True)
|
||||
@patch(
|
||||
"onyx.server.manage.users.send_user_email_invite",
|
||||
side_effect=Exception("SMTP auth failed"),
|
||||
)
|
||||
def test_email_invite_status_send_failed(*_mocks: None) -> None:
|
||||
"""When email sending throws, status is send_failed and invite is still saved."""
|
||||
result = bulk_invite_users(emails=["user@example.com"])
|
||||
|
||||
assert result.email_invite_status == EmailInviteStatus.SEND_FAILED
|
||||
assert result.invited_count == 1
|
||||
assert result == 3
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
"""Unit tests for CodeInterpreterClient streaming-to-batch fallback.
|
||||
|
||||
When the streaming endpoint (/v1/execute/stream) returns 404 — e.g. because the
|
||||
code-interpreter service is an older version that doesn't support streaming — the
|
||||
client should transparently fall back to the batch endpoint (/v1/execute) and
|
||||
convert the batch response into the same stream-event interface.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import patch
|
||||
|
||||
from onyx.tools.tool_implementations.python.code_interpreter_client import (
|
||||
CodeInterpreterClient,
|
||||
)
|
||||
from onyx.tools.tool_implementations.python.code_interpreter_client import FileInput
|
||||
from onyx.tools.tool_implementations.python.code_interpreter_client import (
|
||||
StreamOutputEvent,
|
||||
)
|
||||
from onyx.tools.tool_implementations.python.code_interpreter_client import (
|
||||
StreamResultEvent,
|
||||
)
|
||||
|
||||
|
||||
def _make_batch_response(
|
||||
stdout: str = "",
|
||||
stderr: str = "",
|
||||
exit_code: int = 0,
|
||||
timed_out: bool = False,
|
||||
duration_ms: int = 50,
|
||||
) -> MagicMock:
|
||||
"""Build a mock ``requests.Response`` for the batch /v1/execute endpoint."""
|
||||
resp = MagicMock()
|
||||
resp.status_code = 200
|
||||
resp.raise_for_status = MagicMock()
|
||||
resp.json.return_value = {
|
||||
"stdout": stdout,
|
||||
"stderr": stderr,
|
||||
"exit_code": exit_code,
|
||||
"timed_out": timed_out,
|
||||
"duration_ms": duration_ms,
|
||||
"files": [],
|
||||
}
|
||||
return resp
|
||||
|
||||
|
||||
def _make_404_response() -> MagicMock:
|
||||
"""Build a mock ``requests.Response`` that returns 404 (streaming not found)."""
|
||||
resp = MagicMock()
|
||||
resp.status_code = 404
|
||||
return resp
|
||||
|
||||
|
||||
def test_execute_streaming_fallback_to_batch_on_404() -> None:
|
||||
"""When /v1/execute/stream returns 404, the client should fall back to
|
||||
/v1/execute and yield equivalent StreamEvent objects."""
|
||||
|
||||
client = CodeInterpreterClient(base_url="http://fake:9000")
|
||||
|
||||
stream_resp = _make_404_response()
|
||||
batch_resp = _make_batch_response(
|
||||
stdout="hello world\n",
|
||||
stderr="a warning\n",
|
||||
)
|
||||
|
||||
urls_called: list[str] = []
|
||||
|
||||
def mock_post(url: str, **_kwargs: object) -> MagicMock:
|
||||
urls_called.append(url)
|
||||
if url.endswith("/v1/execute/stream"):
|
||||
return stream_resp
|
||||
if url.endswith("/v1/execute"):
|
||||
return batch_resp
|
||||
raise AssertionError(f"Unexpected URL: {url}")
|
||||
|
||||
with patch.object(client.session, "post", side_effect=mock_post):
|
||||
events = list(client.execute_streaming(code="print('hello world')"))
|
||||
|
||||
# Streaming endpoint was attempted first, then batch
|
||||
assert len(urls_called) == 2
|
||||
assert urls_called[0].endswith("/v1/execute/stream")
|
||||
assert urls_called[1].endswith("/v1/execute")
|
||||
|
||||
# The 404 response must be closed before making the batch call
|
||||
stream_resp.close.assert_called_once()
|
||||
|
||||
# _batch_as_stream yields: stdout event, stderr event, result event
|
||||
assert len(events) == 3
|
||||
|
||||
assert isinstance(events[0], StreamOutputEvent)
|
||||
assert events[0].stream == "stdout"
|
||||
assert events[0].data == "hello world\n"
|
||||
|
||||
assert isinstance(events[1], StreamOutputEvent)
|
||||
assert events[1].stream == "stderr"
|
||||
assert events[1].data == "a warning\n"
|
||||
|
||||
assert isinstance(events[2], StreamResultEvent)
|
||||
assert events[2].exit_code == 0
|
||||
assert not events[2].timed_out
|
||||
assert events[2].duration_ms == 50
|
||||
assert events[2].files == []
|
||||
|
||||
|
||||
def test_execute_streaming_fallback_stdout_only() -> None:
|
||||
"""Fallback with only stdout (no stderr) should yield two events:
|
||||
one StreamOutputEvent for stdout and one StreamResultEvent."""
|
||||
|
||||
client = CodeInterpreterClient(base_url="http://fake:9000")
|
||||
|
||||
stream_resp = _make_404_response()
|
||||
batch_resp = _make_batch_response(stdout="result: 42\n")
|
||||
|
||||
def mock_post(url: str, **_kwargs: object) -> MagicMock:
|
||||
if url.endswith("/v1/execute/stream"):
|
||||
return stream_resp
|
||||
if url.endswith("/v1/execute"):
|
||||
return batch_resp
|
||||
raise AssertionError(f"Unexpected URL: {url}")
|
||||
|
||||
with patch.object(client.session, "post", side_effect=mock_post):
|
||||
events = list(client.execute_streaming(code="print(42)"))
|
||||
|
||||
# No stderr → only stdout + result
|
||||
assert len(events) == 2
|
||||
|
||||
assert isinstance(events[0], StreamOutputEvent)
|
||||
assert events[0].stream == "stdout"
|
||||
assert events[0].data == "result: 42\n"
|
||||
|
||||
assert isinstance(events[1], StreamResultEvent)
|
||||
assert events[1].exit_code == 0
|
||||
|
||||
|
||||
def test_execute_streaming_fallback_preserves_files_param() -> None:
|
||||
"""When falling back, the files parameter must be forwarded to the
|
||||
batch endpoint so staged files are still available for execution."""
|
||||
|
||||
client = CodeInterpreterClient(base_url="http://fake:9000")
|
||||
|
||||
stream_resp = _make_404_response()
|
||||
batch_resp = _make_batch_response(stdout="ok\n")
|
||||
|
||||
captured_payloads: list[dict] = []
|
||||
|
||||
def mock_post(url: str, **kwargs: object) -> MagicMock:
|
||||
if "json" in kwargs:
|
||||
captured_payloads.append(kwargs["json"]) # type: ignore[arg-type]
|
||||
if url.endswith("/v1/execute/stream"):
|
||||
return stream_resp
|
||||
if url.endswith("/v1/execute"):
|
||||
return batch_resp
|
||||
raise AssertionError(f"Unexpected URL: {url}")
|
||||
|
||||
files_input: list[FileInput] = [{"path": "data.csv", "file_id": "file-abc123"}]
|
||||
|
||||
with patch.object(client.session, "post", side_effect=mock_post):
|
||||
events = list(
|
||||
client.execute_streaming(
|
||||
code="import pandas",
|
||||
files=files_input,
|
||||
)
|
||||
)
|
||||
|
||||
# Both the streaming attempt and the batch fallback should include files
|
||||
assert len(captured_payloads) == 2
|
||||
for payload in captured_payloads:
|
||||
assert payload["files"] == files_input
|
||||
assert payload["code"] == "import pandas"
|
||||
|
||||
# Should still yield valid events
|
||||
assert any(isinstance(e, StreamResultEvent) for e in events)
|
||||
@@ -487,7 +487,16 @@ services:
|
||||
|
||||
code-interpreter:
|
||||
image: onyxdotapp/code-interpreter:${CODE_INTERPRETER_IMAGE_TAG:-latest}
|
||||
command: ["bash", "./entrypoint.sh", "code-interpreter-api"]
|
||||
entrypoint: ["/bin/bash", "-c"]
|
||||
command: >
|
||||
"
|
||||
if [ \"$${CODE_INTERPRETER_BETA_ENABLED}\" = \"True\" ] || [ \"$${CODE_INTERPRETER_BETA_ENABLED}\" = \"true\" ]; then
|
||||
exec bash ./entrypoint.sh code-interpreter-api;
|
||||
else
|
||||
echo 'Skipping code interpreter';
|
||||
exec tail -f /dev/null;
|
||||
fi
|
||||
"
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- path: .env
|
||||
|
||||
@@ -69,4 +69,6 @@ services:
|
||||
inference_model_server:
|
||||
profiles: ["inference"]
|
||||
|
||||
code-interpreter: {}
|
||||
# Code interpreter is not needed in minimal mode.
|
||||
code-interpreter:
|
||||
profiles: ["code-interpreter"]
|
||||
|
||||
@@ -315,7 +315,16 @@ services:
|
||||
|
||||
code-interpreter:
|
||||
image: onyxdotapp/code-interpreter:${CODE_INTERPRETER_IMAGE_TAG:-latest}
|
||||
command: ["bash", "./entrypoint.sh", "code-interpreter-api"]
|
||||
entrypoint: ["/bin/bash", "-c"]
|
||||
command: >
|
||||
"
|
||||
if [ \"$${CODE_INTERPRETER_BETA_ENABLED}\" = \"True\" ] || [ \"$${CODE_INTERPRETER_BETA_ENABLED}\" = \"true\" ]; then
|
||||
exec bash ./entrypoint.sh code-interpreter-api;
|
||||
else
|
||||
echo 'Skipping code interpreter';
|
||||
exec tail -f /dev/null;
|
||||
fi
|
||||
"
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- path: .env
|
||||
|
||||
@@ -352,7 +352,16 @@ services:
|
||||
|
||||
code-interpreter:
|
||||
image: onyxdotapp/code-interpreter:${CODE_INTERPRETER_IMAGE_TAG:-latest}
|
||||
command: ["bash", "./entrypoint.sh", "code-interpreter-api"]
|
||||
entrypoint: ["/bin/bash", "-c"]
|
||||
command: >
|
||||
"
|
||||
if [ \"$${CODE_INTERPRETER_BETA_ENABLED}\" = \"True\" ] || [ \"$${CODE_INTERPRETER_BETA_ENABLED}\" = \"true\" ]; then
|
||||
exec bash ./entrypoint.sh code-interpreter-api;
|
||||
else
|
||||
echo 'Skipping code interpreter';
|
||||
exec tail -f /dev/null;
|
||||
fi
|
||||
"
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- path: .env
|
||||
|
||||
@@ -527,7 +527,16 @@ services:
|
||||
|
||||
code-interpreter:
|
||||
image: onyxdotapp/code-interpreter:${CODE_INTERPRETER_IMAGE_TAG:-latest}
|
||||
command: ["bash", "./entrypoint.sh", "code-interpreter-api"]
|
||||
entrypoint: ["/bin/bash", "-c"]
|
||||
command: >
|
||||
"
|
||||
if [ \"$${CODE_INTERPRETER_BETA_ENABLED}\" = \"True\" ] || [ \"$${CODE_INTERPRETER_BETA_ENABLED}\" = \"true\" ]; then
|
||||
exec bash ./entrypoint.sh code-interpreter-api;
|
||||
else
|
||||
echo 'Skipping code interpreter';
|
||||
exec tail -f /dev/null;
|
||||
fi
|
||||
"
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- path: .env
|
||||
|
||||
@@ -19,6 +19,6 @@ dependencies:
|
||||
version: 5.4.0
|
||||
- name: code-interpreter
|
||||
repository: https://onyx-dot-app.github.io/python-sandbox/
|
||||
version: 0.3.0
|
||||
digest: sha256:cf8f01906d46034962c6ce894770621ee183ac761e6942951118aeb48540eddd
|
||||
generated: "2026-02-24T10:59:38.78318-08:00"
|
||||
version: 0.2.1
|
||||
digest: sha256:aedc211d9732c934be8b79735b62f8caa9bcd235e03fd0dd10b49e0a13ed15b7
|
||||
generated: "2026-02-20T11:19:47.957449-08:00"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user