mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-03-12 11:12:40 +00:00
Compare commits
22 Commits
xlsx-parse
...
v3.0.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79b615db46 | ||
|
|
98756bccd4 | ||
|
|
418f84ccdf | ||
|
|
d37756a884 | ||
|
|
9cdc92441b | ||
|
|
b8ed30644a | ||
|
|
d7d19e5a28 | ||
|
|
948650829d | ||
|
|
b6e689be0f | ||
|
|
85877408c8 | ||
|
|
c00df75c79 | ||
|
|
6352c9a09e | ||
|
|
3065f70d7d | ||
|
|
4befbc49dc | ||
|
|
ae9679e8c4 | ||
|
|
ea0ddee5c8 | ||
|
|
2826405dd2 | ||
|
|
8485bf4368 | ||
|
|
7bb52b0839 | ||
|
|
85a54c01f1 | ||
|
|
e4577bd564 | ||
|
|
f150a7b940 |
4
.github/workflows/pr-integration-tests.yml
vendored
4
.github/workflows/pr-integration-tests.yml
vendored
@@ -316,6 +316,7 @@ jobs:
|
||||
# Base config shared by both editions
|
||||
cat <<EOF > deployment/docker_compose/.env
|
||||
COMPOSE_PROFILES=s3-filestore
|
||||
OPENSEARCH_FOR_ONYX_ENABLED=false
|
||||
AUTH_TYPE=basic
|
||||
POSTGRES_POOL_PRE_PING=true
|
||||
POSTGRES_USE_NULL_POOL=true
|
||||
@@ -418,6 +419,7 @@ jobs:
|
||||
-e POSTGRES_POOL_PRE_PING=true \
|
||||
-e POSTGRES_USE_NULL_POOL=true \
|
||||
-e VESPA_HOST=index \
|
||||
-e ENABLE_OPENSEARCH_INDEXING_FOR_ONYX=false \
|
||||
-e REDIS_HOST=cache \
|
||||
-e API_SERVER_HOST=api_server \
|
||||
-e OPENAI_API_KEY=${OPENAI_API_KEY} \
|
||||
@@ -637,6 +639,7 @@ jobs:
|
||||
ONYX_BACKEND_IMAGE=${ECR_CACHE}:integration-test-backend-test-${RUN_ID} \
|
||||
ONYX_MODEL_SERVER_IMAGE=${ECR_CACHE}:integration-test-model-server-test-${RUN_ID} \
|
||||
DEV_MODE=true \
|
||||
OPENSEARCH_FOR_ONYX_ENABLED=false \
|
||||
docker compose -f docker-compose.multitenant-dev.yml up \
|
||||
relational_db \
|
||||
index \
|
||||
@@ -691,6 +694,7 @@ jobs:
|
||||
-e POSTGRES_DB=postgres \
|
||||
-e POSTGRES_USE_NULL_POOL=true \
|
||||
-e VESPA_HOST=index \
|
||||
-e ENABLE_OPENSEARCH_INDEXING_FOR_ONYX=false \
|
||||
-e REDIS_HOST=cache \
|
||||
-e API_SERVER_HOST=api_server \
|
||||
-e OPENAI_API_KEY=${OPENAI_API_KEY} \
|
||||
|
||||
3
.github/workflows/pr-playwright-tests.yml
vendored
3
.github/workflows/pr-playwright-tests.yml
vendored
@@ -12,6 +12,9 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
# TODO: Remove this if we enable merge-queues for release branches.
|
||||
branches:
|
||||
- "release/**"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -598,7 +598,7 @@ Before writing your plan, make sure to do research. Explore the relevant section
|
||||
Never hardcode status codes or use `starlette.status` / `fastapi.status` constants directly.**
|
||||
|
||||
A global FastAPI exception handler converts `OnyxError` into a JSON response with the standard
|
||||
`{"error_code": "...", "message": "..."}` shape. This eliminates boilerplate and keeps error
|
||||
`{"error_code": "...", "detail": "..."}` shape. This eliminates boilerplate and keeps error
|
||||
handling consistent across the entire backend.
|
||||
|
||||
```python
|
||||
|
||||
@@ -68,6 +68,7 @@ def get_external_access_for_raw_gdrive_file(
|
||||
company_domain: str,
|
||||
retriever_drive_service: GoogleDriveService | None,
|
||||
admin_drive_service: GoogleDriveService,
|
||||
fallback_user_email: str,
|
||||
add_prefix: bool = False,
|
||||
) -> ExternalAccess:
|
||||
"""
|
||||
@@ -79,6 +80,11 @@ def get_external_access_for_raw_gdrive_file(
|
||||
set add_prefix to True so group IDs are prefixed with the source type.
|
||||
When invoked from doc_sync (permission sync), use the default (False)
|
||||
since upsert_document_external_perms handles prefixing.
|
||||
fallback_user_email: When we cannot retrieve any permission info for a file
|
||||
(e.g. externally-owned files where the API returns no permissions
|
||||
and permissions.list returns 403), fall back to granting access
|
||||
to this user. This is typically the impersonated org user whose
|
||||
drive contained the file.
|
||||
"""
|
||||
doc_id = file.get("id")
|
||||
if not doc_id:
|
||||
@@ -117,6 +123,26 @@ def get_external_access_for_raw_gdrive_file(
|
||||
[permissions_list, backup_permissions_list]
|
||||
)
|
||||
|
||||
# For externally-owned files, the Drive API may return no permissions
|
||||
# and permissions.list may return 403. In this case, fall back to
|
||||
# granting access to the user who found the file in their drive.
|
||||
# Note, even if other users also have access to this file,
|
||||
# they will not be granted access in Onyx.
|
||||
# We check permissions_list (the final result after all fetch attempts)
|
||||
# rather than the raw fields, because permission_ids may be present
|
||||
# but the actual fetch can still return empty due to a 403.
|
||||
if not permissions_list:
|
||||
logger.info(
|
||||
f"No permission info available for file {doc_id} "
|
||||
f"(likely owned by a user outside of your organization). "
|
||||
f"Falling back to granting access to retriever user: {fallback_user_email}"
|
||||
)
|
||||
return ExternalAccess(
|
||||
external_user_emails={fallback_user_email},
|
||||
external_user_group_ids=set(),
|
||||
is_public=False,
|
||||
)
|
||||
|
||||
folder_ids_to_inherit_permissions_from: set[str] = set()
|
||||
user_emails: set[str] = set()
|
||||
group_emails: set[str] = set()
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from collections.abc import Generator
|
||||
from typing import Any
|
||||
|
||||
from jira import JIRA
|
||||
from jira.exceptions import JIRAError
|
||||
|
||||
from ee.onyx.db.external_perm import ExternalUserGroup
|
||||
from onyx.connectors.jira.utils import build_jira_client
|
||||
@@ -9,107 +11,102 @@ from onyx.utils.logger import setup_logger
|
||||
|
||||
logger = setup_logger()
|
||||
|
||||
_ATLASSIAN_ACCOUNT_TYPE = "atlassian"
|
||||
_GROUP_MEMBER_PAGE_SIZE = 50
|
||||
|
||||
def _get_jira_group_members_email(
|
||||
# The GET /group/member endpoint was introduced in Jira 6.0.
|
||||
# Jira versions older than 6.0 do not have group management REST APIs at all.
|
||||
_MIN_JIRA_VERSION_FOR_GROUP_MEMBER = "6.0"
|
||||
|
||||
|
||||
def _fetch_group_member_page(
|
||||
jira_client: JIRA,
|
||||
group_name: str,
|
||||
) -> list[str]:
|
||||
"""Get all member emails for a Jira group.
|
||||
start_at: int,
|
||||
) -> dict[str, Any]:
|
||||
"""Fetch a single page from the non-deprecated GET /group/member endpoint.
|
||||
|
||||
Filters out app accounts (bots, integrations) and only returns real user emails.
|
||||
The old GET /group endpoint (used by jira_client.group_members()) is deprecated
|
||||
and decommissioned in Jira Server 10.3+. This uses the replacement endpoint
|
||||
directly via the library's internal _get_json helper, following the same pattern
|
||||
as enhanced_search_ids / bulk_fetch_issues in connector.py.
|
||||
|
||||
There is an open PR to the library to switch to this endpoint since last year:
|
||||
https://github.com/pycontribs/jira/pull/2356
|
||||
so once it is merged and released, we can switch to using the library function.
|
||||
"""
|
||||
emails: list[str] = []
|
||||
|
||||
try:
|
||||
# group_members returns an OrderedDict of account_id -> member_info
|
||||
members = jira_client.group_members(group=group_name)
|
||||
|
||||
if not members:
|
||||
logger.warning(f"No members found for group {group_name}")
|
||||
return emails
|
||||
|
||||
for account_id, member_info in members.items():
|
||||
# member_info is a dict with keys like 'fullname', 'email', 'active'
|
||||
email = member_info.get("email")
|
||||
|
||||
# Skip "hidden" emails - these are typically app accounts
|
||||
if email and email != "hidden":
|
||||
emails.append(email)
|
||||
else:
|
||||
# For cloud, we might need to fetch user details separately
|
||||
try:
|
||||
user = jira_client.user(id=account_id)
|
||||
|
||||
# Skip app accounts (bots, integrations, etc.)
|
||||
if hasattr(user, "accountType") and user.accountType == "app":
|
||||
logger.info(
|
||||
f"Skipping app account {account_id} for group {group_name}"
|
||||
)
|
||||
continue
|
||||
|
||||
if hasattr(user, "emailAddress") and user.emailAddress:
|
||||
emails.append(user.emailAddress)
|
||||
else:
|
||||
logger.warning(f"User {account_id} has no email address")
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"Could not fetch email for user {account_id} in group {group_name}: {e}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching members for group {group_name}: {e}")
|
||||
|
||||
return emails
|
||||
return jira_client._get_json(
|
||||
"group/member",
|
||||
params={
|
||||
"groupname": group_name,
|
||||
"includeInactiveUsers": "false",
|
||||
"startAt": start_at,
|
||||
"maxResults": _GROUP_MEMBER_PAGE_SIZE,
|
||||
},
|
||||
)
|
||||
except JIRAError as e:
|
||||
if e.status_code == 404:
|
||||
raise RuntimeError(
|
||||
f"GET /group/member returned 404 for group '{group_name}'. "
|
||||
f"This endpoint requires Jira {_MIN_JIRA_VERSION_FOR_GROUP_MEMBER}+. "
|
||||
f"If you are running a self-hosted Jira instance, please upgrade "
|
||||
f"to at least Jira {_MIN_JIRA_VERSION_FOR_GROUP_MEMBER}."
|
||||
) from e
|
||||
raise
|
||||
|
||||
|
||||
def _build_group_member_email_map(
|
||||
def _get_group_member_emails(
|
||||
jira_client: JIRA,
|
||||
) -> dict[str, set[str]]:
|
||||
"""Build a map of group names to member emails."""
|
||||
group_member_emails: dict[str, set[str]] = {}
|
||||
group_name: str,
|
||||
) -> set[str]:
|
||||
"""Get all member emails for a single Jira group.
|
||||
|
||||
try:
|
||||
# Get all groups from Jira - returns a list of group name strings
|
||||
group_names = jira_client.groups()
|
||||
Uses the non-deprecated GET /group/member endpoint which returns full user
|
||||
objects including accountType, so we can filter out app/customer accounts
|
||||
without making separate user() calls.
|
||||
"""
|
||||
emails: set[str] = set()
|
||||
start_at = 0
|
||||
|
||||
if not group_names:
|
||||
logger.warning("No groups found in Jira")
|
||||
return group_member_emails
|
||||
while True:
|
||||
try:
|
||||
page = _fetch_group_member_page(jira_client, group_name, start_at)
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching members for group {group_name}: {e}")
|
||||
raise
|
||||
|
||||
logger.info(f"Found {len(group_names)} groups in Jira")
|
||||
|
||||
for group_name in group_names:
|
||||
if not group_name:
|
||||
members: list[dict[str, Any]] = page.get("values", [])
|
||||
for member in members:
|
||||
account_type = member.get("accountType")
|
||||
# On Jira DC < 9.0, accountType is absent; include those users.
|
||||
# On Cloud / DC 9.0+, filter to real user accounts only.
|
||||
if account_type is not None and account_type != _ATLASSIAN_ACCOUNT_TYPE:
|
||||
continue
|
||||
|
||||
member_emails = _get_jira_group_members_email(
|
||||
jira_client=jira_client,
|
||||
group_name=group_name,
|
||||
)
|
||||
|
||||
if member_emails:
|
||||
group_member_emails[group_name] = set(member_emails)
|
||||
logger.debug(
|
||||
f"Found {len(member_emails)} members for group {group_name}"
|
||||
)
|
||||
email = member.get("emailAddress")
|
||||
if email:
|
||||
emails.add(email)
|
||||
else:
|
||||
logger.debug(f"No members found for group {group_name}")
|
||||
logger.warning(
|
||||
f"Atlassian user {member.get('accountId', 'unknown')} "
|
||||
f"in group {group_name} has no visible email address"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error building group member email map: {e}")
|
||||
if page.get("isLast", True) or not members:
|
||||
break
|
||||
start_at += len(members)
|
||||
|
||||
return group_member_emails
|
||||
return emails
|
||||
|
||||
|
||||
def jira_group_sync(
|
||||
tenant_id: str, # noqa: ARG001
|
||||
cc_pair: ConnectorCredentialPair,
|
||||
) -> Generator[ExternalUserGroup, None, None]:
|
||||
"""
|
||||
Sync Jira groups and their members.
|
||||
"""Sync Jira groups and their members, yielding one group at a time.
|
||||
|
||||
This function fetches all groups from Jira and yields ExternalUserGroup
|
||||
objects containing the group ID and member emails.
|
||||
Streams group-by-group rather than accumulating all groups in memory.
|
||||
"""
|
||||
jira_base_url = cc_pair.connector.connector_specific_config.get("jira_base_url", "")
|
||||
scoped_token = cc_pair.connector.connector_specific_config.get(
|
||||
@@ -130,12 +127,26 @@ def jira_group_sync(
|
||||
scoped_token=scoped_token,
|
||||
)
|
||||
|
||||
group_member_email_map = _build_group_member_email_map(jira_client=jira_client)
|
||||
if not group_member_email_map:
|
||||
raise ValueError(f"No groups with members found for cc_pair_id={cc_pair.id}")
|
||||
group_names = jira_client.groups()
|
||||
if not group_names:
|
||||
raise ValueError(f"No groups found for cc_pair_id={cc_pair.id}")
|
||||
|
||||
for group_id, group_member_emails in group_member_email_map.items():
|
||||
yield ExternalUserGroup(
|
||||
id=group_id,
|
||||
user_emails=list(group_member_emails),
|
||||
logger.info(f"Found {len(group_names)} groups in Jira")
|
||||
|
||||
for group_name in group_names:
|
||||
if not group_name:
|
||||
continue
|
||||
|
||||
member_emails = _get_group_member_emails(
|
||||
jira_client=jira_client,
|
||||
group_name=group_name,
|
||||
)
|
||||
if not member_emails:
|
||||
logger.debug(f"No members found for group {group_name}")
|
||||
continue
|
||||
|
||||
logger.debug(f"Found {len(member_emails)} members for group {group_name}")
|
||||
yield ExternalUserGroup(
|
||||
id=group_name,
|
||||
user_emails=list(member_emails),
|
||||
)
|
||||
|
||||
@@ -26,6 +26,7 @@ from onyx.db.models import Tool
|
||||
from onyx.db.persona import upsert_persona
|
||||
from onyx.server.features.persona.models import PersonaUpsertRequest
|
||||
from onyx.server.manage.llm.models import LLMProviderUpsertRequest
|
||||
from onyx.server.manage.llm.models import LLMProviderView
|
||||
from onyx.server.settings.models import Settings
|
||||
from onyx.server.settings.store import store_settings as store_base_settings
|
||||
from onyx.utils.logger import setup_logger
|
||||
@@ -125,10 +126,16 @@ def _seed_llms(
|
||||
existing = fetch_existing_llm_provider(name=request.name, db_session=db_session)
|
||||
if existing:
|
||||
request.id = existing.id
|
||||
seeded_providers = [
|
||||
upsert_llm_provider(llm_upsert_request, db_session)
|
||||
for llm_upsert_request in llm_upsert_requests
|
||||
]
|
||||
seeded_providers: list[LLMProviderView] = []
|
||||
for llm_upsert_request in llm_upsert_requests:
|
||||
try:
|
||||
seeded_providers.append(upsert_llm_provider(llm_upsert_request, db_session))
|
||||
except ValueError as e:
|
||||
logger.warning(
|
||||
"Failed to upsert LLM provider '%s' during seeding: %s",
|
||||
llm_upsert_request.name,
|
||||
e,
|
||||
)
|
||||
|
||||
default_provider = next(
|
||||
(p for p in seeded_providers if p.model_configurations), None
|
||||
|
||||
@@ -50,6 +50,7 @@ from onyx.tools.built_in_tools import CITEABLE_TOOLS_NAMES
|
||||
from onyx.tools.built_in_tools import STOPPING_TOOLS_NAMES
|
||||
from onyx.tools.interface import Tool
|
||||
from onyx.tools.models import ChatFile
|
||||
from onyx.tools.models import CustomToolCallSummary
|
||||
from onyx.tools.models import MemoryToolResponseSnapshot
|
||||
from onyx.tools.models import PythonToolRichResponse
|
||||
from onyx.tools.models import ToolCallInfo
|
||||
@@ -980,6 +981,10 @@ def run_llm_loop(
|
||||
|
||||
if memory_snapshot:
|
||||
saved_response = json.dumps(memory_snapshot.model_dump())
|
||||
elif isinstance(tool_response.rich_response, CustomToolCallSummary):
|
||||
saved_response = json.dumps(
|
||||
tool_response.rich_response.model_dump()
|
||||
)
|
||||
elif isinstance(tool_response.rich_response, str):
|
||||
saved_response = tool_response.rich_response
|
||||
else:
|
||||
|
||||
@@ -288,8 +288,9 @@ OPENSEARCH_TEXT_ANALYZER = os.environ.get("OPENSEARCH_TEXT_ANALYZER") or "englis
|
||||
# environments we always want to be dual indexing into both OpenSearch and Vespa
|
||||
# to stress test the new codepaths. Only enable this if there is some instance
|
||||
# of OpenSearch running for the relevant Onyx instance.
|
||||
# NOTE: Now enabled on by default, unless the env indicates otherwise.
|
||||
ENABLE_OPENSEARCH_INDEXING_FOR_ONYX = (
|
||||
os.environ.get("ENABLE_OPENSEARCH_INDEXING_FOR_ONYX", "").lower() == "true"
|
||||
os.environ.get("ENABLE_OPENSEARCH_INDEXING_FOR_ONYX", "true").lower() == "true"
|
||||
)
|
||||
# NOTE: This effectively does nothing anymore, admins can now toggle whether
|
||||
# retrieval is through OpenSearch. This value is only used as a final fallback
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
from collections.abc import AsyncGenerator
|
||||
from collections.abc import AsyncIterable
|
||||
from collections.abc import Iterable
|
||||
from datetime import datetime
|
||||
@@ -204,7 +205,7 @@ def _manage_async_retrieval(
|
||||
|
||||
end_time: datetime | None = end
|
||||
|
||||
async def _async_fetch() -> AsyncIterable[Document]:
|
||||
async def _async_fetch() -> AsyncGenerator[Document, None]:
|
||||
intents = Intents.default()
|
||||
intents.message_content = True
|
||||
async with Client(intents=intents) as discord_client:
|
||||
@@ -227,22 +228,23 @@ def _manage_async_retrieval(
|
||||
|
||||
def run_and_yield() -> Iterable[Document]:
|
||||
loop = asyncio.new_event_loop()
|
||||
async_gen = _async_fetch()
|
||||
try:
|
||||
# Get the async generator
|
||||
async_gen = _async_fetch()
|
||||
# Convert to AsyncIterator
|
||||
async_iter = async_gen.__aiter__()
|
||||
while True:
|
||||
try:
|
||||
# Create a coroutine by calling anext with the async iterator
|
||||
next_coro = anext(async_iter)
|
||||
# Run the coroutine to get the next document
|
||||
doc = loop.run_until_complete(next_coro)
|
||||
doc = loop.run_until_complete(anext(async_gen))
|
||||
yield doc
|
||||
except StopAsyncIteration:
|
||||
break
|
||||
finally:
|
||||
loop.close()
|
||||
# Must close the async generator before the loop so the Discord
|
||||
# client's `async with` block can await its shutdown coroutine.
|
||||
# The nested try/finally ensures the loop always closes even if
|
||||
# aclose() raises (same pattern as cursor.close() before conn.close()).
|
||||
try:
|
||||
loop.run_until_complete(async_gen.aclose())
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
return run_and_yield()
|
||||
|
||||
|
||||
@@ -1722,6 +1722,7 @@ class GoogleDriveConnector(
|
||||
primary_admin_email=self.primary_admin_email,
|
||||
google_domain=self.google_domain,
|
||||
),
|
||||
retriever_email=file.user_email,
|
||||
):
|
||||
slim_batch.append(doc)
|
||||
|
||||
|
||||
@@ -476,6 +476,7 @@ def _get_external_access_for_raw_gdrive_file(
|
||||
company_domain: str,
|
||||
retriever_drive_service: GoogleDriveService | None,
|
||||
admin_drive_service: GoogleDriveService,
|
||||
fallback_user_email: str,
|
||||
add_prefix: bool = False,
|
||||
) -> ExternalAccess:
|
||||
"""
|
||||
@@ -484,6 +485,8 @@ def _get_external_access_for_raw_gdrive_file(
|
||||
add_prefix: When True, prefix group IDs with source type (for indexing path).
|
||||
When False (default), leave unprefixed (for permission sync path
|
||||
where upsert_document_external_perms handles prefixing).
|
||||
fallback_user_email: When permission info can't be retrieved (e.g. externally-owned
|
||||
files), fall back to granting access to this user.
|
||||
"""
|
||||
external_access_fn = cast(
|
||||
Callable[
|
||||
@@ -492,6 +495,7 @@ def _get_external_access_for_raw_gdrive_file(
|
||||
str,
|
||||
GoogleDriveService | None,
|
||||
GoogleDriveService,
|
||||
str,
|
||||
bool,
|
||||
],
|
||||
ExternalAccess,
|
||||
@@ -507,6 +511,7 @@ def _get_external_access_for_raw_gdrive_file(
|
||||
company_domain,
|
||||
retriever_drive_service,
|
||||
admin_drive_service,
|
||||
fallback_user_email,
|
||||
add_prefix,
|
||||
)
|
||||
|
||||
@@ -672,6 +677,7 @@ def _convert_drive_item_to_document(
|
||||
creds, user_email=permission_sync_context.primary_admin_email
|
||||
),
|
||||
add_prefix=True, # Indexing path - prefix here
|
||||
fallback_user_email=retriever_email,
|
||||
)
|
||||
if permission_sync_context
|
||||
else None
|
||||
@@ -753,6 +759,7 @@ def build_slim_document(
|
||||
# if not specified, we will not sync permissions
|
||||
# will also be a no-op if EE is not enabled
|
||||
permission_sync_context: PermissionSyncContext | None,
|
||||
retriever_email: str,
|
||||
) -> SlimDocument | None:
|
||||
if file.get("mimeType") in [DRIVE_FOLDER_TYPE, DRIVE_SHORTCUT_TYPE]:
|
||||
return None
|
||||
@@ -774,6 +781,7 @@ def build_slim_document(
|
||||
creds,
|
||||
user_email=permission_sync_context.primary_admin_email,
|
||||
),
|
||||
fallback_user_email=retriever_email,
|
||||
)
|
||||
if permission_sync_context
|
||||
else None
|
||||
|
||||
@@ -25,6 +25,7 @@ from onyx.server.manage.embedding.models import CloudEmbeddingProvider
|
||||
from onyx.server.manage.embedding.models import CloudEmbeddingProviderCreationRequest
|
||||
from onyx.server.manage.llm.models import LLMProviderUpsertRequest
|
||||
from onyx.server.manage.llm.models import LLMProviderView
|
||||
from onyx.server.manage.llm.models import SyncModelEntry
|
||||
from onyx.utils.logger import setup_logger
|
||||
from shared_configs.enums import EmbeddingProvider
|
||||
|
||||
@@ -270,10 +271,35 @@ def upsert_llm_provider(
|
||||
mc.name for mc in llm_provider_upsert_request.model_configurations
|
||||
}
|
||||
|
||||
# Build a lookup of requested visibility by model name
|
||||
requested_visibility = {
|
||||
mc.name: mc.is_visible
|
||||
for mc in llm_provider_upsert_request.model_configurations
|
||||
}
|
||||
|
||||
# Delete removed models
|
||||
removed_ids = [
|
||||
mc.id for name, mc in existing_by_name.items() if name not in models_to_exist
|
||||
]
|
||||
|
||||
default_model = fetch_default_llm_model(db_session)
|
||||
|
||||
# Prevent removing and hiding the default model
|
||||
if default_model:
|
||||
for name, mc in existing_by_name.items():
|
||||
if mc.id == default_model.id:
|
||||
if default_model.id in removed_ids:
|
||||
raise ValueError(
|
||||
f"Cannot remove the default model '{name}'. "
|
||||
"Please change the default model before removing."
|
||||
)
|
||||
if not requested_visibility.get(name, True):
|
||||
raise ValueError(
|
||||
f"Cannot hide the default model '{name}'. "
|
||||
"Please change the default model before hiding."
|
||||
)
|
||||
break
|
||||
|
||||
if removed_ids:
|
||||
db_session.query(ModelConfiguration).filter(
|
||||
ModelConfiguration.id.in_(removed_ids)
|
||||
@@ -344,9 +370,9 @@ def upsert_llm_provider(
|
||||
def sync_model_configurations(
|
||||
db_session: Session,
|
||||
provider_name: str,
|
||||
models: list[dict],
|
||||
models: list[SyncModelEntry],
|
||||
) -> int:
|
||||
"""Sync model configurations for a dynamic provider (OpenRouter, Bedrock, Ollama).
|
||||
"""Sync model configurations for a dynamic provider (OpenRouter, Bedrock, Ollama, etc.).
|
||||
|
||||
This inserts NEW models from the source API without overwriting existing ones.
|
||||
User preferences (is_visible, max_input_tokens) are preserved for existing models.
|
||||
@@ -354,7 +380,7 @@ def sync_model_configurations(
|
||||
Args:
|
||||
db_session: Database session
|
||||
provider_name: Name of the LLM provider
|
||||
models: List of model dicts with keys: name, display_name, max_input_tokens, supports_image_input
|
||||
models: List of SyncModelEntry objects describing the fetched models
|
||||
|
||||
Returns:
|
||||
Number of new models added
|
||||
@@ -368,21 +394,20 @@ def sync_model_configurations(
|
||||
|
||||
new_count = 0
|
||||
for model in models:
|
||||
model_name = model["name"]
|
||||
if model_name not in existing_names:
|
||||
if model.name not in existing_names:
|
||||
# Insert new model with is_visible=False (user must explicitly enable)
|
||||
supported_flows = [LLMModelFlowType.CHAT]
|
||||
if model.get("supports_image_input", False):
|
||||
if model.supports_image_input:
|
||||
supported_flows.append(LLMModelFlowType.VISION)
|
||||
|
||||
insert_new_model_configuration__no_commit(
|
||||
db_session=db_session,
|
||||
llm_provider_id=provider.id,
|
||||
model_name=model_name,
|
||||
model_name=model.name,
|
||||
supported_flows=supported_flows,
|
||||
is_visible=False,
|
||||
max_input_tokens=model.get("max_input_tokens"),
|
||||
display_name=model.get("display_name"),
|
||||
max_input_tokens=model.max_input_tokens,
|
||||
display_name=model.display_name,
|
||||
)
|
||||
new_count += 1
|
||||
|
||||
@@ -538,7 +563,6 @@ def fetch_default_model(
|
||||
.options(selectinload(ModelConfiguration.llm_provider))
|
||||
.join(LLMModelFlow)
|
||||
.where(
|
||||
ModelConfiguration.is_visible == True, # noqa: E712
|
||||
LLMModelFlow.llm_model_flow_type == flow_type,
|
||||
LLMModelFlow.is_default == True, # noqa: E712
|
||||
)
|
||||
@@ -814,44 +838,30 @@ def sync_auto_mode_models(
|
||||
)
|
||||
changes += 1
|
||||
|
||||
db_session.commit()
|
||||
# Update the default if this provider currently holds the global CHAT default.
|
||||
# We flush (but don't commit) so that _update_default_model can see the new
|
||||
# model rows, then commit everything atomically to avoid a window where the
|
||||
# old default is invisible but still pointed-to.
|
||||
db_session.flush()
|
||||
|
||||
# Update the default if this provider currently holds the global CHAT default
|
||||
recommended_default = llm_recommendations.get_default_model(provider.provider)
|
||||
if recommended_default:
|
||||
current_default_name = db_session.scalar(
|
||||
select(ModelConfiguration.name)
|
||||
.join(
|
||||
LLMModelFlow,
|
||||
LLMModelFlow.model_configuration_id == ModelConfiguration.id,
|
||||
)
|
||||
.where(
|
||||
ModelConfiguration.llm_provider_id == provider.id,
|
||||
LLMModelFlow.llm_model_flow_type == LLMModelFlowType.CHAT,
|
||||
LLMModelFlow.is_default == True, # noqa: E712
|
||||
)
|
||||
)
|
||||
current_default = fetch_default_llm_model(db_session)
|
||||
|
||||
if (
|
||||
current_default_name is not None
|
||||
and current_default_name != recommended_default.name
|
||||
current_default
|
||||
and current_default.llm_provider_id == provider.id
|
||||
and current_default.name != recommended_default.name
|
||||
):
|
||||
try:
|
||||
_update_default_model(
|
||||
db_session=db_session,
|
||||
provider_id=provider.id,
|
||||
model=recommended_default.name,
|
||||
flow_type=LLMModelFlowType.CHAT,
|
||||
)
|
||||
changes += 1
|
||||
except ValueError:
|
||||
logger.warning(
|
||||
"Recommended default model '%s' not found "
|
||||
"for provider_id=%s; skipping default update.",
|
||||
recommended_default.name,
|
||||
provider.id,
|
||||
)
|
||||
_update_default_model__no_commit(
|
||||
db_session=db_session,
|
||||
provider_id=provider.id,
|
||||
model=recommended_default.name,
|
||||
flow_type=LLMModelFlowType.CHAT,
|
||||
)
|
||||
changes += 1
|
||||
|
||||
db_session.commit()
|
||||
return changes
|
||||
|
||||
|
||||
@@ -982,7 +992,7 @@ def update_model_configuration__no_commit(
|
||||
db_session.flush()
|
||||
|
||||
|
||||
def _update_default_model(
|
||||
def _update_default_model__no_commit(
|
||||
db_session: Session,
|
||||
provider_id: int,
|
||||
model: str,
|
||||
@@ -1020,6 +1030,14 @@ def _update_default_model(
|
||||
new_default.is_default = True
|
||||
model_config.is_visible = True
|
||||
|
||||
|
||||
def _update_default_model(
|
||||
db_session: Session,
|
||||
provider_id: int,
|
||||
model: str,
|
||||
flow_type: LLMModelFlowType,
|
||||
) -> None:
|
||||
_update_default_model__no_commit(db_session, provider_id, model, flow_type)
|
||||
db_session.commit()
|
||||
|
||||
|
||||
|
||||
@@ -91,11 +91,11 @@ class OnyxErrorCode(Enum):
|
||||
"""Build a structured error detail dict.
|
||||
|
||||
Returns a dict like:
|
||||
{"error_code": "UNAUTHENTICATED", "message": "Token expired"}
|
||||
{"error_code": "UNAUTHENTICATED", "detail": "Token expired"}
|
||||
|
||||
If no message is supplied, the error code itself is used as the message.
|
||||
If no message is supplied, the error code itself is used as the detail.
|
||||
"""
|
||||
return {
|
||||
"error_code": self.code,
|
||||
"message": message or self.code,
|
||||
"detail": message or self.code,
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
Raise ``OnyxError`` instead of ``HTTPException`` in business code. A global
|
||||
FastAPI exception handler (registered via ``register_onyx_exception_handlers``)
|
||||
converts it into a JSON response with the standard
|
||||
``{"error_code": "...", "message": "..."}`` shape.
|
||||
``{"error_code": "...", "detail": "..."}`` shape.
|
||||
|
||||
Usage::
|
||||
|
||||
@@ -37,21 +37,21 @@ class OnyxError(Exception):
|
||||
|
||||
Attributes:
|
||||
error_code: The ``OnyxErrorCode`` enum member.
|
||||
message: Human-readable message (defaults to the error code string).
|
||||
detail: Human-readable detail (defaults to the error code string).
|
||||
status_code: HTTP status — either overridden or from the error code.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
error_code: OnyxErrorCode,
|
||||
message: str | None = None,
|
||||
detail: str | None = None,
|
||||
*,
|
||||
status_code_override: int | None = None,
|
||||
) -> None:
|
||||
resolved_message = message or error_code.code
|
||||
super().__init__(resolved_message)
|
||||
resolved_detail = detail or error_code.code
|
||||
super().__init__(resolved_detail)
|
||||
self.error_code = error_code
|
||||
self.message = resolved_message
|
||||
self.detail = resolved_detail
|
||||
self._status_code_override = status_code_override
|
||||
|
||||
@property
|
||||
@@ -73,11 +73,11 @@ def register_onyx_exception_handlers(app: FastAPI) -> None:
|
||||
) -> JSONResponse:
|
||||
status_code = exc.status_code
|
||||
if status_code >= 500:
|
||||
logger.error(f"OnyxError {exc.error_code.code}: {exc.message}")
|
||||
logger.error(f"OnyxError {exc.error_code.code}: {exc.detail}")
|
||||
elif status_code >= 400:
|
||||
logger.warning(f"OnyxError {exc.error_code.code}: {exc.message}")
|
||||
logger.warning(f"OnyxError {exc.error_code.code}: {exc.detail}")
|
||||
|
||||
return JSONResponse(
|
||||
status_code=status_code,
|
||||
content=exc.error_code.detail(exc.message),
|
||||
content=exc.error_code.detail(exc.detail),
|
||||
)
|
||||
|
||||
@@ -43,6 +43,7 @@ WELL_KNOWN_PROVIDER_NAMES = [
|
||||
LlmProviderNames.AZURE,
|
||||
LlmProviderNames.OLLAMA_CHAT,
|
||||
LlmProviderNames.LM_STUDIO,
|
||||
LlmProviderNames.LITELLM_PROXY,
|
||||
]
|
||||
|
||||
|
||||
@@ -59,6 +60,7 @@ PROVIDER_DISPLAY_NAMES: dict[str, str] = {
|
||||
"ollama": "Ollama",
|
||||
LlmProviderNames.OLLAMA_CHAT: "Ollama",
|
||||
LlmProviderNames.LM_STUDIO: "LM Studio",
|
||||
LlmProviderNames.LITELLM_PROXY: "LiteLLM Proxy",
|
||||
"groq": "Groq",
|
||||
"anyscale": "Anyscale",
|
||||
"deepseek": "DeepSeek",
|
||||
@@ -109,6 +111,7 @@ AGGREGATOR_PROVIDERS: set[str] = {
|
||||
LlmProviderNames.LM_STUDIO,
|
||||
LlmProviderNames.VERTEX_AI,
|
||||
LlmProviderNames.AZURE,
|
||||
LlmProviderNames.LITELLM_PROXY,
|
||||
}
|
||||
|
||||
# Model family name mappings for display name generation
|
||||
|
||||
@@ -11,6 +11,8 @@ OLLAMA_API_KEY_CONFIG_KEY = "OLLAMA_API_KEY"
|
||||
LM_STUDIO_PROVIDER_NAME = "lm_studio"
|
||||
LM_STUDIO_API_KEY_CONFIG_KEY = "LM_STUDIO_API_KEY"
|
||||
|
||||
LITELLM_PROXY_PROVIDER_NAME = "litellm_proxy"
|
||||
|
||||
# Providers that use optional Bearer auth from custom_config
|
||||
PROVIDERS_WITH_SPECIAL_API_KEY_HANDLING: dict[str, str] = {
|
||||
LlmProviderNames.OLLAMA_CHAT: OLLAMA_API_KEY_CONFIG_KEY,
|
||||
|
||||
@@ -15,6 +15,7 @@ from onyx.llm.well_known_providers.auto_update_service import (
|
||||
from onyx.llm.well_known_providers.constants import ANTHROPIC_PROVIDER_NAME
|
||||
from onyx.llm.well_known_providers.constants import AZURE_PROVIDER_NAME
|
||||
from onyx.llm.well_known_providers.constants import BEDROCK_PROVIDER_NAME
|
||||
from onyx.llm.well_known_providers.constants import LITELLM_PROXY_PROVIDER_NAME
|
||||
from onyx.llm.well_known_providers.constants import LM_STUDIO_PROVIDER_NAME
|
||||
from onyx.llm.well_known_providers.constants import OLLAMA_PROVIDER_NAME
|
||||
from onyx.llm.well_known_providers.constants import OPENAI_PROVIDER_NAME
|
||||
@@ -47,6 +48,7 @@ def _get_provider_to_models_map() -> dict[str, list[str]]:
|
||||
OLLAMA_PROVIDER_NAME: [], # Dynamic - fetched from Ollama API
|
||||
LM_STUDIO_PROVIDER_NAME: [], # Dynamic - fetched from LM Studio API
|
||||
OPENROUTER_PROVIDER_NAME: [], # Dynamic - fetched from OpenRouter API
|
||||
LITELLM_PROXY_PROVIDER_NAME: [], # Dynamic - fetched from LiteLLM proxy API
|
||||
}
|
||||
|
||||
|
||||
@@ -331,6 +333,7 @@ def get_provider_display_name(provider_name: str) -> str:
|
||||
BEDROCK_PROVIDER_NAME: "Amazon Bedrock",
|
||||
VERTEXAI_PROVIDER_NAME: "Google Vertex AI",
|
||||
OPENROUTER_PROVIDER_NAME: "OpenRouter",
|
||||
LITELLM_PROXY_PROVIDER_NAME: "LiteLLM Proxy",
|
||||
}
|
||||
|
||||
if provider_name in _ONYX_PROVIDER_DISPLAY_NAMES:
|
||||
|
||||
@@ -10,6 +10,7 @@ from onyx.mcp_server.utils import get_indexed_sources
|
||||
from onyx.mcp_server.utils import require_access_token
|
||||
from onyx.utils.logger import setup_logger
|
||||
from onyx.utils.variable_functionality import build_api_server_url_for_http_requests
|
||||
from onyx.utils.variable_functionality import global_version
|
||||
|
||||
logger = setup_logger()
|
||||
|
||||
@@ -26,6 +27,14 @@ async def search_indexed_documents(
|
||||
Use this tool for information that is not public knowledge and specific to the user,
|
||||
their team, their work, or their organization/company.
|
||||
|
||||
Note: In CE mode, this tool uses the chat endpoint internally which invokes an LLM
|
||||
on every call, consuming tokens and adding latency.
|
||||
Additionally, CE callers receive a truncated snippet (blurb) instead of a full document chunk,
|
||||
but this should still be sufficient for most use cases. CE mode functionality should be swapped
|
||||
when a dedicated CE search endpoint is implemented.
|
||||
|
||||
In EE mode, the dedicated search endpoint is used instead.
|
||||
|
||||
To find a list of available sources, use the `indexed_sources` resource.
|
||||
Returns chunks of text as search results with snippets, scores, and metadata.
|
||||
|
||||
@@ -111,48 +120,73 @@ async def search_indexed_documents(
|
||||
if time_cutoff_dt:
|
||||
filters["time_cutoff"] = time_cutoff_dt.isoformat()
|
||||
|
||||
# Build the search request using the new SendSearchQueryRequest format
|
||||
search_request = {
|
||||
"search_query": query,
|
||||
"filters": filters,
|
||||
"num_docs_fed_to_llm_selection": limit,
|
||||
"run_query_expansion": False,
|
||||
"include_content": True,
|
||||
"stream": False,
|
||||
}
|
||||
is_ee = global_version.is_ee_version()
|
||||
base_url = build_api_server_url_for_http_requests(respect_env_override_if_set=True)
|
||||
auth_headers = {"Authorization": f"Bearer {access_token.token}"}
|
||||
|
||||
search_request: dict[str, Any]
|
||||
if is_ee:
|
||||
# EE: use the dedicated search endpoint (no LLM invocation)
|
||||
search_request = {
|
||||
"search_query": query,
|
||||
"filters": filters,
|
||||
"num_docs_fed_to_llm_selection": limit,
|
||||
"run_query_expansion": False,
|
||||
"include_content": True,
|
||||
"stream": False,
|
||||
}
|
||||
endpoint = f"{base_url}/search/send-search-message"
|
||||
error_key = "error"
|
||||
docs_key = "search_docs"
|
||||
content_field = "content"
|
||||
else:
|
||||
# CE: fall back to the chat endpoint (invokes LLM, consumes tokens)
|
||||
search_request = {
|
||||
"message": query,
|
||||
"stream": False,
|
||||
"chat_session_info": {},
|
||||
}
|
||||
if filters:
|
||||
search_request["internal_search_filters"] = filters
|
||||
endpoint = f"{base_url}/chat/send-chat-message"
|
||||
error_key = "error_msg"
|
||||
docs_key = "top_documents"
|
||||
content_field = "blurb"
|
||||
|
||||
# Call the API server using the new send-search-message route
|
||||
try:
|
||||
response = await get_http_client().post(
|
||||
f"{build_api_server_url_for_http_requests(respect_env_override_if_set=True)}/search/send-search-message",
|
||||
endpoint,
|
||||
json=search_request,
|
||||
headers={"Authorization": f"Bearer {access_token.token}"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
response.raise_for_status()
|
||||
result = response.json()
|
||||
|
||||
# Check for error in response
|
||||
if result.get("error"):
|
||||
if result.get(error_key):
|
||||
return {
|
||||
"documents": [],
|
||||
"total_results": 0,
|
||||
"query": query,
|
||||
"error": result.get("error"),
|
||||
"error": result.get(error_key),
|
||||
}
|
||||
|
||||
# Return simplified format for MCP clients
|
||||
fields_to_return = [
|
||||
"semantic_identifier",
|
||||
"content",
|
||||
"source_type",
|
||||
"link",
|
||||
"score",
|
||||
]
|
||||
documents = [
|
||||
{key: doc.get(key) for key in fields_to_return}
|
||||
for doc in result.get("search_docs", [])
|
||||
{
|
||||
"semantic_identifier": doc.get("semantic_identifier"),
|
||||
"content": doc.get(content_field),
|
||||
"source_type": doc.get("source_type"),
|
||||
"link": doc.get("link"),
|
||||
"score": doc.get("score"),
|
||||
}
|
||||
for doc in result.get(docs_key, [])
|
||||
]
|
||||
|
||||
# NOTE: search depth is controlled by the backend persona defaults, not `limit`.
|
||||
# `limit` only caps the returned list; fewer results may be returned if the
|
||||
# backend retrieves fewer documents than requested.
|
||||
documents = documents[:limit]
|
||||
|
||||
logger.info(
|
||||
f"Onyx MCP Server: Internal search returned {len(documents)} results"
|
||||
)
|
||||
@@ -160,7 +194,6 @@ async def search_indexed_documents(
|
||||
"documents": documents,
|
||||
"total_results": len(documents),
|
||||
"query": query,
|
||||
"executed_queries": result.get("all_executed_queries", [query]),
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"Onyx MCP Server: Document search error: {e}", exc_info=True)
|
||||
|
||||
@@ -58,6 +58,9 @@ 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 LitellmFinalModelResponse
|
||||
from onyx.server.manage.llm.models import LitellmModelDetails
|
||||
from onyx.server.manage.llm.models import LitellmModelsRequest
|
||||
from onyx.server.manage.llm.models import LLMCost
|
||||
from onyx.server.manage.llm.models import LLMProviderDescriptor
|
||||
from onyx.server.manage.llm.models import LLMProviderResponse
|
||||
@@ -65,12 +68,14 @@ from onyx.server.manage.llm.models import LLMProviderUpsertRequest
|
||||
from onyx.server.manage.llm.models import LLMProviderView
|
||||
from onyx.server.manage.llm.models import LMStudioFinalModelResponse
|
||||
from onyx.server.manage.llm.models import LMStudioModelsRequest
|
||||
from onyx.server.manage.llm.models import ModelConfigurationUpsertRequest
|
||||
from onyx.server.manage.llm.models import OllamaFinalModelResponse
|
||||
from onyx.server.manage.llm.models import OllamaModelDetails
|
||||
from onyx.server.manage.llm.models import OllamaModelsRequest
|
||||
from onyx.server.manage.llm.models import OpenRouterFinalModelResponse
|
||||
from onyx.server.manage.llm.models import OpenRouterModelDetails
|
||||
from onyx.server.manage.llm.models import OpenRouterModelsRequest
|
||||
from onyx.server.manage.llm.models import SyncModelEntry
|
||||
from onyx.server.manage.llm.models import TestLLMRequest
|
||||
from onyx.server.manage.llm.models import VisionProviderResponse
|
||||
from onyx.server.manage.llm.utils import generate_bedrock_display_name
|
||||
@@ -97,6 +102,34 @@ def _mask_string(value: str) -> str:
|
||||
return value[:4] + "****" + value[-4:]
|
||||
|
||||
|
||||
def _sync_fetched_models(
|
||||
db_session: Session,
|
||||
provider_name: str,
|
||||
models: list[SyncModelEntry],
|
||||
source_label: str,
|
||||
) -> None:
|
||||
"""Sync fetched models to DB for the given provider.
|
||||
|
||||
Args:
|
||||
db_session: Database session
|
||||
provider_name: Name of the LLM provider
|
||||
models: List of SyncModelEntry objects describing the fetched models
|
||||
source_label: Human-readable label for log messages (e.g. "Bedrock", "LiteLLM")
|
||||
"""
|
||||
try:
|
||||
new_count = sync_model_configurations(
|
||||
db_session=db_session,
|
||||
provider_name=provider_name,
|
||||
models=models,
|
||||
)
|
||||
if new_count > 0:
|
||||
logger.info(
|
||||
f"Added {new_count} new {source_label} models to provider '{provider_name}'"
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.warning(f"Failed to sync {source_label} models to DB: {e}")
|
||||
|
||||
|
||||
# Keys in custom_config that contain sensitive credentials
|
||||
_SENSITIVE_CONFIG_KEYS = {
|
||||
"vertex_credentials",
|
||||
@@ -445,16 +478,17 @@ def put_llm_provider(
|
||||
not existing_provider or not existing_provider.is_auto_mode
|
||||
)
|
||||
|
||||
# Before the upsert, check if this provider currently owns the global
|
||||
# CHAT default. The upsert may cascade-delete model_configurations
|
||||
# (and their flow mappings), so we need to remember this beforehand.
|
||||
was_default_provider = False
|
||||
if existing_provider and transitioning_to_auto_mode:
|
||||
current_default = fetch_default_llm_model(db_session)
|
||||
was_default_provider = (
|
||||
current_default is not None
|
||||
and current_default.llm_provider_id == existing_provider.id
|
||||
)
|
||||
# When transitioning to auto mode, preserve existing model configurations
|
||||
# so the upsert doesn't try to delete them (which would trip the default
|
||||
# model protection guard). sync_auto_mode_models will handle the model
|
||||
# lifecycle afterward — adding new models, hiding removed ones, and
|
||||
# updating the default. This is safe even if sync fails: the provider
|
||||
# keeps its old models and default rather than losing them.
|
||||
if transitioning_to_auto_mode and existing_provider:
|
||||
llm_provider_upsert_request.model_configurations = [
|
||||
ModelConfigurationUpsertRequest.from_model(mc)
|
||||
for mc in existing_provider.model_configurations
|
||||
]
|
||||
|
||||
try:
|
||||
result = upsert_llm_provider(
|
||||
@@ -468,7 +502,6 @@ def put_llm_provider(
|
||||
|
||||
config = fetch_llm_recommendations_from_github()
|
||||
if config and llm_provider_upsert_request.provider in config.providers:
|
||||
# Refetch the provider to get the updated model
|
||||
updated_provider = fetch_existing_llm_provider_by_id(
|
||||
id=result.id, db_session=db_session
|
||||
)
|
||||
@@ -478,20 +511,6 @@ def put_llm_provider(
|
||||
updated_provider,
|
||||
config,
|
||||
)
|
||||
|
||||
# If this provider was the default before the transition,
|
||||
# restore the default using the recommended model.
|
||||
if was_default_provider:
|
||||
recommended = config.get_default_model(
|
||||
llm_provider_upsert_request.provider
|
||||
)
|
||||
if recommended:
|
||||
update_default_provider(
|
||||
provider_id=updated_provider.id,
|
||||
model_name=recommended.name,
|
||||
db_session=db_session,
|
||||
)
|
||||
|
||||
# Refresh result with synced models
|
||||
result = LLMProviderView.from_model(updated_provider)
|
||||
|
||||
@@ -976,27 +995,20 @@ def get_bedrock_available_models(
|
||||
|
||||
# Sync new models to DB if provider_name is specified
|
||||
if request.provider_name:
|
||||
try:
|
||||
models_to_sync = [
|
||||
{
|
||||
"name": r.name,
|
||||
"display_name": r.display_name,
|
||||
"max_input_tokens": r.max_input_tokens,
|
||||
"supports_image_input": r.supports_image_input,
|
||||
}
|
||||
for r in results
|
||||
]
|
||||
new_count = sync_model_configurations(
|
||||
db_session=db_session,
|
||||
provider_name=request.provider_name,
|
||||
models=models_to_sync,
|
||||
)
|
||||
if new_count > 0:
|
||||
logger.info(
|
||||
f"Added {new_count} new Bedrock models to provider '{request.provider_name}'"
|
||||
_sync_fetched_models(
|
||||
db_session=db_session,
|
||||
provider_name=request.provider_name,
|
||||
models=[
|
||||
SyncModelEntry(
|
||||
name=r.name,
|
||||
display_name=r.display_name,
|
||||
max_input_tokens=r.max_input_tokens,
|
||||
supports_image_input=r.supports_image_input,
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.warning(f"Failed to sync Bedrock models to DB: {e}")
|
||||
for r in results
|
||||
],
|
||||
source_label="Bedrock",
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
@@ -1114,27 +1126,20 @@ def get_ollama_available_models(
|
||||
|
||||
# Sync new models to DB if provider_name is specified
|
||||
if request.provider_name:
|
||||
try:
|
||||
models_to_sync = [
|
||||
{
|
||||
"name": r.name,
|
||||
"display_name": r.display_name,
|
||||
"max_input_tokens": r.max_input_tokens,
|
||||
"supports_image_input": r.supports_image_input,
|
||||
}
|
||||
for r in sorted_results
|
||||
]
|
||||
new_count = sync_model_configurations(
|
||||
db_session=db_session,
|
||||
provider_name=request.provider_name,
|
||||
models=models_to_sync,
|
||||
)
|
||||
if new_count > 0:
|
||||
logger.info(
|
||||
f"Added {new_count} new Ollama models to provider '{request.provider_name}'"
|
||||
_sync_fetched_models(
|
||||
db_session=db_session,
|
||||
provider_name=request.provider_name,
|
||||
models=[
|
||||
SyncModelEntry(
|
||||
name=r.name,
|
||||
display_name=r.display_name,
|
||||
max_input_tokens=r.max_input_tokens,
|
||||
supports_image_input=r.supports_image_input,
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.warning(f"Failed to sync Ollama models to DB: {e}")
|
||||
for r in sorted_results
|
||||
],
|
||||
source_label="Ollama",
|
||||
)
|
||||
|
||||
return sorted_results
|
||||
|
||||
@@ -1223,27 +1228,20 @@ def get_openrouter_available_models(
|
||||
|
||||
# Sync new models to DB if provider_name is specified
|
||||
if request.provider_name:
|
||||
try:
|
||||
models_to_sync = [
|
||||
{
|
||||
"name": r.name,
|
||||
"display_name": r.display_name,
|
||||
"max_input_tokens": r.max_input_tokens,
|
||||
"supports_image_input": r.supports_image_input,
|
||||
}
|
||||
for r in sorted_results
|
||||
]
|
||||
new_count = sync_model_configurations(
|
||||
db_session=db_session,
|
||||
provider_name=request.provider_name,
|
||||
models=models_to_sync,
|
||||
)
|
||||
if new_count > 0:
|
||||
logger.info(
|
||||
f"Added {new_count} new OpenRouter models to provider '{request.provider_name}'"
|
||||
_sync_fetched_models(
|
||||
db_session=db_session,
|
||||
provider_name=request.provider_name,
|
||||
models=[
|
||||
SyncModelEntry(
|
||||
name=r.name,
|
||||
display_name=r.display_name,
|
||||
max_input_tokens=r.max_input_tokens,
|
||||
supports_image_input=r.supports_image_input,
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.warning(f"Failed to sync OpenRouter models to DB: {e}")
|
||||
for r in sorted_results
|
||||
],
|
||||
source_label="OpenRouter",
|
||||
)
|
||||
|
||||
return sorted_results
|
||||
|
||||
@@ -1337,26 +1335,119 @@ def get_lm_studio_available_models(
|
||||
|
||||
# Sync new models to DB if provider_name is specified
|
||||
if request.provider_name:
|
||||
try:
|
||||
models_to_sync = [
|
||||
{
|
||||
"name": r.name,
|
||||
"display_name": r.display_name,
|
||||
"max_input_tokens": r.max_input_tokens,
|
||||
"supports_image_input": r.supports_image_input,
|
||||
}
|
||||
for r in sorted_results
|
||||
]
|
||||
new_count = sync_model_configurations(
|
||||
db_session=db_session,
|
||||
provider_name=request.provider_name,
|
||||
models=models_to_sync,
|
||||
)
|
||||
if new_count > 0:
|
||||
logger.info(
|
||||
f"Added {new_count} new LM Studio models to provider '{request.provider_name}'"
|
||||
_sync_fetched_models(
|
||||
db_session=db_session,
|
||||
provider_name=request.provider_name,
|
||||
models=[
|
||||
SyncModelEntry(
|
||||
name=r.name,
|
||||
display_name=r.display_name,
|
||||
max_input_tokens=r.max_input_tokens,
|
||||
supports_image_input=r.supports_image_input,
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.warning(f"Failed to sync LM Studio models to DB: {e}")
|
||||
for r in sorted_results
|
||||
],
|
||||
source_label="LM Studio",
|
||||
)
|
||||
|
||||
return sorted_results
|
||||
|
||||
|
||||
@admin_router.post("/litellm/available-models")
|
||||
def get_litellm_available_models(
|
||||
request: LitellmModelsRequest,
|
||||
_: User = Depends(current_admin_user),
|
||||
db_session: Session = Depends(get_session),
|
||||
) -> list[LitellmFinalModelResponse]:
|
||||
"""Fetch available models from Litellm proxy /v1/models endpoint."""
|
||||
response_json = _get_litellm_models_response(
|
||||
api_key=request.api_key, api_base=request.api_base
|
||||
)
|
||||
|
||||
models = response_json.get("data", [])
|
||||
if not isinstance(models, list) or len(models) == 0:
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.VALIDATION_ERROR,
|
||||
"No models found from your Litellm endpoint",
|
||||
)
|
||||
|
||||
results: list[LitellmFinalModelResponse] = []
|
||||
for model in models:
|
||||
try:
|
||||
model_details = LitellmModelDetails.model_validate(model)
|
||||
|
||||
results.append(
|
||||
LitellmFinalModelResponse(
|
||||
provider_name=model_details.owned_by,
|
||||
model_name=model_details.id,
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Failed to parse Litellm model entry",
|
||||
extra={"error": str(e), "item": str(model)[:1000]},
|
||||
)
|
||||
|
||||
if not results:
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.VALIDATION_ERROR,
|
||||
"No compatible models found from Litellm",
|
||||
)
|
||||
|
||||
sorted_results = sorted(results, key=lambda m: m.model_name.lower())
|
||||
|
||||
# Sync new models to DB if provider_name is specified
|
||||
if request.provider_name:
|
||||
_sync_fetched_models(
|
||||
db_session=db_session,
|
||||
provider_name=request.provider_name,
|
||||
models=[
|
||||
SyncModelEntry(
|
||||
name=r.model_name,
|
||||
display_name=r.model_name,
|
||||
)
|
||||
for r in sorted_results
|
||||
],
|
||||
source_label="LiteLLM",
|
||||
)
|
||||
|
||||
return sorted_results
|
||||
|
||||
|
||||
def _get_litellm_models_response(api_key: str, api_base: str) -> dict:
|
||||
"""Perform GET to Litellm proxy /api/v1/models and return parsed JSON."""
|
||||
cleaned_api_base = api_base.strip().rstrip("/")
|
||||
url = f"{cleaned_api_base}/v1/models"
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"HTTP-Referer": "https://onyx.app",
|
||||
"X-Title": "Onyx",
|
||||
}
|
||||
|
||||
try:
|
||||
response = httpx.get(url, headers=headers, timeout=10.0)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except httpx.HTTPStatusError as e:
|
||||
if e.response.status_code == 401:
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.VALIDATION_ERROR,
|
||||
"Authentication failed: invalid or missing API key for LiteLLM proxy.",
|
||||
)
|
||||
elif e.response.status_code == 404:
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.VALIDATION_ERROR,
|
||||
f"LiteLLM models endpoint not found at {url}. "
|
||||
"Please verify the API base URL.",
|
||||
)
|
||||
else:
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.BAD_GATEWAY,
|
||||
f"Failed to fetch LiteLLM models: {e}",
|
||||
)
|
||||
except Exception as e:
|
||||
raise OnyxError(
|
||||
OnyxErrorCode.BAD_GATEWAY,
|
||||
f"Failed to fetch LiteLLM models: {e}",
|
||||
)
|
||||
|
||||
@@ -420,3 +420,32 @@ class LLMProviderResponse(BaseModel, Generic[T]):
|
||||
default_text=default_text,
|
||||
default_vision=default_vision,
|
||||
)
|
||||
|
||||
|
||||
class SyncModelEntry(BaseModel):
|
||||
"""Typed model for syncing fetched models to the DB."""
|
||||
|
||||
name: str
|
||||
display_name: str
|
||||
max_input_tokens: int | None = None
|
||||
supports_image_input: bool = False
|
||||
|
||||
|
||||
class LitellmModelsRequest(BaseModel):
|
||||
api_key: str
|
||||
api_base: str
|
||||
provider_name: str | None = None # Optional: to save models to existing provider
|
||||
|
||||
|
||||
class LitellmModelDetails(BaseModel):
|
||||
"""Response model for Litellm proxy /api/v1/models endpoint"""
|
||||
|
||||
id: str # Model ID (e.g. "gpt-4o")
|
||||
object: str # "model"
|
||||
created: int # Unix timestamp in seconds
|
||||
owned_by: str # Provider name (e.g. "openai")
|
||||
|
||||
|
||||
class LitellmFinalModelResponse(BaseModel):
|
||||
provider_name: str # Provider name (e.g. "openai")
|
||||
model_name: str # Model ID (e.g. "gpt-4o")
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
from typing import cast
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from onyx.chat.citation_utils import extract_citation_order_from_text
|
||||
@@ -20,7 +22,9 @@ from onyx.server.query_and_chat.placement import Placement
|
||||
from onyx.server.query_and_chat.streaming_models import AgentResponseDelta
|
||||
from onyx.server.query_and_chat.streaming_models import AgentResponseStart
|
||||
from onyx.server.query_and_chat.streaming_models import CitationInfo
|
||||
from onyx.server.query_and_chat.streaming_models import CustomToolArgs
|
||||
from onyx.server.query_and_chat.streaming_models import CustomToolDelta
|
||||
from onyx.server.query_and_chat.streaming_models import CustomToolErrorInfo
|
||||
from onyx.server.query_and_chat.streaming_models import CustomToolStart
|
||||
from onyx.server.query_and_chat.streaming_models import FileReaderResult
|
||||
from onyx.server.query_and_chat.streaming_models import FileReaderStart
|
||||
@@ -180,24 +184,37 @@ def create_custom_tool_packets(
|
||||
tab_index: int = 0,
|
||||
data: dict | list | str | int | float | bool | None = None,
|
||||
file_ids: list[str] | None = None,
|
||||
error: CustomToolErrorInfo | None = None,
|
||||
tool_args: dict[str, Any] | None = None,
|
||||
tool_id: int | None = None,
|
||||
) -> list[Packet]:
|
||||
packets: list[Packet] = []
|
||||
|
||||
packets.append(
|
||||
Packet(
|
||||
placement=Placement(turn_index=turn_index, tab_index=tab_index),
|
||||
obj=CustomToolStart(tool_name=tool_name),
|
||||
obj=CustomToolStart(tool_name=tool_name, tool_id=tool_id),
|
||||
)
|
||||
)
|
||||
|
||||
if tool_args:
|
||||
packets.append(
|
||||
Packet(
|
||||
placement=Placement(turn_index=turn_index, tab_index=tab_index),
|
||||
obj=CustomToolArgs(tool_name=tool_name, tool_args=tool_args),
|
||||
)
|
||||
)
|
||||
|
||||
packets.append(
|
||||
Packet(
|
||||
placement=Placement(turn_index=turn_index, tab_index=tab_index),
|
||||
obj=CustomToolDelta(
|
||||
tool_name=tool_name,
|
||||
tool_id=tool_id,
|
||||
response_type=response_type,
|
||||
data=data,
|
||||
file_ids=file_ids,
|
||||
error=error,
|
||||
),
|
||||
),
|
||||
)
|
||||
@@ -657,13 +674,55 @@ def translate_assistant_message_to_packets(
|
||||
|
||||
else:
|
||||
# Custom tool or unknown tool
|
||||
# Try to parse as structured CustomToolCallSummary JSON
|
||||
custom_data: dict | list | str | int | float | bool | None = (
|
||||
tool_call.tool_call_response
|
||||
)
|
||||
custom_error: CustomToolErrorInfo | None = None
|
||||
custom_response_type = "text"
|
||||
|
||||
try:
|
||||
parsed = json.loads(tool_call.tool_call_response)
|
||||
if isinstance(parsed, dict) and "tool_name" in parsed:
|
||||
custom_data = parsed.get("tool_result")
|
||||
custom_response_type = parsed.get(
|
||||
"response_type", "text"
|
||||
)
|
||||
if parsed.get("error"):
|
||||
custom_error = CustomToolErrorInfo(
|
||||
**parsed["error"]
|
||||
)
|
||||
except (
|
||||
json.JSONDecodeError,
|
||||
KeyError,
|
||||
TypeError,
|
||||
ValidationError,
|
||||
):
|
||||
pass
|
||||
|
||||
custom_file_ids: list[str] | None = None
|
||||
if custom_response_type in ("image", "csv") and isinstance(
|
||||
custom_data, dict
|
||||
):
|
||||
custom_file_ids = custom_data.get("file_ids")
|
||||
custom_data = None
|
||||
|
||||
custom_args = {
|
||||
k: v
|
||||
for k, v in (tool_call.tool_call_arguments or {}).items()
|
||||
if k != "requestBody"
|
||||
}
|
||||
turn_tool_packets.extend(
|
||||
create_custom_tool_packets(
|
||||
tool_name=tool.display_name or tool.name,
|
||||
response_type="text",
|
||||
response_type=custom_response_type,
|
||||
turn_index=turn_num,
|
||||
tab_index=tool_call.tab_index,
|
||||
data=tool_call.tool_call_response,
|
||||
data=custom_data,
|
||||
file_ids=custom_file_ids,
|
||||
error=custom_error,
|
||||
tool_args=custom_args if custom_args else None,
|
||||
tool_id=tool_call.tool_id,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ class StreamingType(Enum):
|
||||
PYTHON_TOOL_START = "python_tool_start"
|
||||
PYTHON_TOOL_DELTA = "python_tool_delta"
|
||||
CUSTOM_TOOL_START = "custom_tool_start"
|
||||
CUSTOM_TOOL_ARGS = "custom_tool_args"
|
||||
CUSTOM_TOOL_DELTA = "custom_tool_delta"
|
||||
FILE_READER_START = "file_reader_start"
|
||||
FILE_READER_RESULT = "file_reader_result"
|
||||
@@ -245,6 +246,20 @@ class CustomToolStart(BaseObj):
|
||||
type: Literal["custom_tool_start"] = StreamingType.CUSTOM_TOOL_START.value
|
||||
|
||||
tool_name: str
|
||||
tool_id: int | None = None
|
||||
|
||||
|
||||
class CustomToolArgs(BaseObj):
|
||||
type: Literal["custom_tool_args"] = StreamingType.CUSTOM_TOOL_ARGS.value
|
||||
|
||||
tool_name: str
|
||||
tool_args: dict[str, Any]
|
||||
|
||||
|
||||
class CustomToolErrorInfo(BaseModel):
|
||||
is_auth_error: bool = False
|
||||
status_code: int
|
||||
message: str
|
||||
|
||||
|
||||
# The allowed streamed packets for a custom tool
|
||||
@@ -252,11 +267,13 @@ class CustomToolDelta(BaseObj):
|
||||
type: Literal["custom_tool_delta"] = StreamingType.CUSTOM_TOOL_DELTA.value
|
||||
|
||||
tool_name: str
|
||||
tool_id: int | None = None
|
||||
response_type: str
|
||||
# For non-file responses
|
||||
data: dict | list | str | int | float | bool | None = None
|
||||
# For file-based responses like image/csv
|
||||
file_ids: list[str] | None = None
|
||||
error: CustomToolErrorInfo | None = None
|
||||
|
||||
|
||||
################################################
|
||||
@@ -366,6 +383,7 @@ PacketObj = Union[
|
||||
PythonToolStart,
|
||||
PythonToolDelta,
|
||||
CustomToolStart,
|
||||
CustomToolArgs,
|
||||
CustomToolDelta,
|
||||
FileReaderStart,
|
||||
FileReaderResult,
|
||||
|
||||
@@ -8,8 +8,6 @@ from onyx.server.query_and_chat.placement import Placement
|
||||
from onyx.server.query_and_chat.streaming_models import AgentResponseDelta
|
||||
from onyx.server.query_and_chat.streaming_models import AgentResponseStart
|
||||
from onyx.server.query_and_chat.streaming_models import CitationInfo
|
||||
from onyx.server.query_and_chat.streaming_models import CustomToolDelta
|
||||
from onyx.server.query_and_chat.streaming_models import CustomToolStart
|
||||
from onyx.server.query_and_chat.streaming_models import GeneratedImage
|
||||
from onyx.server.query_and_chat.streaming_models import ImageGenerationFinal
|
||||
from onyx.server.query_and_chat.streaming_models import ImageGenerationToolStart
|
||||
@@ -165,39 +163,6 @@ def create_image_generation_packets(
|
||||
return packets
|
||||
|
||||
|
||||
def create_custom_tool_packets(
|
||||
tool_name: str,
|
||||
response_type: str,
|
||||
turn_index: int,
|
||||
data: dict | list | str | int | float | bool | None = None,
|
||||
file_ids: list[str] | None = None,
|
||||
) -> list[Packet]:
|
||||
packets: list[Packet] = []
|
||||
|
||||
packets.append(
|
||||
Packet(
|
||||
placement=Placement(turn_index=turn_index),
|
||||
obj=CustomToolStart(tool_name=tool_name),
|
||||
)
|
||||
)
|
||||
|
||||
packets.append(
|
||||
Packet(
|
||||
placement=Placement(turn_index=turn_index),
|
||||
obj=CustomToolDelta(
|
||||
tool_name=tool_name,
|
||||
response_type=response_type,
|
||||
data=data,
|
||||
file_ids=file_ids,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
packets.append(Packet(placement=Placement(turn_index=turn_index), obj=SectionEnd()))
|
||||
|
||||
return packets
|
||||
|
||||
|
||||
def create_fetch_packets(
|
||||
fetch_docs: list[SavedSearchDoc],
|
||||
urls: list[str],
|
||||
|
||||
@@ -275,9 +275,13 @@ def setup_postgres(db_session: Session) -> None:
|
||||
],
|
||||
api_key_changed=True,
|
||||
)
|
||||
new_llm_provider = upsert_llm_provider(
|
||||
llm_provider_upsert_request=model_req, db_session=db_session
|
||||
)
|
||||
try:
|
||||
new_llm_provider = upsert_llm_provider(
|
||||
llm_provider_upsert_request=model_req, db_session=db_session
|
||||
)
|
||||
except ValueError as e:
|
||||
logger.warning("Failed to upsert LLM provider during setup: %s", e)
|
||||
return
|
||||
update_default_provider(
|
||||
provider_id=new_llm_provider.id, model_name=llm_model, db_session=db_session
|
||||
)
|
||||
|
||||
@@ -18,6 +18,7 @@ from onyx.context.search.models import SearchDoc
|
||||
from onyx.context.search.models import SearchDocsResponse
|
||||
from onyx.db.memory import UserMemoryContext
|
||||
from onyx.server.query_and_chat.placement import Placement
|
||||
from onyx.server.query_and_chat.streaming_models import CustomToolErrorInfo
|
||||
from onyx.server.query_and_chat.streaming_models import GeneratedImage
|
||||
from onyx.tools.tool_implementations.images.models import FinalImageGenerationResponse
|
||||
from onyx.tools.tool_implementations.memory.models import MemoryToolResponse
|
||||
@@ -61,6 +62,7 @@ class CustomToolCallSummary(BaseModel):
|
||||
tool_name: str
|
||||
response_type: str # e.g., 'json', 'image', 'csv', 'graph'
|
||||
tool_result: Any # The response data
|
||||
error: CustomToolErrorInfo | None = None
|
||||
|
||||
|
||||
class ToolCallKickoff(BaseModel):
|
||||
|
||||
@@ -15,7 +15,9 @@ from onyx.chat.emitter import get_default_emitter
|
||||
from onyx.configs.constants import FileOrigin
|
||||
from onyx.file_store.file_store import get_default_file_store
|
||||
from onyx.server.query_and_chat.placement import Placement
|
||||
from onyx.server.query_and_chat.streaming_models import CustomToolArgs
|
||||
from onyx.server.query_and_chat.streaming_models import CustomToolDelta
|
||||
from onyx.server.query_and_chat.streaming_models import CustomToolErrorInfo
|
||||
from onyx.server.query_and_chat.streaming_models import CustomToolStart
|
||||
from onyx.server.query_and_chat.streaming_models import Packet
|
||||
from onyx.tools.interface import Tool
|
||||
@@ -139,7 +141,7 @@ class CustomTool(Tool[None]):
|
||||
self.emitter.emit(
|
||||
Packet(
|
||||
placement=placement,
|
||||
obj=CustomToolStart(tool_name=self._name),
|
||||
obj=CustomToolStart(tool_name=self._name, tool_id=self._id),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -149,10 +151,8 @@ class CustomTool(Tool[None]):
|
||||
override_kwargs: None = None, # noqa: ARG002
|
||||
**llm_kwargs: Any,
|
||||
) -> ToolResponse:
|
||||
request_body = llm_kwargs.get(REQUEST_BODY)
|
||||
|
||||
# Build path params
|
||||
path_params = {}
|
||||
|
||||
for path_param_schema in self._method_spec.get_path_param_schemas():
|
||||
param_name = path_param_schema["name"]
|
||||
if param_name not in llm_kwargs:
|
||||
@@ -165,6 +165,7 @@ class CustomTool(Tool[None]):
|
||||
)
|
||||
path_params[param_name] = llm_kwargs[param_name]
|
||||
|
||||
# Build query params
|
||||
query_params = {}
|
||||
for query_param_schema in self._method_spec.get_query_param_schemas():
|
||||
if query_param_schema["name"] in llm_kwargs:
|
||||
@@ -172,6 +173,20 @@ class CustomTool(Tool[None]):
|
||||
query_param_schema["name"]
|
||||
]
|
||||
|
||||
# Emit args packet (path + query params only, no request body)
|
||||
tool_args = {**path_params, **query_params}
|
||||
if tool_args:
|
||||
self.emitter.emit(
|
||||
Packet(
|
||||
placement=placement,
|
||||
obj=CustomToolArgs(
|
||||
tool_name=self._name,
|
||||
tool_args=tool_args,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
request_body = llm_kwargs.get(REQUEST_BODY)
|
||||
url = self._method_spec.build_url(self._base_url, path_params, query_params)
|
||||
method = self._method_spec.method
|
||||
|
||||
@@ -180,6 +195,18 @@ class CustomTool(Tool[None]):
|
||||
)
|
||||
content_type = response.headers.get("Content-Type", "")
|
||||
|
||||
# Detect HTTP errors — only 401/403 are flagged as auth errors
|
||||
error_info: CustomToolErrorInfo | None = None
|
||||
if response.status_code in (401, 403):
|
||||
error_info = CustomToolErrorInfo(
|
||||
is_auth_error=True,
|
||||
status_code=response.status_code,
|
||||
message=f"{self._name} action failed because of authentication error",
|
||||
)
|
||||
logger.warning(
|
||||
f"Auth error from custom tool '{self._name}': HTTP {response.status_code}"
|
||||
)
|
||||
|
||||
tool_result: Any
|
||||
response_type: str
|
||||
file_ids: List[str] | None = None
|
||||
@@ -222,9 +249,11 @@ class CustomTool(Tool[None]):
|
||||
placement=placement,
|
||||
obj=CustomToolDelta(
|
||||
tool_name=self._name,
|
||||
tool_id=self._id,
|
||||
response_type=response_type,
|
||||
data=data,
|
||||
file_ids=file_ids,
|
||||
error=error_info,
|
||||
),
|
||||
)
|
||||
)
|
||||
@@ -236,6 +265,7 @@ class CustomTool(Tool[None]):
|
||||
tool_name=self._name,
|
||||
response_type=response_type,
|
||||
tool_result=tool_result,
|
||||
error=error_info,
|
||||
),
|
||||
llm_facing_response=llm_facing_response,
|
||||
)
|
||||
|
||||
@@ -406,7 +406,7 @@ referencing==0.36.2
|
||||
# jsonschema-specifications
|
||||
regex==2025.11.3
|
||||
# via tiktoken
|
||||
release-tag==0.4.3
|
||||
release-tag==0.5.2
|
||||
# via onyx
|
||||
reorder-python-imports-black==3.14.0
|
||||
# via onyx
|
||||
|
||||
@@ -158,7 +158,7 @@ class TestLLMConfigurationEndpoint:
|
||||
)
|
||||
|
||||
assert exc_info.value.error_code == OnyxErrorCode.VALIDATION_ERROR
|
||||
assert exc_info.value.message == error_message
|
||||
assert exc_info.value.detail == error_message
|
||||
|
||||
finally:
|
||||
db_session.rollback()
|
||||
@@ -540,7 +540,7 @@ class TestDefaultProviderEndpoint:
|
||||
run_test_default_provider(_=_create_mock_admin())
|
||||
|
||||
assert exc_info.value.error_code == OnyxErrorCode.VALIDATION_ERROR
|
||||
assert "No LLM Provider setup" in exc_info.value.message
|
||||
assert "No LLM Provider setup" in exc_info.value.detail
|
||||
|
||||
finally:
|
||||
db_session.rollback()
|
||||
@@ -585,7 +585,7 @@ class TestDefaultProviderEndpoint:
|
||||
run_test_default_provider(_=_create_mock_admin())
|
||||
|
||||
assert exc_info.value.error_code == OnyxErrorCode.VALIDATION_ERROR
|
||||
assert exc_info.value.message == error_message
|
||||
assert exc_info.value.detail == error_message
|
||||
|
||||
finally:
|
||||
db_session.rollback()
|
||||
|
||||
@@ -111,7 +111,7 @@ class TestLLMProviderChanges:
|
||||
|
||||
assert exc_info.value.error_code == OnyxErrorCode.VALIDATION_ERROR
|
||||
assert "cannot be changed without changing the API key" in str(
|
||||
exc_info.value.message
|
||||
exc_info.value.detail
|
||||
)
|
||||
finally:
|
||||
_cleanup_provider(db_session, provider_name)
|
||||
@@ -247,7 +247,7 @@ class TestLLMProviderChanges:
|
||||
|
||||
assert exc_info.value.error_code == OnyxErrorCode.VALIDATION_ERROR
|
||||
assert "cannot be changed without changing the API key" in str(
|
||||
exc_info.value.message
|
||||
exc_info.value.detail
|
||||
)
|
||||
finally:
|
||||
_cleanup_provider(db_session, provider_name)
|
||||
@@ -350,7 +350,7 @@ class TestLLMProviderChanges:
|
||||
|
||||
assert exc_info.value.error_code == OnyxErrorCode.VALIDATION_ERROR
|
||||
assert "cannot be changed without changing the API key" in str(
|
||||
exc_info.value.message
|
||||
exc_info.value.detail
|
||||
)
|
||||
finally:
|
||||
_cleanup_provider(db_session, provider_name)
|
||||
@@ -386,7 +386,7 @@ class TestLLMProviderChanges:
|
||||
|
||||
assert exc_info.value.error_code == OnyxErrorCode.VALIDATION_ERROR
|
||||
assert "cannot be changed without changing the API key" in str(
|
||||
exc_info.value.message
|
||||
exc_info.value.detail
|
||||
)
|
||||
finally:
|
||||
_cleanup_provider(db_session, provider_name)
|
||||
|
||||
@@ -1152,3 +1152,179 @@ class TestAutoModeTransitionsAndResync:
|
||||
finally:
|
||||
db_session.rollback()
|
||||
_cleanup_provider(db_session, provider_name)
|
||||
|
||||
def test_sync_updates_default_when_recommended_default_changes(
|
||||
self,
|
||||
db_session: Session,
|
||||
provider_name: str,
|
||||
) -> None:
|
||||
"""When the provider owns the CHAT default and a sync arrives with a
|
||||
different recommended default model (both models still in config),
|
||||
the global default should be updated to the new recommendation.
|
||||
|
||||
Steps:
|
||||
1. Create auto-mode provider with config v1: default=gpt-4o.
|
||||
2. Set gpt-4o as the global CHAT default.
|
||||
3. Re-sync with config v2: default=gpt-4o-mini (gpt-4o still present).
|
||||
4. Verify the CHAT default switched to gpt-4o-mini and both models
|
||||
remain visible.
|
||||
"""
|
||||
config_v1 = _create_mock_llm_recommendations(
|
||||
provider=LlmProviderNames.OPENAI,
|
||||
default_model_name="gpt-4o",
|
||||
additional_models=["gpt-4o-mini"],
|
||||
)
|
||||
config_v2 = _create_mock_llm_recommendations(
|
||||
provider=LlmProviderNames.OPENAI,
|
||||
default_model_name="gpt-4o-mini",
|
||||
additional_models=["gpt-4o"],
|
||||
)
|
||||
|
||||
try:
|
||||
with patch(
|
||||
"onyx.server.manage.llm.api.fetch_llm_recommendations_from_github",
|
||||
return_value=config_v1,
|
||||
):
|
||||
put_llm_provider(
|
||||
llm_provider_upsert_request=LLMProviderUpsertRequest(
|
||||
name=provider_name,
|
||||
provider=LlmProviderNames.OPENAI,
|
||||
api_key="sk-test-key-00000000000000000000000000000000000",
|
||||
api_key_changed=True,
|
||||
is_auto_mode=True,
|
||||
model_configurations=[],
|
||||
),
|
||||
is_creation=True,
|
||||
_=_create_mock_admin(),
|
||||
db_session=db_session,
|
||||
)
|
||||
|
||||
# Set gpt-4o as the global CHAT default
|
||||
db_session.expire_all()
|
||||
provider = fetch_existing_llm_provider(
|
||||
name=provider_name, db_session=db_session
|
||||
)
|
||||
assert provider is not None
|
||||
update_default_provider(provider.id, "gpt-4o", db_session)
|
||||
|
||||
default_before = fetch_default_llm_model(db_session)
|
||||
assert default_before is not None
|
||||
assert default_before.name == "gpt-4o"
|
||||
|
||||
# Re-sync with config v2 (recommended default changed)
|
||||
db_session.expire_all()
|
||||
provider = fetch_existing_llm_provider(
|
||||
name=provider_name, db_session=db_session
|
||||
)
|
||||
assert provider is not None
|
||||
|
||||
changes = sync_auto_mode_models(
|
||||
db_session=db_session,
|
||||
provider=provider,
|
||||
llm_recommendations=config_v2,
|
||||
)
|
||||
assert changes > 0, "Sync should report changes when default switches"
|
||||
|
||||
# Both models should remain visible
|
||||
db_session.expire_all()
|
||||
provider = fetch_existing_llm_provider(
|
||||
name=provider_name, db_session=db_session
|
||||
)
|
||||
assert provider is not None
|
||||
visibility = {
|
||||
mc.name: mc.is_visible for mc in provider.model_configurations
|
||||
}
|
||||
assert visibility["gpt-4o"] is True
|
||||
assert visibility["gpt-4o-mini"] is True
|
||||
|
||||
# The CHAT default should now be gpt-4o-mini
|
||||
default_after = fetch_default_llm_model(db_session)
|
||||
assert default_after is not None
|
||||
assert (
|
||||
default_after.name == "gpt-4o-mini"
|
||||
), f"Default should be updated to 'gpt-4o-mini', got '{default_after.name}'"
|
||||
|
||||
finally:
|
||||
db_session.rollback()
|
||||
_cleanup_provider(db_session, provider_name)
|
||||
|
||||
def test_sync_idempotent_when_default_already_matches(
|
||||
self,
|
||||
db_session: Session,
|
||||
provider_name: str,
|
||||
) -> None:
|
||||
"""When the provider owns the CHAT default and it already matches the
|
||||
recommended default, re-syncing should report zero changes.
|
||||
|
||||
This is a regression test for the bug where changes was unconditionally
|
||||
incremented even when the default was already correct.
|
||||
"""
|
||||
config = _create_mock_llm_recommendations(
|
||||
provider=LlmProviderNames.OPENAI,
|
||||
default_model_name="gpt-4o",
|
||||
additional_models=["gpt-4o-mini"],
|
||||
)
|
||||
|
||||
try:
|
||||
with patch(
|
||||
"onyx.server.manage.llm.api.fetch_llm_recommendations_from_github",
|
||||
return_value=config,
|
||||
):
|
||||
put_llm_provider(
|
||||
llm_provider_upsert_request=LLMProviderUpsertRequest(
|
||||
name=provider_name,
|
||||
provider=LlmProviderNames.OPENAI,
|
||||
api_key="sk-test-key-00000000000000000000000000000000000",
|
||||
api_key_changed=True,
|
||||
is_auto_mode=True,
|
||||
model_configurations=[],
|
||||
),
|
||||
is_creation=True,
|
||||
_=_create_mock_admin(),
|
||||
db_session=db_session,
|
||||
)
|
||||
|
||||
# Set gpt-4o (the recommended default) as global CHAT default
|
||||
db_session.expire_all()
|
||||
provider = fetch_existing_llm_provider(
|
||||
name=provider_name, db_session=db_session
|
||||
)
|
||||
assert provider is not None
|
||||
update_default_provider(provider.id, "gpt-4o", db_session)
|
||||
|
||||
# First sync to stabilize state
|
||||
db_session.expire_all()
|
||||
provider = fetch_existing_llm_provider(
|
||||
name=provider_name, db_session=db_session
|
||||
)
|
||||
assert provider is not None
|
||||
sync_auto_mode_models(
|
||||
db_session=db_session,
|
||||
provider=provider,
|
||||
llm_recommendations=config,
|
||||
)
|
||||
|
||||
# Second sync — default already matches, should be a no-op
|
||||
db_session.expire_all()
|
||||
provider = fetch_existing_llm_provider(
|
||||
name=provider_name, db_session=db_session
|
||||
)
|
||||
assert provider is not None
|
||||
changes = sync_auto_mode_models(
|
||||
db_session=db_session,
|
||||
provider=provider,
|
||||
llm_recommendations=config,
|
||||
)
|
||||
assert changes == 0, (
|
||||
f"Expected 0 changes when default already matches recommended, "
|
||||
f"got {changes}"
|
||||
)
|
||||
|
||||
# Default should still be gpt-4o
|
||||
default_model = fetch_default_llm_model(db_session)
|
||||
assert default_model is not None
|
||||
assert default_model.name == "gpt-4o"
|
||||
|
||||
finally:
|
||||
db_session.rollback()
|
||||
_cleanup_provider(db_session, provider_name)
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
"""
|
||||
This should act as the main point of reference for testing that default model
|
||||
logic is consisten.
|
||||
|
||||
-
|
||||
"""
|
||||
|
||||
from collections.abc import Generator
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from onyx.db.llm import fetch_existing_llm_provider
|
||||
from onyx.db.llm import remove_llm_provider
|
||||
from onyx.db.llm import update_default_provider
|
||||
from onyx.db.llm import update_default_vision_provider
|
||||
from onyx.db.llm import upsert_llm_provider
|
||||
from onyx.llm.constants import LlmProviderNames
|
||||
from onyx.server.manage.llm.models import LLMProviderUpsertRequest
|
||||
from onyx.server.manage.llm.models import LLMProviderView
|
||||
from onyx.server.manage.llm.models import ModelConfigurationUpsertRequest
|
||||
|
||||
|
||||
def _create_test_provider(
|
||||
db_session: Session,
|
||||
name: str,
|
||||
models: list[ModelConfigurationUpsertRequest] | None = None,
|
||||
) -> LLMProviderView:
|
||||
"""Helper to create a test LLM provider with multiple models."""
|
||||
if models is None:
|
||||
models = [
|
||||
ModelConfigurationUpsertRequest(
|
||||
name="gpt-4o", is_visible=True, supports_image_input=True
|
||||
),
|
||||
ModelConfigurationUpsertRequest(
|
||||
name="gpt-4o-mini", is_visible=True, supports_image_input=False
|
||||
),
|
||||
]
|
||||
return upsert_llm_provider(
|
||||
LLMProviderUpsertRequest(
|
||||
name=name,
|
||||
provider=LlmProviderNames.OPENAI,
|
||||
api_key="sk-test-key-00000000000000000000000000000000000",
|
||||
api_key_changed=True,
|
||||
model_configurations=models,
|
||||
),
|
||||
db_session=db_session,
|
||||
)
|
||||
|
||||
|
||||
def _cleanup_provider(db_session: Session, name: str) -> None:
|
||||
"""Helper to clean up a test provider by name."""
|
||||
provider = fetch_existing_llm_provider(name=name, db_session=db_session)
|
||||
if provider:
|
||||
remove_llm_provider(db_session, provider.id)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def provider_name(db_session: Session) -> Generator[str, None, None]:
|
||||
"""Generate a unique provider name for each test, with automatic cleanup."""
|
||||
name = f"test-provider-{uuid4().hex[:8]}"
|
||||
yield name
|
||||
db_session.rollback()
|
||||
_cleanup_provider(db_session, name)
|
||||
|
||||
|
||||
class TestDefaultModelProtection:
|
||||
"""Tests that the default model cannot be removed or hidden."""
|
||||
|
||||
def test_cannot_remove_default_text_model(
|
||||
self,
|
||||
db_session: Session,
|
||||
provider_name: str,
|
||||
) -> None:
|
||||
"""Removing the default text model from a provider should raise ValueError."""
|
||||
provider = _create_test_provider(db_session, provider_name)
|
||||
update_default_provider(provider.id, "gpt-4o", db_session)
|
||||
|
||||
# Try to update the provider without the default model
|
||||
with pytest.raises(ValueError, match="Cannot remove the default model"):
|
||||
upsert_llm_provider(
|
||||
LLMProviderUpsertRequest(
|
||||
id=provider.id,
|
||||
name=provider_name,
|
||||
provider=LlmProviderNames.OPENAI,
|
||||
api_key="sk-test-key-00000000000000000000000000000000000",
|
||||
api_key_changed=True,
|
||||
model_configurations=[
|
||||
ModelConfigurationUpsertRequest(
|
||||
name="gpt-4o-mini", is_visible=True
|
||||
),
|
||||
],
|
||||
),
|
||||
db_session=db_session,
|
||||
)
|
||||
|
||||
def test_cannot_hide_default_text_model(
|
||||
self,
|
||||
db_session: Session,
|
||||
provider_name: str,
|
||||
) -> None:
|
||||
"""Setting is_visible=False on the default text model should raise ValueError."""
|
||||
provider = _create_test_provider(db_session, provider_name)
|
||||
update_default_provider(provider.id, "gpt-4o", db_session)
|
||||
|
||||
# Try to hide the default model
|
||||
with pytest.raises(ValueError, match="Cannot hide the default model"):
|
||||
upsert_llm_provider(
|
||||
LLMProviderUpsertRequest(
|
||||
id=provider.id,
|
||||
name=provider_name,
|
||||
provider=LlmProviderNames.OPENAI,
|
||||
api_key="sk-test-key-00000000000000000000000000000000000",
|
||||
api_key_changed=True,
|
||||
model_configurations=[
|
||||
ModelConfigurationUpsertRequest(
|
||||
name="gpt-4o", is_visible=False
|
||||
),
|
||||
ModelConfigurationUpsertRequest(
|
||||
name="gpt-4o-mini", is_visible=True
|
||||
),
|
||||
],
|
||||
),
|
||||
db_session=db_session,
|
||||
)
|
||||
|
||||
def test_cannot_remove_default_vision_model(
|
||||
self,
|
||||
db_session: Session,
|
||||
provider_name: str,
|
||||
) -> None:
|
||||
"""Removing the default vision model from a provider should raise ValueError."""
|
||||
provider = _create_test_provider(db_session, provider_name)
|
||||
# Set gpt-4o as both the text and vision default
|
||||
update_default_provider(provider.id, "gpt-4o", db_session)
|
||||
update_default_vision_provider(provider.id, "gpt-4o", db_session)
|
||||
|
||||
# Try to remove the default vision model
|
||||
with pytest.raises(ValueError, match="Cannot remove the default model"):
|
||||
upsert_llm_provider(
|
||||
LLMProviderUpsertRequest(
|
||||
id=provider.id,
|
||||
name=provider_name,
|
||||
provider=LlmProviderNames.OPENAI,
|
||||
api_key="sk-test-key-00000000000000000000000000000000000",
|
||||
api_key_changed=True,
|
||||
model_configurations=[
|
||||
ModelConfigurationUpsertRequest(
|
||||
name="gpt-4o-mini", is_visible=True
|
||||
),
|
||||
],
|
||||
),
|
||||
db_session=db_session,
|
||||
)
|
||||
|
||||
def test_can_remove_non_default_model(
|
||||
self,
|
||||
db_session: Session,
|
||||
provider_name: str,
|
||||
) -> None:
|
||||
"""Removing a non-default model should succeed."""
|
||||
provider = _create_test_provider(db_session, provider_name)
|
||||
update_default_provider(provider.id, "gpt-4o", db_session)
|
||||
|
||||
# Remove gpt-4o-mini (not default) — should succeed
|
||||
updated = upsert_llm_provider(
|
||||
LLMProviderUpsertRequest(
|
||||
id=provider.id,
|
||||
name=provider_name,
|
||||
provider=LlmProviderNames.OPENAI,
|
||||
api_key="sk-test-key-00000000000000000000000000000000000",
|
||||
api_key_changed=True,
|
||||
model_configurations=[
|
||||
ModelConfigurationUpsertRequest(
|
||||
name="gpt-4o", is_visible=True, supports_image_input=True
|
||||
),
|
||||
],
|
||||
),
|
||||
db_session=db_session,
|
||||
)
|
||||
|
||||
model_names = {mc.name for mc in updated.model_configurations}
|
||||
assert "gpt-4o" in model_names
|
||||
assert "gpt-4o-mini" not in model_names
|
||||
|
||||
def test_can_hide_non_default_model(
|
||||
self,
|
||||
db_session: Session,
|
||||
provider_name: str,
|
||||
) -> None:
|
||||
"""Hiding a non-default model should succeed."""
|
||||
provider = _create_test_provider(db_session, provider_name)
|
||||
update_default_provider(provider.id, "gpt-4o", db_session)
|
||||
|
||||
# Hide gpt-4o-mini (not default) — should succeed
|
||||
updated = upsert_llm_provider(
|
||||
LLMProviderUpsertRequest(
|
||||
id=provider.id,
|
||||
name=provider_name,
|
||||
provider=LlmProviderNames.OPENAI,
|
||||
api_key="sk-test-key-00000000000000000000000000000000000",
|
||||
api_key_changed=True,
|
||||
model_configurations=[
|
||||
ModelConfigurationUpsertRequest(
|
||||
name="gpt-4o", is_visible=True, supports_image_input=True
|
||||
),
|
||||
ModelConfigurationUpsertRequest(
|
||||
name="gpt-4o-mini", is_visible=False
|
||||
),
|
||||
],
|
||||
),
|
||||
db_session=db_session,
|
||||
)
|
||||
|
||||
model_visibility = {
|
||||
mc.name: mc.is_visible for mc in updated.model_configurations
|
||||
}
|
||||
assert model_visibility["gpt-4o"] is True
|
||||
assert model_visibility["gpt-4o-mini"] is False
|
||||
@@ -427,7 +427,7 @@ def test_delete_default_llm_provider_rejected(reset: None) -> None: # noqa: ARG
|
||||
headers=admin_user.headers,
|
||||
)
|
||||
assert delete_response.status_code == 400
|
||||
assert "Cannot delete the default LLM provider" in delete_response.json()["message"]
|
||||
assert "Cannot delete the default LLM provider" in delete_response.json()["detail"]
|
||||
|
||||
# Verify provider still exists
|
||||
provider_data = _get_provider_by_id(admin_user, created_provider["id"])
|
||||
@@ -674,7 +674,7 @@ def test_duplicate_provider_name_rejected(reset: None) -> None: # noqa: ARG001
|
||||
json=base_payload,
|
||||
)
|
||||
assert response.status_code == 409
|
||||
assert "already exists" in response.json()["message"]
|
||||
assert "already exists" in response.json()["detail"]
|
||||
|
||||
|
||||
def test_rename_provider_rejected(reset: None) -> None: # noqa: ARG001
|
||||
@@ -711,7 +711,7 @@ def test_rename_provider_rejected(reset: None) -> None: # noqa: ARG001
|
||||
json=update_payload,
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert "not currently supported" in response.json()["message"]
|
||||
assert "not currently supported" in response.json()["detail"]
|
||||
|
||||
# Verify no duplicate was created — only the original provider should exist
|
||||
provider = _get_provider_by_id(admin_user, provider_id)
|
||||
|
||||
@@ -69,7 +69,7 @@ def test_unauthorized_persona_access_returns_403(
|
||||
|
||||
# Should return 403 Forbidden
|
||||
assert response.status_code == 403
|
||||
assert "don't have access to this assistant" in response.json()["message"]
|
||||
assert "don't have access to this assistant" in response.json()["detail"]
|
||||
|
||||
|
||||
def test_authorized_persona_access_returns_filtered_providers(
|
||||
@@ -245,4 +245,4 @@ def test_nonexistent_persona_returns_404(
|
||||
|
||||
# Should return 404
|
||||
assert response.status_code == 404
|
||||
assert "Persona not found" in response.json()["message"]
|
||||
assert "Persona not found" in response.json()["detail"]
|
||||
|
||||
@@ -107,7 +107,7 @@ class TestCreateCheckoutSession:
|
||||
|
||||
assert exc_info.value.status_code == 502
|
||||
assert exc_info.value.error_code is OnyxErrorCode.BAD_GATEWAY
|
||||
assert exc_info.value.message == "Stripe error"
|
||||
assert exc_info.value.detail == "Stripe error"
|
||||
|
||||
|
||||
class TestCreateCustomerPortalSession:
|
||||
@@ -137,7 +137,7 @@ class TestCreateCustomerPortalSession:
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert exc_info.value.error_code is OnyxErrorCode.VALIDATION_ERROR
|
||||
assert exc_info.value.message == "No license found"
|
||||
assert exc_info.value.detail == "No license found"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("ee.onyx.server.billing.api.create_portal_service")
|
||||
@@ -243,7 +243,7 @@ class TestUpdateSeats:
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert exc_info.value.error_code is OnyxErrorCode.VALIDATION_ERROR
|
||||
assert exc_info.value.message == "No license found"
|
||||
assert exc_info.value.detail == "No license found"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("ee.onyx.server.billing.api.get_used_seats")
|
||||
@@ -317,7 +317,7 @@ class TestUpdateSeats:
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert exc_info.value.error_code is OnyxErrorCode.BAD_GATEWAY
|
||||
assert exc_info.value.message == "Cannot reduce below 10 seats"
|
||||
assert exc_info.value.detail == "Cannot reduce below 10 seats"
|
||||
|
||||
|
||||
class TestCircuitBreaker:
|
||||
@@ -346,7 +346,7 @@ class TestCircuitBreaker:
|
||||
|
||||
assert exc_info.value.status_code == 503
|
||||
assert exc_info.value.error_code is OnyxErrorCode.SERVICE_UNAVAILABLE
|
||||
assert "Connect to Stripe" in exc_info.value.message
|
||||
assert "Connect to Stripe" in exc_info.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("ee.onyx.server.billing.api.MULTI_TENANT", False)
|
||||
|
||||
@@ -101,7 +101,7 @@ class TestMakeBillingRequest:
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert exc_info.value.error_code is OnyxErrorCode.BAD_GATEWAY
|
||||
assert "Bad request" in exc_info.value.message
|
||||
assert "Bad request" in exc_info.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("ee.onyx.server.billing.service._get_headers")
|
||||
@@ -152,7 +152,7 @@ class TestMakeBillingRequest:
|
||||
|
||||
assert exc_info.value.status_code == 502
|
||||
assert exc_info.value.error_code is OnyxErrorCode.BAD_GATEWAY
|
||||
assert "Failed to connect" in exc_info.value.message
|
||||
assert "Failed to connect" in exc_info.value.detail
|
||||
|
||||
|
||||
class TestCreateCheckoutSession:
|
||||
|
||||
@@ -72,7 +72,7 @@ class TestGetStripePublishableKey:
|
||||
|
||||
assert exc_info.value.status_code == 500
|
||||
assert exc_info.value.error_code is OnyxErrorCode.INTERNAL_ERROR
|
||||
assert exc_info.value.message == "Invalid Stripe publishable key format"
|
||||
assert exc_info.value.detail == "Invalid Stripe publishable key format"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("ee.onyx.server.tenants.billing_api.STRIPE_PUBLISHABLE_KEY_OVERRIDE", None)
|
||||
@@ -97,7 +97,7 @@ class TestGetStripePublishableKey:
|
||||
|
||||
assert exc_info.value.status_code == 500
|
||||
assert exc_info.value.error_code is OnyxErrorCode.INTERNAL_ERROR
|
||||
assert exc_info.value.message == "Invalid Stripe publishable key format"
|
||||
assert exc_info.value.detail == "Invalid Stripe publishable key format"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("ee.onyx.server.tenants.billing_api.STRIPE_PUBLISHABLE_KEY_OVERRIDE", None)
|
||||
@@ -118,7 +118,7 @@ class TestGetStripePublishableKey:
|
||||
|
||||
assert exc_info.value.status_code == 500
|
||||
assert exc_info.value.error_code is OnyxErrorCode.INTERNAL_ERROR
|
||||
assert exc_info.value.message == "Failed to fetch Stripe publishable key"
|
||||
assert exc_info.value.detail == "Failed to fetch Stripe publishable key"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("ee.onyx.server.tenants.billing_api.STRIPE_PUBLISHABLE_KEY_OVERRIDE", None)
|
||||
@@ -132,7 +132,7 @@ class TestGetStripePublishableKey:
|
||||
|
||||
assert exc_info.value.status_code == 500
|
||||
assert exc_info.value.error_code is OnyxErrorCode.INTERNAL_ERROR
|
||||
assert "not configured" in exc_info.value.message
|
||||
assert "not configured" in exc_info.value.detail
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch(
|
||||
|
||||
@@ -7,6 +7,7 @@ import pytest
|
||||
|
||||
from onyx.db.llm import sync_model_configurations
|
||||
from onyx.llm.constants import LlmProviderNames
|
||||
from onyx.server.manage.llm.models import SyncModelEntry
|
||||
|
||||
|
||||
class TestSyncModelConfigurations:
|
||||
@@ -25,18 +26,18 @@ class TestSyncModelConfigurations:
|
||||
"onyx.db.llm.fetch_existing_llm_provider", return_value=mock_provider
|
||||
):
|
||||
models = [
|
||||
{
|
||||
"name": "gpt-4",
|
||||
"display_name": "GPT-4",
|
||||
"max_input_tokens": 128000,
|
||||
"supports_image_input": True,
|
||||
},
|
||||
{
|
||||
"name": "gpt-4o",
|
||||
"display_name": "GPT-4o",
|
||||
"max_input_tokens": 128000,
|
||||
"supports_image_input": True,
|
||||
},
|
||||
SyncModelEntry(
|
||||
name="gpt-4",
|
||||
display_name="GPT-4",
|
||||
max_input_tokens=128000,
|
||||
supports_image_input=True,
|
||||
),
|
||||
SyncModelEntry(
|
||||
name="gpt-4o",
|
||||
display_name="GPT-4o",
|
||||
max_input_tokens=128000,
|
||||
supports_image_input=True,
|
||||
),
|
||||
]
|
||||
|
||||
result = sync_model_configurations(
|
||||
@@ -67,18 +68,18 @@ class TestSyncModelConfigurations:
|
||||
"onyx.db.llm.fetch_existing_llm_provider", return_value=mock_provider
|
||||
):
|
||||
models = [
|
||||
{
|
||||
"name": "gpt-4", # Existing - should be skipped
|
||||
"display_name": "GPT-4",
|
||||
"max_input_tokens": 128000,
|
||||
"supports_image_input": True,
|
||||
},
|
||||
{
|
||||
"name": "gpt-4o", # New - should be inserted
|
||||
"display_name": "GPT-4o",
|
||||
"max_input_tokens": 128000,
|
||||
"supports_image_input": True,
|
||||
},
|
||||
SyncModelEntry(
|
||||
name="gpt-4", # Existing - should be skipped
|
||||
display_name="GPT-4",
|
||||
max_input_tokens=128000,
|
||||
supports_image_input=True,
|
||||
),
|
||||
SyncModelEntry(
|
||||
name="gpt-4o", # New - should be inserted
|
||||
display_name="GPT-4o",
|
||||
max_input_tokens=128000,
|
||||
supports_image_input=True,
|
||||
),
|
||||
]
|
||||
|
||||
result = sync_model_configurations(
|
||||
@@ -105,12 +106,12 @@ class TestSyncModelConfigurations:
|
||||
"onyx.db.llm.fetch_existing_llm_provider", return_value=mock_provider
|
||||
):
|
||||
models = [
|
||||
{
|
||||
"name": "gpt-4", # Already exists
|
||||
"display_name": "GPT-4",
|
||||
"max_input_tokens": 128000,
|
||||
"supports_image_input": True,
|
||||
},
|
||||
SyncModelEntry(
|
||||
name="gpt-4", # Already exists
|
||||
display_name="GPT-4",
|
||||
max_input_tokens=128000,
|
||||
supports_image_input=True,
|
||||
),
|
||||
]
|
||||
|
||||
result = sync_model_configurations(
|
||||
@@ -131,7 +132,7 @@ class TestSyncModelConfigurations:
|
||||
sync_model_configurations(
|
||||
db_session=mock_session,
|
||||
provider_name="nonexistent",
|
||||
models=[{"name": "model", "display_name": "Model"}],
|
||||
models=[SyncModelEntry(name="model", display_name="Model")],
|
||||
)
|
||||
|
||||
def test_handles_missing_optional_fields(self) -> None:
|
||||
@@ -145,12 +146,12 @@ class TestSyncModelConfigurations:
|
||||
with patch(
|
||||
"onyx.db.llm.fetch_existing_llm_provider", return_value=mock_provider
|
||||
):
|
||||
# Model with only required fields
|
||||
# Model with only required fields (max_input_tokens and supports_image_input default)
|
||||
models = [
|
||||
{
|
||||
"name": "model-1",
|
||||
# No display_name, max_input_tokens, or supports_image_input
|
||||
},
|
||||
SyncModelEntry(
|
||||
name="model-1",
|
||||
display_name="Model 1",
|
||||
),
|
||||
]
|
||||
|
||||
result = sync_model_configurations(
|
||||
|
||||
@@ -15,12 +15,12 @@ class TestOnyxError:
|
||||
def test_basic_construction(self) -> None:
|
||||
err = OnyxError(OnyxErrorCode.NOT_FOUND, "Session not found")
|
||||
assert err.error_code is OnyxErrorCode.NOT_FOUND
|
||||
assert err.message == "Session not found"
|
||||
assert err.detail == "Session not found"
|
||||
assert err.status_code == 404
|
||||
|
||||
def test_message_defaults_to_code(self) -> None:
|
||||
err = OnyxError(OnyxErrorCode.UNAUTHENTICATED)
|
||||
assert err.message == "UNAUTHENTICATED"
|
||||
assert err.detail == "UNAUTHENTICATED"
|
||||
assert str(err) == "UNAUTHENTICATED"
|
||||
|
||||
def test_status_code_override(self) -> None:
|
||||
@@ -73,18 +73,18 @@ class TestExceptionHandler:
|
||||
assert resp.status_code == 404
|
||||
body = resp.json()
|
||||
assert body["error_code"] == "NOT_FOUND"
|
||||
assert body["message"] == "Thing not found"
|
||||
assert body["detail"] == "Thing not found"
|
||||
|
||||
def test_status_code_override_in_response(self, client: TestClient) -> None:
|
||||
resp = client.get("/boom-override")
|
||||
assert resp.status_code == 503
|
||||
body = resp.json()
|
||||
assert body["error_code"] == "BAD_GATEWAY"
|
||||
assert body["message"] == "upstream 503"
|
||||
assert body["detail"] == "upstream 503"
|
||||
|
||||
def test_default_message(self, client: TestClient) -> None:
|
||||
resp = client.get("/boom-default-msg")
|
||||
assert resp.status_code == 401
|
||||
body = resp.json()
|
||||
assert body["error_code"] == "UNAUTHENTICATED"
|
||||
assert body["message"] == "UNAUTHENTICATED"
|
||||
assert body["detail"] == "UNAUTHENTICATED"
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
"""Tests for LLM model fetch endpoints.
|
||||
|
||||
These tests verify the full request/response flow for fetching models
|
||||
from dynamic providers (Ollama, OpenRouter), including the
|
||||
from dynamic providers (Ollama, OpenRouter, Litellm), including the
|
||||
sync-to-DB behavior when provider_name is specified.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from onyx.error_handling.exceptions import OnyxError
|
||||
from onyx.server.manage.llm.models import LitellmFinalModelResponse
|
||||
from onyx.server.manage.llm.models import LitellmModelsRequest
|
||||
from onyx.server.manage.llm.models import LMStudioFinalModelResponse
|
||||
from onyx.server.manage.llm.models import LMStudioModelsRequest
|
||||
from onyx.server.manage.llm.models import OllamaFinalModelResponse
|
||||
@@ -614,3 +618,283 @@ class TestGetLMStudioAvailableModels:
|
||||
request = LMStudioModelsRequest(api_base="http://localhost:1234")
|
||||
with pytest.raises(OnyxError):
|
||||
get_lm_studio_available_models(request, MagicMock(), mock_session)
|
||||
|
||||
|
||||
class TestGetLitellmAvailableModels:
|
||||
"""Tests for the Litellm proxy model fetch endpoint."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_litellm_response(self) -> dict:
|
||||
"""Mock response from Litellm /v1/models endpoint."""
|
||||
return {
|
||||
"data": [
|
||||
{
|
||||
"id": "gpt-4o",
|
||||
"object": "model",
|
||||
"created": 1700000000,
|
||||
"owned_by": "openai",
|
||||
},
|
||||
{
|
||||
"id": "claude-3-5-sonnet",
|
||||
"object": "model",
|
||||
"created": 1700000001,
|
||||
"owned_by": "anthropic",
|
||||
},
|
||||
{
|
||||
"id": "gemini-pro",
|
||||
"object": "model",
|
||||
"created": 1700000002,
|
||||
"owned_by": "google",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
def test_returns_model_list(self, mock_litellm_response: dict) -> None:
|
||||
"""Test that endpoint returns properly formatted model list."""
|
||||
from onyx.server.manage.llm.api import get_litellm_available_models
|
||||
|
||||
mock_session = MagicMock()
|
||||
|
||||
with patch("onyx.server.manage.llm.api.httpx.get") as mock_get:
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = mock_litellm_response
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
request = LitellmModelsRequest(
|
||||
api_base="http://localhost:4000",
|
||||
api_key="test-key",
|
||||
)
|
||||
results = get_litellm_available_models(request, MagicMock(), mock_session)
|
||||
|
||||
assert len(results) == 3
|
||||
assert all(isinstance(r, LitellmFinalModelResponse) for r in results)
|
||||
|
||||
def test_model_fields_parsed_correctly(self, mock_litellm_response: dict) -> None:
|
||||
"""Test that provider_name and model_name are correctly extracted."""
|
||||
from onyx.server.manage.llm.api import get_litellm_available_models
|
||||
|
||||
mock_session = MagicMock()
|
||||
|
||||
with patch("onyx.server.manage.llm.api.httpx.get") as mock_get:
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = mock_litellm_response
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
request = LitellmModelsRequest(
|
||||
api_base="http://localhost:4000",
|
||||
api_key="test-key",
|
||||
)
|
||||
results = get_litellm_available_models(request, MagicMock(), mock_session)
|
||||
|
||||
gpt = next(r for r in results if r.model_name == "gpt-4o")
|
||||
assert gpt.provider_name == "openai"
|
||||
|
||||
claude = next(r for r in results if r.model_name == "claude-3-5-sonnet")
|
||||
assert claude.provider_name == "anthropic"
|
||||
|
||||
def test_results_sorted_by_model_name(self, mock_litellm_response: dict) -> None:
|
||||
"""Test that results are alphabetically sorted by model_name."""
|
||||
from onyx.server.manage.llm.api import get_litellm_available_models
|
||||
|
||||
mock_session = MagicMock()
|
||||
|
||||
with patch("onyx.server.manage.llm.api.httpx.get") as mock_get:
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = mock_litellm_response
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
request = LitellmModelsRequest(
|
||||
api_base="http://localhost:4000",
|
||||
api_key="test-key",
|
||||
)
|
||||
results = get_litellm_available_models(request, MagicMock(), mock_session)
|
||||
|
||||
model_names = [r.model_name for r in results]
|
||||
assert model_names == sorted(model_names, key=str.lower)
|
||||
|
||||
def test_empty_data_raises_onyx_error(self) -> None:
|
||||
"""Test that empty model list raises OnyxError."""
|
||||
from onyx.server.manage.llm.api import get_litellm_available_models
|
||||
|
||||
mock_session = MagicMock()
|
||||
|
||||
with patch("onyx.server.manage.llm.api.httpx.get") as mock_get:
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {"data": []}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
request = LitellmModelsRequest(
|
||||
api_base="http://localhost:4000",
|
||||
api_key="test-key",
|
||||
)
|
||||
with pytest.raises(OnyxError, match="No models found"):
|
||||
get_litellm_available_models(request, MagicMock(), mock_session)
|
||||
|
||||
def test_missing_data_key_raises_onyx_error(self) -> None:
|
||||
"""Test that response without 'data' key raises OnyxError."""
|
||||
from onyx.server.manage.llm.api import get_litellm_available_models
|
||||
|
||||
mock_session = MagicMock()
|
||||
|
||||
with patch("onyx.server.manage.llm.api.httpx.get") as mock_get:
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {}
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
request = LitellmModelsRequest(
|
||||
api_base="http://localhost:4000",
|
||||
api_key="test-key",
|
||||
)
|
||||
with pytest.raises(OnyxError):
|
||||
get_litellm_available_models(request, MagicMock(), mock_session)
|
||||
|
||||
def test_skips_unparseable_entries(self) -> None:
|
||||
"""Test that malformed model entries are skipped without failing."""
|
||||
from onyx.server.manage.llm.api import get_litellm_available_models
|
||||
|
||||
mock_session = MagicMock()
|
||||
response_with_bad_entry = {
|
||||
"data": [
|
||||
{
|
||||
"id": "gpt-4o",
|
||||
"object": "model",
|
||||
"created": 1700000000,
|
||||
"owned_by": "openai",
|
||||
},
|
||||
# Missing required fields
|
||||
{"bad_field": "bad_value"},
|
||||
]
|
||||
}
|
||||
|
||||
with patch("onyx.server.manage.llm.api.httpx.get") as mock_get:
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = response_with_bad_entry
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
request = LitellmModelsRequest(
|
||||
api_base="http://localhost:4000",
|
||||
api_key="test-key",
|
||||
)
|
||||
results = get_litellm_available_models(request, MagicMock(), mock_session)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0].model_name == "gpt-4o"
|
||||
|
||||
def test_all_entries_unparseable_raises_onyx_error(self) -> None:
|
||||
"""Test that OnyxError is raised when all entries fail to parse."""
|
||||
from onyx.server.manage.llm.api import get_litellm_available_models
|
||||
|
||||
mock_session = MagicMock()
|
||||
response_all_bad = {
|
||||
"data": [
|
||||
{"bad_field": "bad_value"},
|
||||
{"another_bad": 123},
|
||||
]
|
||||
}
|
||||
|
||||
with patch("onyx.server.manage.llm.api.httpx.get") as mock_get:
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = response_all_bad
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
request = LitellmModelsRequest(
|
||||
api_base="http://localhost:4000",
|
||||
api_key="test-key",
|
||||
)
|
||||
with pytest.raises(OnyxError, match="No compatible models"):
|
||||
get_litellm_available_models(request, MagicMock(), mock_session)
|
||||
|
||||
def test_api_base_trailing_slash_handled(self) -> None:
|
||||
"""Test that trailing slashes in api_base are handled correctly."""
|
||||
from onyx.server.manage.llm.api import get_litellm_available_models
|
||||
|
||||
mock_session = MagicMock()
|
||||
mock_litellm_response = {
|
||||
"data": [
|
||||
{
|
||||
"id": "gpt-4o",
|
||||
"object": "model",
|
||||
"created": 1700000000,
|
||||
"owned_by": "openai",
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
with patch("onyx.server.manage.llm.api.httpx.get") as mock_get:
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = mock_litellm_response
|
||||
mock_response.raise_for_status = MagicMock()
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
request = LitellmModelsRequest(
|
||||
api_base="http://localhost:4000/",
|
||||
api_key="test-key",
|
||||
)
|
||||
get_litellm_available_models(request, MagicMock(), mock_session)
|
||||
|
||||
# Should call /v1/models without double slashes
|
||||
call_args = mock_get.call_args
|
||||
assert call_args[0][0] == "http://localhost:4000/v1/models"
|
||||
|
||||
def test_connection_failure_raises_onyx_error(self) -> None:
|
||||
"""Test that connection failures are wrapped in OnyxError."""
|
||||
from onyx.server.manage.llm.api import get_litellm_available_models
|
||||
|
||||
mock_session = MagicMock()
|
||||
|
||||
with patch("onyx.server.manage.llm.api.httpx.get") as mock_get:
|
||||
mock_get.side_effect = Exception("Connection refused")
|
||||
|
||||
request = LitellmModelsRequest(
|
||||
api_base="http://localhost:4000",
|
||||
api_key="test-key",
|
||||
)
|
||||
with pytest.raises(OnyxError, match="Failed to fetch LiteLLM models"):
|
||||
get_litellm_available_models(request, MagicMock(), mock_session)
|
||||
|
||||
def test_401_raises_authentication_error(self) -> None:
|
||||
"""Test that a 401 response raises OnyxError with authentication message."""
|
||||
from onyx.server.manage.llm.api import get_litellm_available_models
|
||||
|
||||
mock_session = MagicMock()
|
||||
|
||||
with patch("onyx.server.manage.llm.api.httpx.get") as mock_get:
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 401
|
||||
mock_get.side_effect = httpx.HTTPStatusError(
|
||||
"Unauthorized", request=MagicMock(), response=mock_response
|
||||
)
|
||||
|
||||
request = LitellmModelsRequest(
|
||||
api_base="http://localhost:4000",
|
||||
api_key="bad-key",
|
||||
)
|
||||
with pytest.raises(OnyxError, match="Authentication failed"):
|
||||
get_litellm_available_models(request, MagicMock(), mock_session)
|
||||
|
||||
def test_404_raises_not_found_error(self) -> None:
|
||||
"""Test that a 404 response raises OnyxError with endpoint not found message."""
|
||||
from onyx.server.manage.llm.api import get_litellm_available_models
|
||||
|
||||
mock_session = MagicMock()
|
||||
|
||||
with patch("onyx.server.manage.llm.api.httpx.get") as mock_get:
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 404
|
||||
mock_get.side_effect = httpx.HTTPStatusError(
|
||||
"Not Found", request=MagicMock(), response=mock_response
|
||||
)
|
||||
|
||||
request = LitellmModelsRequest(
|
||||
api_base="http://localhost:4000",
|
||||
api_key="test-key",
|
||||
)
|
||||
with pytest.raises(OnyxError, match="endpoint not found"):
|
||||
get_litellm_available_models(request, MagicMock(), mock_session)
|
||||
|
||||
@@ -61,6 +61,9 @@ services:
|
||||
- POSTGRES_HOST=relational_db
|
||||
- POSTGRES_DEFAULT_SCHEMA=${POSTGRES_DEFAULT_SCHEMA:-}
|
||||
- VESPA_HOST=index
|
||||
- OPENSEARCH_HOST=${OPENSEARCH_HOST:-opensearch}
|
||||
- OPENSEARCH_ADMIN_PASSWORD=${OPENSEARCH_ADMIN_PASSWORD:-StrongPassword123!}
|
||||
- ENABLE_OPENSEARCH_INDEXING_FOR_ONYX=${OPENSEARCH_FOR_ONYX_ENABLED:-true}
|
||||
- REDIS_HOST=cache
|
||||
- WEB_DOMAIN=${WEB_DOMAIN:-}
|
||||
# MinIO configuration
|
||||
@@ -77,6 +80,7 @@ services:
|
||||
- DISABLE_RERANK_FOR_STREAMING=${DISABLE_RERANK_FOR_STREAMING:-}
|
||||
- MODEL_SERVER_HOST=${MODEL_SERVER_HOST:-inference_model_server}
|
||||
- MODEL_SERVER_PORT=${MODEL_SERVER_PORT:-}
|
||||
- CODE_INTERPRETER_BASE_URL=${CODE_INTERPRETER_BASE_URL:-http://code-interpreter:8000}
|
||||
- LOG_ONYX_MODEL_INTERACTIONS=${LOG_ONYX_MODEL_INTERACTIONS:-}
|
||||
- LOG_VESPA_TIMING_INFORMATION=${LOG_VESPA_TIMING_INFORMATION:-}
|
||||
- LOG_ENDPOINT_LATENCY=${LOG_ENDPOINT_LATENCY:-}
|
||||
@@ -168,6 +172,9 @@ services:
|
||||
- POSTGRES_DB=${POSTGRES_DB:-}
|
||||
- POSTGRES_DEFAULT_SCHEMA=${POSTGRES_DEFAULT_SCHEMA:-}
|
||||
- VESPA_HOST=index
|
||||
- OPENSEARCH_HOST=${OPENSEARCH_HOST:-opensearch}
|
||||
- OPENSEARCH_ADMIN_PASSWORD=${OPENSEARCH_ADMIN_PASSWORD:-StrongPassword123!}
|
||||
- ENABLE_OPENSEARCH_INDEXING_FOR_ONYX=${OPENSEARCH_FOR_ONYX_ENABLED:-true}
|
||||
- REDIS_HOST=cache
|
||||
- WEB_DOMAIN=${WEB_DOMAIN:-}
|
||||
# MinIO configuration
|
||||
@@ -424,6 +431,50 @@ services:
|
||||
max-size: "50m"
|
||||
max-file: "6"
|
||||
|
||||
opensearch:
|
||||
image: opensearchproject/opensearch:3.4.0
|
||||
restart: unless-stopped
|
||||
# Controls whether this service runs. In order to enable it, add
|
||||
# opensearch-enabled to COMPOSE_PROFILES in the environment for this
|
||||
# docker-compose.
|
||||
# NOTE: Now enabled on by default. To explicitly disable this service,
|
||||
# uncomment this profile and ensure COMPOSE_PROFILES in your env does not
|
||||
# list the profile, or when running docker compose, include all desired
|
||||
# service names but this one. Additionally set
|
||||
# OPENSEARCH_FOR_ONYX_ENABLED=false in your env.
|
||||
# profiles: ["opensearch-enabled"]
|
||||
environment:
|
||||
# We need discovery.type=single-node so that OpenSearch doesn't try
|
||||
# forming a cluster and waiting for other nodes to become live.
|
||||
- discovery.type=single-node
|
||||
- OPENSEARCH_INITIAL_ADMIN_PASSWORD=${OPENSEARCH_ADMIN_PASSWORD:-StrongPassword123!}
|
||||
# This and the JVM config below come from the example in https://docs.opensearch.org/latest/install-and-configure/install-opensearch/docker/
|
||||
# We do this to avoid unstable performance from page swaps.
|
||||
- bootstrap.memory_lock=true # Disable JVM heap memory swapping.
|
||||
# Java heap should be ~50% of memory limit. For now we assume a limit of
|
||||
# 4g although in practice the container can request more than this.
|
||||
# See https://opster.com/guides/opensearch/opensearch-basics/opensearch-heap-size-usage-and-jvm-garbage-collection/
|
||||
# Xms is the starting size, Xmx is the maximum size. These should be the
|
||||
# same.
|
||||
- "OPENSEARCH_JAVA_OPTS=-Xms2g -Xmx2g"
|
||||
volumes:
|
||||
- opensearch-data:/usr/share/opensearch/data
|
||||
# These come from the example in https://docs.opensearch.org/latest/install-and-configure/install-opensearch/docker/
|
||||
ulimits:
|
||||
# Similarly to bootstrap.memory_lock, we don't want to impose limits on
|
||||
# how much memory a process can lock from being swapped.
|
||||
memlock:
|
||||
soft: -1 # Set memlock to unlimited (no soft or hard limit).
|
||||
hard: -1
|
||||
nofile:
|
||||
soft: 65536 # Maximum number of open files for the opensearch user - set to at least 65536.
|
||||
hard: 65536
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "6"
|
||||
|
||||
nginx:
|
||||
image: nginx:1.25.5-alpine
|
||||
restart: unless-stopped
|
||||
@@ -508,3 +559,5 @@ volumes:
|
||||
model_cache_huggingface:
|
||||
indexing_huggingface_model_cache:
|
||||
# mcp_server_logs:
|
||||
# Persistent data for OpenSearch.
|
||||
opensearch-data:
|
||||
|
||||
@@ -21,6 +21,9 @@ services:
|
||||
- AUTH_TYPE=${AUTH_TYPE:-oidc}
|
||||
- POSTGRES_HOST=relational_db
|
||||
- VESPA_HOST=index
|
||||
- OPENSEARCH_HOST=${OPENSEARCH_HOST:-opensearch}
|
||||
- OPENSEARCH_ADMIN_PASSWORD=${OPENSEARCH_ADMIN_PASSWORD:-StrongPassword123!}
|
||||
- ENABLE_OPENSEARCH_INDEXING_FOR_ONYX=${OPENSEARCH_FOR_ONYX_ENABLED:-true}
|
||||
- REDIS_HOST=cache
|
||||
- MODEL_SERVER_HOST=${MODEL_SERVER_HOST:-inference_model_server}
|
||||
# MinIO configuration
|
||||
@@ -55,6 +58,9 @@ services:
|
||||
- AUTH_TYPE=${AUTH_TYPE:-oidc}
|
||||
- POSTGRES_HOST=relational_db
|
||||
- VESPA_HOST=index
|
||||
- OPENSEARCH_HOST=${OPENSEARCH_HOST:-opensearch}
|
||||
- OPENSEARCH_ADMIN_PASSWORD=${OPENSEARCH_ADMIN_PASSWORD:-StrongPassword123!}
|
||||
- ENABLE_OPENSEARCH_INDEXING_FOR_ONYX=${OPENSEARCH_FOR_ONYX_ENABLED:-true}
|
||||
- REDIS_HOST=cache
|
||||
- MODEL_SERVER_HOST=${MODEL_SERVER_HOST:-inference_model_server}
|
||||
- INDEXING_MODEL_SERVER_HOST=${INDEXING_MODEL_SERVER_HOST:-indexing_model_server}
|
||||
@@ -228,6 +234,50 @@ services:
|
||||
max-size: "50m"
|
||||
max-file: "6"
|
||||
|
||||
opensearch:
|
||||
image: opensearchproject/opensearch:3.4.0
|
||||
restart: unless-stopped
|
||||
# Controls whether this service runs. In order to enable it, add
|
||||
# opensearch-enabled to COMPOSE_PROFILES in the environment for this
|
||||
# docker-compose.
|
||||
# NOTE: Now enabled on by default. To explicitly disable this service,
|
||||
# uncomment this profile and ensure COMPOSE_PROFILES in your env does not
|
||||
# list the profile, or when running docker compose, include all desired
|
||||
# service names but this one. Additionally set
|
||||
# OPENSEARCH_FOR_ONYX_ENABLED=false in your env.
|
||||
# profiles: ["opensearch-enabled"]
|
||||
environment:
|
||||
# We need discovery.type=single-node so that OpenSearch doesn't try
|
||||
# forming a cluster and waiting for other nodes to become live.
|
||||
- discovery.type=single-node
|
||||
- OPENSEARCH_INITIAL_ADMIN_PASSWORD=${OPENSEARCH_ADMIN_PASSWORD:-StrongPassword123!}
|
||||
# This and the JVM config below come from the example in https://docs.opensearch.org/latest/install-and-configure/install-opensearch/docker/
|
||||
# We do this to avoid unstable performance from page swaps.
|
||||
- bootstrap.memory_lock=true # Disable JVM heap memory swapping.
|
||||
# Java heap should be ~50% of memory limit. For now we assume a limit of
|
||||
# 4g although in practice the container can request more than this.
|
||||
# See https://opster.com/guides/opensearch/opensearch-basics/opensearch-heap-size-usage-and-jvm-garbage-collection/
|
||||
# Xms is the starting size, Xmx is the maximum size. These should be the
|
||||
# same.
|
||||
- "OPENSEARCH_JAVA_OPTS=-Xms2g -Xmx2g"
|
||||
volumes:
|
||||
- opensearch-data:/usr/share/opensearch/data
|
||||
# These come from the example in https://docs.opensearch.org/latest/install-and-configure/install-opensearch/docker/
|
||||
ulimits:
|
||||
# Similarly to bootstrap.memory_lock, we don't want to impose limits on
|
||||
# how much memory a process can lock from being swapped.
|
||||
memlock:
|
||||
soft: -1 # Set memlock to unlimited (no soft or hard limit).
|
||||
hard: -1
|
||||
nofile:
|
||||
soft: 65536 # Maximum number of open files for the opensearch user - set to at least 65536.
|
||||
hard: 65536
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "6"
|
||||
|
||||
nginx:
|
||||
image: nginx:1.25.5-alpine
|
||||
restart: unless-stopped
|
||||
@@ -315,3 +365,5 @@ volumes:
|
||||
model_cache_huggingface:
|
||||
indexing_huggingface_model_cache:
|
||||
# mcp_server_logs:
|
||||
# Persistent data for OpenSearch.
|
||||
opensearch-data:
|
||||
|
||||
@@ -21,8 +21,12 @@ services:
|
||||
- AUTH_TYPE=${AUTH_TYPE:-oidc}
|
||||
- POSTGRES_HOST=relational_db
|
||||
- VESPA_HOST=index
|
||||
- OPENSEARCH_HOST=${OPENSEARCH_HOST:-opensearch}
|
||||
- OPENSEARCH_ADMIN_PASSWORD=${OPENSEARCH_ADMIN_PASSWORD:-StrongPassword123!}
|
||||
- ENABLE_OPENSEARCH_INDEXING_FOR_ONYX=${OPENSEARCH_FOR_ONYX_ENABLED:-true}
|
||||
- REDIS_HOST=cache
|
||||
- MODEL_SERVER_HOST=${MODEL_SERVER_HOST:-inference_model_server}
|
||||
- CODE_INTERPRETER_BASE_URL=${CODE_INTERPRETER_BASE_URL:-http://code-interpreter:8000}
|
||||
- USE_IAM_AUTH=${USE_IAM_AUTH}
|
||||
- AWS_REGION_NAME=${AWS_REGION_NAME-}
|
||||
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID-}
|
||||
@@ -68,6 +72,9 @@ services:
|
||||
- AUTH_TYPE=${AUTH_TYPE:-oidc}
|
||||
- POSTGRES_HOST=relational_db
|
||||
- VESPA_HOST=index
|
||||
- OPENSEARCH_HOST=${OPENSEARCH_HOST:-opensearch}
|
||||
- OPENSEARCH_ADMIN_PASSWORD=${OPENSEARCH_ADMIN_PASSWORD:-StrongPassword123!}
|
||||
- ENABLE_OPENSEARCH_INDEXING_FOR_ONYX=${OPENSEARCH_FOR_ONYX_ENABLED:-true}
|
||||
- REDIS_HOST=cache
|
||||
- MODEL_SERVER_HOST=${MODEL_SERVER_HOST:-inference_model_server}
|
||||
- INDEXING_MODEL_SERVER_HOST=${INDEXING_MODEL_SERVER_HOST:-indexing_model_server}
|
||||
@@ -251,6 +258,50 @@ services:
|
||||
max-size: "50m"
|
||||
max-file: "6"
|
||||
|
||||
opensearch:
|
||||
image: opensearchproject/opensearch:3.4.0
|
||||
restart: unless-stopped
|
||||
# Controls whether this service runs. In order to enable it, add
|
||||
# opensearch-enabled to COMPOSE_PROFILES in the environment for this
|
||||
# docker-compose.
|
||||
# NOTE: Now enabled on by default. To explicitly disable this service,
|
||||
# uncomment this profile and ensure COMPOSE_PROFILES in your env does not
|
||||
# list the profile, or when running docker compose, include all desired
|
||||
# service names but this one. Additionally set
|
||||
# OPENSEARCH_FOR_ONYX_ENABLED=false in your env.
|
||||
# profiles: ["opensearch-enabled"]
|
||||
environment:
|
||||
# We need discovery.type=single-node so that OpenSearch doesn't try
|
||||
# forming a cluster and waiting for other nodes to become live.
|
||||
- discovery.type=single-node
|
||||
- OPENSEARCH_INITIAL_ADMIN_PASSWORD=${OPENSEARCH_ADMIN_PASSWORD:-StrongPassword123!}
|
||||
# This and the JVM config below come from the example in https://docs.opensearch.org/latest/install-and-configure/install-opensearch/docker/
|
||||
# We do this to avoid unstable performance from page swaps.
|
||||
- bootstrap.memory_lock=true # Disable JVM heap memory swapping.
|
||||
# Java heap should be ~50% of memory limit. For now we assume a limit of
|
||||
# 4g although in practice the container can request more than this.
|
||||
# See https://opster.com/guides/opensearch/opensearch-basics/opensearch-heap-size-usage-and-jvm-garbage-collection/
|
||||
# Xms is the starting size, Xmx is the maximum size. These should be the
|
||||
# same.
|
||||
- "OPENSEARCH_JAVA_OPTS=-Xms2g -Xmx2g"
|
||||
volumes:
|
||||
- opensearch-data:/usr/share/opensearch/data
|
||||
# These come from the example in https://docs.opensearch.org/latest/install-and-configure/install-opensearch/docker/
|
||||
ulimits:
|
||||
# Similarly to bootstrap.memory_lock, we don't want to impose limits on
|
||||
# how much memory a process can lock from being swapped.
|
||||
memlock:
|
||||
soft: -1 # Set memlock to unlimited (no soft or hard limit).
|
||||
hard: -1
|
||||
nofile:
|
||||
soft: 65536 # Maximum number of open files for the opensearch user - set to at least 65536.
|
||||
hard: 65536
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "6"
|
||||
|
||||
nginx:
|
||||
image: nginx:1.25.5-alpine
|
||||
restart: unless-stopped
|
||||
@@ -343,3 +394,5 @@ volumes:
|
||||
# mcp_server_logs:
|
||||
# Shared volume for persistent document storage (Craft file-system mode)
|
||||
file-system:
|
||||
# Persistent data for OpenSearch.
|
||||
opensearch-data:
|
||||
|
||||
@@ -22,8 +22,12 @@ services:
|
||||
- AUTH_TYPE=${AUTH_TYPE:-oidc}
|
||||
- POSTGRES_HOST=relational_db
|
||||
- VESPA_HOST=index
|
||||
- OPENSEARCH_HOST=${OPENSEARCH_HOST:-opensearch}
|
||||
- OPENSEARCH_ADMIN_PASSWORD=${OPENSEARCH_ADMIN_PASSWORD:-StrongPassword123!}
|
||||
- ENABLE_OPENSEARCH_INDEXING_FOR_ONYX=${OPENSEARCH_FOR_ONYX_ENABLED:-true}
|
||||
- REDIS_HOST=cache
|
||||
- MODEL_SERVER_HOST=${MODEL_SERVER_HOST:-inference_model_server}
|
||||
- CODE_INTERPRETER_BASE_URL=${CODE_INTERPRETER_BASE_URL:-http://code-interpreter:8000}
|
||||
- USE_IAM_AUTH=${USE_IAM_AUTH}
|
||||
- AWS_REGION_NAME=${AWS_REGION_NAME-}
|
||||
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID-}
|
||||
@@ -73,6 +77,9 @@ services:
|
||||
- AUTH_TYPE=${AUTH_TYPE:-oidc}
|
||||
- POSTGRES_HOST=relational_db
|
||||
- VESPA_HOST=index
|
||||
- OPENSEARCH_HOST=${OPENSEARCH_HOST:-opensearch}
|
||||
- OPENSEARCH_ADMIN_PASSWORD=${OPENSEARCH_ADMIN_PASSWORD:-StrongPassword123!}
|
||||
- ENABLE_OPENSEARCH_INDEXING_FOR_ONYX=${OPENSEARCH_FOR_ONYX_ENABLED:-true}
|
||||
- REDIS_HOST=cache
|
||||
- MODEL_SERVER_HOST=${MODEL_SERVER_HOST:-inference_model_server}
|
||||
- INDEXING_MODEL_SERVER_HOST=${INDEXING_MODEL_SERVER_HOST:-indexing_model_server}
|
||||
@@ -270,6 +277,50 @@ services:
|
||||
max-size: "50m"
|
||||
max-file: "6"
|
||||
|
||||
opensearch:
|
||||
image: opensearchproject/opensearch:3.4.0
|
||||
restart: unless-stopped
|
||||
# Controls whether this service runs. In order to enable it, add
|
||||
# opensearch-enabled to COMPOSE_PROFILES in the environment for this
|
||||
# docker-compose.
|
||||
# NOTE: Now enabled on by default. To explicitly disable this service,
|
||||
# uncomment this profile and ensure COMPOSE_PROFILES in your env does not
|
||||
# list the profile, or when running docker compose, include all desired
|
||||
# service names but this one. Additionally set
|
||||
# OPENSEARCH_FOR_ONYX_ENABLED=false in your env.
|
||||
# profiles: ["opensearch-enabled"]
|
||||
environment:
|
||||
# We need discovery.type=single-node so that OpenSearch doesn't try
|
||||
# forming a cluster and waiting for other nodes to become live.
|
||||
- discovery.type=single-node
|
||||
- OPENSEARCH_INITIAL_ADMIN_PASSWORD=${OPENSEARCH_ADMIN_PASSWORD:-StrongPassword123!}
|
||||
# This and the JVM config below come from the example in https://docs.opensearch.org/latest/install-and-configure/install-opensearch/docker/
|
||||
# We do this to avoid unstable performance from page swaps.
|
||||
- bootstrap.memory_lock=true # Disable JVM heap memory swapping.
|
||||
# Java heap should be ~50% of memory limit. For now we assume a limit of
|
||||
# 4g although in practice the container can request more than this.
|
||||
# See https://opster.com/guides/opensearch/opensearch-basics/opensearch-heap-size-usage-and-jvm-garbage-collection/
|
||||
# Xms is the starting size, Xmx is the maximum size. These should be the
|
||||
# same.
|
||||
- "OPENSEARCH_JAVA_OPTS=-Xms2g -Xmx2g"
|
||||
volumes:
|
||||
- opensearch-data:/usr/share/opensearch/data
|
||||
# These come from the example in https://docs.opensearch.org/latest/install-and-configure/install-opensearch/docker/
|
||||
ulimits:
|
||||
# Similarly to bootstrap.memory_lock, we don't want to impose limits on
|
||||
# how much memory a process can lock from being swapped.
|
||||
memlock:
|
||||
soft: -1 # Set memlock to unlimited (no soft or hard limit).
|
||||
hard: -1
|
||||
nofile:
|
||||
soft: 65536 # Maximum number of open files for the opensearch user - set to at least 65536.
|
||||
hard: 65536
|
||||
logging:
|
||||
driver: json-file
|
||||
options:
|
||||
max-size: "50m"
|
||||
max-file: "6"
|
||||
|
||||
nginx:
|
||||
image: nginx:1.25.5-alpine
|
||||
restart: unless-stopped
|
||||
@@ -380,3 +431,5 @@ volumes:
|
||||
# mcp_server_logs:
|
||||
# Shared volume for persistent document storage (Craft file-system mode)
|
||||
file-system:
|
||||
# Persistent data for OpenSearch.
|
||||
opensearch-data:
|
||||
|
||||
@@ -57,6 +57,9 @@ services:
|
||||
condition: service_started
|
||||
index:
|
||||
condition: service_started
|
||||
opensearch:
|
||||
condition: service_started
|
||||
required: false
|
||||
cache:
|
||||
condition: service_started
|
||||
inference_model_server:
|
||||
@@ -78,9 +81,10 @@ services:
|
||||
- VESPA_HOST=${VESPA_HOST:-index}
|
||||
- OPENSEARCH_HOST=${OPENSEARCH_HOST:-opensearch}
|
||||
- OPENSEARCH_ADMIN_PASSWORD=${OPENSEARCH_ADMIN_PASSWORD:-StrongPassword123!}
|
||||
- ENABLE_OPENSEARCH_INDEXING_FOR_ONYX=${OPENSEARCH_FOR_ONYX_ENABLED:-false}
|
||||
- ENABLE_OPENSEARCH_INDEXING_FOR_ONYX=${OPENSEARCH_FOR_ONYX_ENABLED:-true}
|
||||
- REDIS_HOST=${REDIS_HOST:-cache}
|
||||
- MODEL_SERVER_HOST=${MODEL_SERVER_HOST:-inference_model_server}
|
||||
- CODE_INTERPRETER_BASE_URL=${CODE_INTERPRETER_BASE_URL:-http://code-interpreter:8000}
|
||||
- S3_ENDPOINT_URL=${S3_ENDPOINT_URL:-http://minio:9000}
|
||||
- S3_AWS_ACCESS_KEY_ID=${S3_AWS_ACCESS_KEY_ID:-minioadmin}
|
||||
- S3_AWS_SECRET_ACCESS_KEY=${S3_AWS_SECRET_ACCESS_KEY:-minioadmin}
|
||||
@@ -139,11 +143,19 @@ services:
|
||||
- path: .env
|
||||
required: false
|
||||
depends_on:
|
||||
- relational_db
|
||||
- index
|
||||
- cache
|
||||
- inference_model_server
|
||||
- indexing_model_server
|
||||
relational_db:
|
||||
condition: service_started
|
||||
index:
|
||||
condition: service_started
|
||||
opensearch:
|
||||
condition: service_started
|
||||
required: false
|
||||
cache:
|
||||
condition: service_started
|
||||
inference_model_server:
|
||||
condition: service_started
|
||||
indexing_model_server:
|
||||
condition: service_started
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
- FILE_STORE_BACKEND=${FILE_STORE_BACKEND:-s3}
|
||||
@@ -151,7 +163,7 @@ services:
|
||||
- VESPA_HOST=${VESPA_HOST:-index}
|
||||
- OPENSEARCH_HOST=${OPENSEARCH_HOST:-opensearch}
|
||||
- OPENSEARCH_ADMIN_PASSWORD=${OPENSEARCH_ADMIN_PASSWORD:-StrongPassword123!}
|
||||
- ENABLE_OPENSEARCH_INDEXING_FOR_ONYX=${OPENSEARCH_FOR_ONYX_ENABLED:-false}
|
||||
- ENABLE_OPENSEARCH_INDEXING_FOR_ONYX=${OPENSEARCH_FOR_ONYX_ENABLED:-true}
|
||||
- REDIS_HOST=${REDIS_HOST:-cache}
|
||||
- MODEL_SERVER_HOST=${MODEL_SERVER_HOST:-inference_model_server}
|
||||
- INDEXING_MODEL_SERVER_HOST=${INDEXING_MODEL_SERVER_HOST:-indexing_model_server}
|
||||
@@ -406,7 +418,12 @@ services:
|
||||
# Controls whether this service runs. In order to enable it, add
|
||||
# opensearch-enabled to COMPOSE_PROFILES in the environment for this
|
||||
# docker-compose.
|
||||
profiles: ["opensearch-enabled"]
|
||||
# NOTE: Now enabled on by default. To explicitly disable this service,
|
||||
# uncomment this profile and ensure COMPOSE_PROFILES in your env does not
|
||||
# list the profile, or when running docker compose, include all desired
|
||||
# service names but this one. Additionally set
|
||||
# OPENSEARCH_FOR_ONYX_ENABLED=false in your env.
|
||||
# profiles: ["opensearch-enabled"]
|
||||
environment:
|
||||
# We need discovery.type=single-node so that OpenSearch doesn't try
|
||||
# forming a cluster and waiting for other nodes to become live.
|
||||
@@ -416,11 +433,11 @@ services:
|
||||
# We do this to avoid unstable performance from page swaps.
|
||||
- bootstrap.memory_lock=true # Disable JVM heap memory swapping.
|
||||
# Java heap should be ~50% of memory limit. For now we assume a limit of
|
||||
# 2g although in practice the container can request more than this.
|
||||
# 4g although in practice the container can request more than this.
|
||||
# See https://opster.com/guides/opensearch/opensearch-basics/opensearch-heap-size-usage-and-jvm-garbage-collection/
|
||||
# Xms is the starting size, Xmx is the maximum size. These should be the
|
||||
# same.
|
||||
- "OPENSEARCH_JAVA_OPTS=-Xms1g -Xmx1g"
|
||||
- "OPENSEARCH_JAVA_OPTS=-Xms2g -Xmx2g"
|
||||
volumes:
|
||||
- opensearch-data:/usr/share/opensearch/data
|
||||
# These come from the example in https://docs.opensearch.org/latest/install-and-configure/install-opensearch/docker/
|
||||
|
||||
@@ -67,10 +67,8 @@ POSTGRES_PASSWORD=password
|
||||
## remove s3-filestore from COMPOSE_PROFILES and set FILE_STORE_BACKEND=postgres.
|
||||
COMPOSE_PROFILES=s3-filestore
|
||||
FILE_STORE_BACKEND=s3
|
||||
## Settings for enabling OpenSearch. Uncomment these and comment out
|
||||
## COMPOSE_PROFILES above.
|
||||
# COMPOSE_PROFILES=s3-filestore,opensearch-enabled
|
||||
# OPENSEARCH_FOR_ONYX_ENABLED=true
|
||||
## Setting for enabling OpenSearch.
|
||||
OPENSEARCH_FOR_ONYX_ENABLED=true
|
||||
|
||||
## MinIO/S3 Configuration (only needed when FILE_STORE_BACKEND=s3)
|
||||
S3_ENDPOINT_URL=http://minio:9000
|
||||
|
||||
@@ -5,7 +5,7 @@ home: https://www.onyx.app/
|
||||
sources:
|
||||
- "https://github.com/onyx-dot-app/onyx"
|
||||
type: application
|
||||
version: 0.4.32
|
||||
version: 0.4.33
|
||||
appVersion: latest
|
||||
annotations:
|
||||
category: Productivity
|
||||
|
||||
@@ -76,7 +76,10 @@ vespa:
|
||||
memory: 32000Mi
|
||||
|
||||
opensearch:
|
||||
enabled: false
|
||||
# Enabled by default. Override to false and set the appropriate env vars in
|
||||
# the instance-specific values yaml if using AWS-managed OpenSearch, or simply
|
||||
# override to false to entirely disable.
|
||||
enabled: true
|
||||
# These values are passed to the opensearch subchart.
|
||||
# See https://github.com/opensearch-project/helm-charts/blob/main/charts/opensearch/values.yaml
|
||||
|
||||
@@ -1158,8 +1161,10 @@ auth:
|
||||
opensearch:
|
||||
# Enable or disable this secret entirely. Will remove from env var
|
||||
# configurations and remove any created secrets.
|
||||
# Set to true when opensearch.enabled is true.
|
||||
enabled: false
|
||||
# Enabled by default. Override to false and set the appropriate env vars in
|
||||
# the instance-specific values yaml if using AWS-managed OpenSearch, or
|
||||
# simply override to false to entirely disable.
|
||||
enabled: true
|
||||
# Overwrite the default secret name, ignored if existingSecret is defined.
|
||||
secretName: 'onyx-opensearch'
|
||||
# Use a secret specified elsewhere.
|
||||
|
||||
@@ -153,7 +153,7 @@ dev = [
|
||||
"pytest-repeat==0.9.4",
|
||||
"pytest-xdist==3.8.0",
|
||||
"pytest==8.3.5",
|
||||
"release-tag==0.4.3",
|
||||
"release-tag==0.5.2",
|
||||
"reorder-python-imports-black==3.14.0",
|
||||
"ruff==0.12.0",
|
||||
"types-beautifulsoup4==4.12.0.3",
|
||||
|
||||
18
uv.lock
generated
18
uv.lock
generated
@@ -4485,7 +4485,7 @@ requires-dist = [
|
||||
{ name = "pywikibot", marker = "extra == 'backend'", specifier = "==9.0.0" },
|
||||
{ name = "rapidfuzz", marker = "extra == 'backend'", specifier = "==3.13.0" },
|
||||
{ name = "redis", marker = "extra == 'backend'", specifier = "==5.0.8" },
|
||||
{ name = "release-tag", marker = "extra == 'dev'", specifier = "==0.4.3" },
|
||||
{ name = "release-tag", marker = "extra == 'dev'", specifier = "==0.5.2" },
|
||||
{ name = "reorder-python-imports-black", marker = "extra == 'dev'", specifier = "==3.14.0" },
|
||||
{ name = "requests", marker = "extra == 'backend'", specifier = "==2.32.5" },
|
||||
{ name = "requests-oauthlib", marker = "extra == 'backend'", specifier = "==1.3.1" },
|
||||
@@ -6338,16 +6338,16 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "release-tag"
|
||||
version = "0.4.3"
|
||||
version = "0.5.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/39/18/c1d17d973f73f0aa7e2c45f852839ab909756e1bd9727d03babe400fcef0/release_tag-0.4.3-py3-none-any.whl", hash = "sha256:4206f4fa97df930c8176bfee4d3976a7385150ed14b317bd6bae7101ac8b66dd", size = 1181112, upload-time = "2025-12-03T00:18:19.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/c7/ecc443953840ac313856b2181f55eb8d34fa2c733cdd1edd0bcceee0938d/release_tag-0.4.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7a347a9ad3d2af16e5367e52b451fbc88a0b7b666850758e8f9a601554a8fb13", size = 1170517, upload-time = "2025-12-03T00:18:11.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/81/2f6ffa0d87c792364ca9958433fe088c8acc3d096ac9734040049c6ad506/release_tag-0.4.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2d1603aa37d8e4f5df63676bbfddc802fbc108a744ba28288ad25c997981c164", size = 1101663, upload-time = "2025-12-03T00:18:15.173Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/ed/9e4ebe400fc52e38dda6e6a45d9da9decd4535ab15e170b8d9b229a66730/release_tag-0.4.3-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:6db7b81a198e3ba6a87496a554684912c13f9297ea8db8600a80f4f971709d37", size = 1079322, upload-time = "2025-12-03T00:18:16.094Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/64/9e0ce6119e091ef9211fa82b9593f564eeec8bdd86eff6a97fe6e2fcb20f/release_tag-0.4.3-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:d79a9cf191dd2c29e1b3a35453fa364b08a7aadd15aeb2c556a7661c6cf4d5ad", size = 1181129, upload-time = "2025-12-03T00:18:15.82Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/09/d96acf18f0773b6355080a568ba48931faa9dbe91ab1abefc6f8c4df04a8/release_tag-0.4.3-py3-none-win_amd64.whl", hash = "sha256:3958b880375f2241d0cc2b9882363bf54b1d4d7ca8ffc6eecc63ab92f23307f0", size = 1260773, upload-time = "2025-12-03T00:18:14.723Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/da/ecb6346df1ffb0752fe213e25062f802c10df2948717f0d5f9816c2df914/release_tag-0.4.3-py3-none-win_arm64.whl", hash = "sha256:7d5b08000e6e398d46f05a50139031046348fba6d47909f01e468bb7600c19df", size = 1142155, upload-time = "2025-12-03T00:18:20.647Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/92/01192a540b29cfadaa23850c8f6a2041d541b83a3fa1dc52a5f55212b3b6/release_tag-0.5.2-py3-none-any.whl", hash = "sha256:1e9ca7618bcfc63ad7a0728c84bbad52ef82d07586c4cc11365b44ea8f588069", size = 1264752, upload-time = "2026-03-11T00:27:18.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/77/81fb42a23cd0de61caf84266f7aac1950b1c324883788b7c48e5344f61ae/release_tag-0.5.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8fbc61ff7bac2b96fab09566ec45c6508c201efc3f081f57702e1761bbc178d5", size = 1255075, upload-time = "2026-03-11T00:27:24.442Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/e6/769f8be94304529c1a531e995f2f3ac83f3c54738ce488b0abde75b20851/release_tag-0.5.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fa3d7e495a0c516858a81878d03803539712677a3d6e015503de21cce19bea5e", size = 1163627, upload-time = "2026-03-11T00:27:26.412Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/68/7543e9daa0dfd41c487bf140d91fd5879327bb7c001a96aa5264667c30a1/release_tag-0.5.2-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:e8b60453218d6926da1fdcb99c2e17c851be0d7ab1975e97951f0bff5f32b565", size = 1140133, upload-time = "2026-03-11T00:27:20.633Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/30/9087825696271012d889d136310dbdf0811976ae2b2f5a490f4e437903e1/release_tag-0.5.2-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:0e302ed60c2bf8b7ba5634842be28a27d83cec995869e112b0348b3f01a84ff5", size = 1264767, upload-time = "2026-03-11T00:27:28.355Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/a3/5b51b0cbdbf2299f545124beab182cfdfe01bf5b615efbc94aee3a64ea67/release_tag-0.5.2-py3-none-win_amd64.whl", hash = "sha256:e3c0629d373a16b9a3da965e89fca893640ce9878ec548865df3609b70989a89", size = 1340816, upload-time = "2026-03-11T00:27:22.622Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/6f/832c2023a8bd8414c93452bd8b43bf61cedfa5b9575f70c06fb911e51a29/release_tag-0.5.2-py3-none-win_arm64.whl", hash = "sha256:5f26b008e0be0c7a122acd8fcb1bb5c822f38e77fed0c0bf6c550cc226c6bf14", size = 1203191, upload-time = "2026-03-11T00:27:29.789Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -144,6 +144,7 @@ module.exports = {
|
||||
"**/src/app/**/hooks/*.test.ts", // Pure packet processor tests
|
||||
"**/src/refresh-components/**/*.test.ts",
|
||||
"**/src/sections/**/*.test.ts",
|
||||
"**/src/components/**/*.test.ts",
|
||||
// Add more patterns here as you add more unit tests
|
||||
],
|
||||
},
|
||||
|
||||
@@ -7,7 +7,7 @@ import { SlackTokensForm } from "./SlackTokensForm";
|
||||
import * as SettingsLayouts from "@/layouts/settings-layouts";
|
||||
import { SvgSlack } from "@opal/icons";
|
||||
|
||||
export const NewSlackBotForm = () => {
|
||||
export function NewSlackBotForm() {
|
||||
const [formValues] = useState({
|
||||
name: "",
|
||||
enabled: true,
|
||||
@@ -19,7 +19,12 @@ export const NewSlackBotForm = () => {
|
||||
|
||||
return (
|
||||
<SettingsLayouts.Root>
|
||||
<SettingsLayouts.Header icon={SvgSlack} title="New Slack Bot" separator />
|
||||
<SettingsLayouts.Header
|
||||
icon={SvgSlack}
|
||||
title="New Slack Bot"
|
||||
separator
|
||||
backButton
|
||||
/>
|
||||
<SettingsLayouts.Body>
|
||||
<CardSection>
|
||||
<div className="p-4">
|
||||
@@ -33,4 +38,4 @@ export const NewSlackBotForm = () => {
|
||||
</SettingsLayouts.Body>
|
||||
</SettingsLayouts.Root>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { toast } from "@/hooks/useToast";
|
||||
import { SlackBot, ValidSources } from "@/lib/types";
|
||||
import { SlackBot } from "@/lib/types";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { updateSlackBotField } from "@/lib/updateSlackBotField";
|
||||
import { SlackTokensForm } from "./SlackTokensForm";
|
||||
import { SourceIcon } from "@/components/SourceIcon";
|
||||
|
||||
import { EditableStringFieldDisplay } from "@/components/EditableStringFieldDisplay";
|
||||
import { deleteSlackBot } from "./new/lib";
|
||||
import GenericConfirmModal from "@/components/modals/GenericConfirmModal";
|
||||
@@ -90,10 +90,7 @@ export const ExistingSlackBotForm = ({
|
||||
<div>
|
||||
<div className="flex items-center justify-between h-14">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="my-auto">
|
||||
<SourceIcon iconSize={32} sourceType={ValidSources.Slack} />
|
||||
</div>
|
||||
<div className="ml-1">
|
||||
<div>
|
||||
<EditableStringFieldDisplay
|
||||
value={formValues.name}
|
||||
isEditable={true}
|
||||
|
||||
@@ -1,100 +1,122 @@
|
||||
import { SlackChannelConfigCreationForm } from "../SlackChannelConfigCreationForm";
|
||||
import { fetchSS } from "@/lib/utilsSS";
|
||||
"use client";
|
||||
|
||||
import { use } from "react";
|
||||
import { SlackChannelConfigCreationForm } from "@/app/admin/bots/[bot-id]/channels/SlackChannelConfigCreationForm";
|
||||
import { ErrorCallout } from "@/components/ErrorCallout";
|
||||
import { DocumentSetSummary, SlackChannelConfig } from "@/lib/types";
|
||||
import { InstantSSRAutoRefresh } from "@/components/SSRAutoRefresh";
|
||||
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
|
||||
import * as SettingsLayouts from "@/layouts/settings-layouts";
|
||||
import { SvgSlack } from "@opal/icons";
|
||||
import { FetchAgentsResponse, fetchAgentsSS } from "@/lib/agentsSS";
|
||||
import { getStandardAnswerCategoriesIfEE } from "@/components/standardAnswers/getStandardAnswerCategoriesIfEE";
|
||||
import { useSlackChannelConfigs } from "@/app/admin/bots/[bot-id]/hooks";
|
||||
import { useDocumentSets } from "@/app/admin/documents/sets/hooks";
|
||||
import { useAgents } from "@/hooks/useAgents";
|
||||
import { useStandardAnswerCategories } from "@/app/ee/admin/standard-answer/hooks";
|
||||
import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled";
|
||||
import type { StandardAnswerCategoryResponse } from "@/components/standardAnswers/getStandardAnswerCategoriesIfEE";
|
||||
|
||||
async function EditslackChannelConfigPage(props: {
|
||||
params: Promise<{ id: number }>;
|
||||
}) {
|
||||
const params = await props.params;
|
||||
const tasks = [
|
||||
fetchSS("/manage/admin/slack-app/channel"),
|
||||
fetchSS("/manage/document-set"),
|
||||
fetchAgentsSS(),
|
||||
];
|
||||
function EditSlackChannelConfigContent({ id }: { id: string }) {
|
||||
const isPaidEnterprise = usePaidEnterpriseFeaturesEnabled();
|
||||
|
||||
const [
|
||||
slackChannelsResponse,
|
||||
documentSetsResponse,
|
||||
[assistants, agentsFetchError],
|
||||
] = (await Promise.all(tasks)) as [Response, Response, FetchAgentsResponse];
|
||||
const {
|
||||
data: slackChannelConfigs,
|
||||
isLoading: isChannelsLoading,
|
||||
error: channelsError,
|
||||
} = useSlackChannelConfigs();
|
||||
|
||||
const eeStandardAnswerCategoryResponse =
|
||||
await getStandardAnswerCategoriesIfEE();
|
||||
const {
|
||||
data: documentSets,
|
||||
isLoading: isDocSetsLoading,
|
||||
error: docSetsError,
|
||||
} = useDocumentSets();
|
||||
|
||||
if (!slackChannelsResponse.ok) {
|
||||
return (
|
||||
<ErrorCallout
|
||||
errorTitle="Something went wrong :("
|
||||
errorMsg={`Failed to fetch Slack Channels - ${await slackChannelsResponse.text()}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const allslackChannelConfigs =
|
||||
(await slackChannelsResponse.json()) as SlackChannelConfig[];
|
||||
const {
|
||||
agents,
|
||||
isLoading: isAgentsLoading,
|
||||
error: agentsError,
|
||||
} = useAgents();
|
||||
|
||||
const slackChannelConfig = allslackChannelConfigs.find(
|
||||
(config) => config.id === Number(params.id)
|
||||
const {
|
||||
data: standardAnswerCategories,
|
||||
isLoading: isStdAnswerLoading,
|
||||
error: stdAnswerError,
|
||||
} = useStandardAnswerCategories();
|
||||
|
||||
const isLoading =
|
||||
isChannelsLoading ||
|
||||
isDocSetsLoading ||
|
||||
isAgentsLoading ||
|
||||
(isPaidEnterprise && isStdAnswerLoading);
|
||||
|
||||
const slackChannelConfig = slackChannelConfigs?.find(
|
||||
(config) => config.id === Number(id)
|
||||
);
|
||||
|
||||
if (!slackChannelConfig) {
|
||||
return (
|
||||
<ErrorCallout
|
||||
errorTitle="Something went wrong :("
|
||||
errorMsg={`Did not find Slack Channel config with ID: ${params.id}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!documentSetsResponse.ok) {
|
||||
return (
|
||||
<ErrorCallout
|
||||
errorTitle="Something went wrong :("
|
||||
errorMsg={`Failed to fetch document sets - ${await documentSetsResponse.text()}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const response = await documentSetsResponse.json();
|
||||
const documentSets = response as DocumentSetSummary[];
|
||||
|
||||
if (agentsFetchError) {
|
||||
return (
|
||||
<ErrorCallout
|
||||
errorTitle="Something went wrong :("
|
||||
errorMsg={`Failed to fetch personas - ${agentsFetchError}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const title = slackChannelConfig?.is_default
|
||||
? "Edit Default Slack Config"
|
||||
: "Edit Slack Channel Config";
|
||||
|
||||
return (
|
||||
<SettingsLayouts.Root>
|
||||
<InstantSSRAutoRefresh />
|
||||
<SettingsLayouts.Header
|
||||
icon={SvgSlack}
|
||||
title={
|
||||
slackChannelConfig.is_default
|
||||
? "Edit Default Slack Config"
|
||||
: "Edit Slack Channel Config"
|
||||
}
|
||||
title={title}
|
||||
separator
|
||||
backButton
|
||||
/>
|
||||
<SettingsLayouts.Body>
|
||||
<SlackChannelConfigCreationForm
|
||||
slack_bot_id={slackChannelConfig.slack_bot_id}
|
||||
documentSets={documentSets}
|
||||
personas={assistants}
|
||||
standardAnswerCategoryResponse={eeStandardAnswerCategoryResponse}
|
||||
existingSlackChannelConfig={slackChannelConfig}
|
||||
/>
|
||||
{isLoading ? (
|
||||
<SimpleLoader />
|
||||
) : channelsError || !slackChannelConfigs ? (
|
||||
<ErrorCallout
|
||||
errorTitle="Something went wrong :("
|
||||
errorMsg={`Failed to fetch Slack Channels - ${
|
||||
channelsError?.message ?? "unknown error"
|
||||
}`}
|
||||
/>
|
||||
) : !slackChannelConfig ? (
|
||||
<ErrorCallout
|
||||
errorTitle="Something went wrong :("
|
||||
errorMsg={`Did not find Slack Channel config with ID: ${id}`}
|
||||
/>
|
||||
) : docSetsError || !documentSets ? (
|
||||
<ErrorCallout
|
||||
errorTitle="Something went wrong :("
|
||||
errorMsg={`Failed to fetch document sets - ${
|
||||
docSetsError?.message ?? "unknown error"
|
||||
}`}
|
||||
/>
|
||||
) : agentsError ? (
|
||||
<ErrorCallout
|
||||
errorTitle="Something went wrong :("
|
||||
errorMsg={`Failed to fetch agents - ${
|
||||
agentsError?.message ?? "unknown error"
|
||||
}`}
|
||||
/>
|
||||
) : (
|
||||
<SlackChannelConfigCreationForm
|
||||
slack_bot_id={slackChannelConfig.slack_bot_id}
|
||||
documentSets={documentSets}
|
||||
personas={agents}
|
||||
standardAnswerCategoryResponse={
|
||||
isPaidEnterprise
|
||||
? {
|
||||
paidEnterpriseFeaturesEnabled: true,
|
||||
categories: standardAnswerCategories ?? [],
|
||||
...(stdAnswerError
|
||||
? { error: { message: String(stdAnswerError) } }
|
||||
: {}),
|
||||
}
|
||||
: { paidEnterpriseFeaturesEnabled: false }
|
||||
}
|
||||
existingSlackChannelConfig={slackChannelConfig}
|
||||
/>
|
||||
)}
|
||||
</SettingsLayouts.Body>
|
||||
</SettingsLayouts.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditslackChannelConfigPage;
|
||||
export default function Page(props: { params: Promise<{ id: string }> }) {
|
||||
const params = use(props.params);
|
||||
|
||||
return <EditSlackChannelConfigContent id={params.id} />;
|
||||
}
|
||||
|
||||
@@ -1,53 +1,109 @@
|
||||
import { SlackChannelConfigCreationForm } from "../SlackChannelConfigCreationForm";
|
||||
import { fetchSS } from "@/lib/utilsSS";
|
||||
"use client";
|
||||
|
||||
import { use, useEffect } from "react";
|
||||
import { SlackChannelConfigCreationForm } from "@/app/admin/bots/[bot-id]/channels/SlackChannelConfigCreationForm";
|
||||
import { ErrorCallout } from "@/components/ErrorCallout";
|
||||
import { DocumentSetSummary } from "@/lib/types";
|
||||
import { fetchAgentsSS } from "@/lib/agentsSS";
|
||||
import { getStandardAnswerCategoriesIfEE } from "@/components/standardAnswers/getStandardAnswerCategoriesIfEE";
|
||||
import { redirect } from "next/navigation";
|
||||
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
|
||||
import * as SettingsLayouts from "@/layouts/settings-layouts";
|
||||
import { SvgSlack } from "@opal/icons";
|
||||
import { useDocumentSets } from "@/app/admin/documents/sets/hooks";
|
||||
import { useAgents } from "@/hooks/useAgents";
|
||||
import { useStandardAnswerCategories } from "@/app/ee/admin/standard-answer/hooks";
|
||||
import { usePaidEnterpriseFeaturesEnabled } from "@/components/settings/usePaidEnterpriseFeaturesEnabled";
|
||||
import type { StandardAnswerCategoryResponse } from "@/components/standardAnswers/getStandardAnswerCategoriesIfEE";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
function NewChannelConfigContent({ slackBotId }: { slackBotId: number }) {
|
||||
const isPaidEnterprise = usePaidEnterpriseFeaturesEnabled();
|
||||
|
||||
const {
|
||||
data: documentSets,
|
||||
isLoading: isDocSetsLoading,
|
||||
error: docSetsError,
|
||||
} = useDocumentSets();
|
||||
|
||||
const {
|
||||
agents,
|
||||
isLoading: isAgentsLoading,
|
||||
error: agentsError,
|
||||
} = useAgents();
|
||||
|
||||
const {
|
||||
data: standardAnswerCategories,
|
||||
isLoading: isStdAnswerLoading,
|
||||
error: stdAnswerError,
|
||||
} = useStandardAnswerCategories();
|
||||
|
||||
if (
|
||||
isDocSetsLoading ||
|
||||
isAgentsLoading ||
|
||||
(isPaidEnterprise && isStdAnswerLoading)
|
||||
) {
|
||||
return <SimpleLoader />;
|
||||
}
|
||||
|
||||
if (docSetsError || !documentSets) {
|
||||
return (
|
||||
<ErrorCallout
|
||||
errorTitle="Something went wrong :("
|
||||
errorMsg={`Failed to fetch document sets - ${
|
||||
docSetsError?.message ?? "unknown error"
|
||||
}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (agentsError) {
|
||||
return (
|
||||
<ErrorCallout
|
||||
errorTitle="Something went wrong :("
|
||||
errorMsg={`Failed to fetch agents - ${
|
||||
agentsError?.message ?? "unknown error"
|
||||
}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const standardAnswerCategoryResponse: StandardAnswerCategoryResponse =
|
||||
isPaidEnterprise
|
||||
? {
|
||||
paidEnterpriseFeaturesEnabled: true,
|
||||
categories: standardAnswerCategories ?? [],
|
||||
...(stdAnswerError
|
||||
? { error: { message: String(stdAnswerError) } }
|
||||
: {}),
|
||||
}
|
||||
: { paidEnterpriseFeaturesEnabled: false };
|
||||
|
||||
return (
|
||||
<SlackChannelConfigCreationForm
|
||||
slack_bot_id={slackBotId}
|
||||
documentSets={documentSets}
|
||||
personas={agents}
|
||||
standardAnswerCategoryResponse={standardAnswerCategoryResponse}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Page(props: { params: Promise<{ "bot-id": string }> }) {
|
||||
const unwrappedParams = use(props.params);
|
||||
const router = useRouter();
|
||||
|
||||
async function NewChannelConfigPage(props: {
|
||||
params: Promise<{ "bot-id": string }>;
|
||||
}) {
|
||||
const unwrappedParams = await props.params;
|
||||
const slack_bot_id_raw = unwrappedParams?.["bot-id"] || null;
|
||||
const slack_bot_id = slack_bot_id_raw
|
||||
? parseInt(slack_bot_id_raw as string, 10)
|
||||
: null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!slack_bot_id || isNaN(slack_bot_id)) {
|
||||
router.replace("/admin/bots");
|
||||
}
|
||||
}, [slack_bot_id, router]);
|
||||
|
||||
if (!slack_bot_id || isNaN(slack_bot_id)) {
|
||||
redirect("/admin/bots");
|
||||
return null;
|
||||
}
|
||||
|
||||
const [documentSetsResponse, agentsResponse, standardAnswerCategoryResponse] =
|
||||
await Promise.all([
|
||||
fetchSS("/manage/document-set") as Promise<Response>,
|
||||
fetchAgentsSS(),
|
||||
getStandardAnswerCategoriesIfEE(),
|
||||
]);
|
||||
|
||||
if (!documentSetsResponse.ok) {
|
||||
return (
|
||||
<ErrorCallout
|
||||
errorTitle="Something went wrong :("
|
||||
errorMsg={`Failed to fetch document sets - ${await documentSetsResponse.text()}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const documentSets =
|
||||
(await documentSetsResponse.json()) as DocumentSetSummary[];
|
||||
|
||||
if (agentsResponse[1]) {
|
||||
return (
|
||||
<ErrorCallout
|
||||
errorTitle="Something went wrong :("
|
||||
errorMsg={`Failed to fetch agents - ${agentsResponse[1]}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsLayouts.Root>
|
||||
<SettingsLayouts.Header
|
||||
@@ -57,15 +113,8 @@ async function NewChannelConfigPage(props: {
|
||||
backButton
|
||||
/>
|
||||
<SettingsLayouts.Body>
|
||||
<SlackChannelConfigCreationForm
|
||||
slack_bot_id={slack_bot_id}
|
||||
documentSets={documentSets}
|
||||
personas={agentsResponse[0]}
|
||||
standardAnswerCategoryResponse={standardAnswerCategoryResponse}
|
||||
/>
|
||||
<NewChannelConfigContent slackBotId={slack_bot_id} />
|
||||
</SettingsLayouts.Body>
|
||||
</SettingsLayouts.Root>
|
||||
);
|
||||
}
|
||||
|
||||
export default NewChannelConfigPage;
|
||||
|
||||
@@ -1,82 +1,62 @@
|
||||
"use client";
|
||||
|
||||
import { use } from "react";
|
||||
import BackButton from "@/refresh-components/buttons/BackButton";
|
||||
import { ErrorCallout } from "@/components/ErrorCallout";
|
||||
import { ThreeDotsLoader } from "@/components/Loading";
|
||||
import { InstantSSRAutoRefresh } from "@/components/SSRAutoRefresh";
|
||||
import SimpleLoader from "@/refresh-components/loaders/SimpleLoader";
|
||||
import SlackChannelConfigsTable from "./SlackChannelConfigsTable";
|
||||
import { useSlackBot, useSlackChannelConfigsByBot } from "./hooks";
|
||||
import { ExistingSlackBotForm } from "../SlackBotUpdateForm";
|
||||
import Separator from "@/refresh-components/Separator";
|
||||
|
||||
function SlackBotEditPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ "bot-id": string }>;
|
||||
}) {
|
||||
// Unwrap the params promise
|
||||
const unwrappedParams = use(params);
|
||||
import * as SettingsLayouts from "@/layouts/settings-layouts";
|
||||
import { SvgSlack } from "@opal/icons";
|
||||
import { getErrorMsg } from "@/lib/error";
|
||||
|
||||
function SlackBotEditContent({ botId }: { botId: string }) {
|
||||
const {
|
||||
data: slackBot,
|
||||
isLoading: isSlackBotLoading,
|
||||
error: slackBotError,
|
||||
refreshSlackBot,
|
||||
} = useSlackBot(Number(unwrappedParams["bot-id"]));
|
||||
} = useSlackBot(Number(botId));
|
||||
|
||||
const {
|
||||
data: slackChannelConfigs,
|
||||
isLoading: isSlackChannelConfigsLoading,
|
||||
error: slackChannelConfigsError,
|
||||
refreshSlackChannelConfigs,
|
||||
} = useSlackChannelConfigsByBot(Number(unwrappedParams["bot-id"]));
|
||||
} = useSlackChannelConfigsByBot(Number(botId));
|
||||
|
||||
if (isSlackBotLoading || isSlackChannelConfigsLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-screen">
|
||||
<ThreeDotsLoader />
|
||||
</div>
|
||||
);
|
||||
return <SimpleLoader />;
|
||||
}
|
||||
|
||||
if (slackBotError || !slackBot) {
|
||||
const errorMsg =
|
||||
slackBotError?.info?.message ||
|
||||
slackBotError?.info?.detail ||
|
||||
"An unknown error occurred";
|
||||
return (
|
||||
<ErrorCallout
|
||||
errorTitle="Something went wrong :("
|
||||
errorMsg={`Failed to fetch Slack Bot ${unwrappedParams["bot-id"]}: ${errorMsg}`}
|
||||
errorMsg={`Failed to fetch Slack Bot ${botId}: ${getErrorMsg(
|
||||
slackBotError
|
||||
)}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (slackChannelConfigsError || !slackChannelConfigs) {
|
||||
const errorMsg =
|
||||
slackChannelConfigsError?.info?.message ||
|
||||
slackChannelConfigsError?.info?.detail ||
|
||||
"An unknown error occurred";
|
||||
return (
|
||||
<ErrorCallout
|
||||
errorTitle="Something went wrong :("
|
||||
errorMsg={`Failed to fetch Slack Bot ${unwrappedParams["bot-id"]}: ${errorMsg}`}
|
||||
errorMsg={`Failed to fetch Slack Bot ${botId}: ${getErrorMsg(
|
||||
slackChannelConfigsError
|
||||
)}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<InstantSSRAutoRefresh />
|
||||
|
||||
<BackButton routerOverride="/admin/bots" />
|
||||
|
||||
<ExistingSlackBotForm
|
||||
existingSlackBot={slackBot}
|
||||
refreshSlackBot={refreshSlackBot}
|
||||
/>
|
||||
<Separator />
|
||||
|
||||
<div className="mt-8">
|
||||
<SlackChannelConfigsTable
|
||||
@@ -94,9 +74,19 @@ export default function Page({
|
||||
}: {
|
||||
params: Promise<{ "bot-id": string }>;
|
||||
}) {
|
||||
const unwrappedParams = use(params);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SlackBotEditPage params={params} />
|
||||
</>
|
||||
<SettingsLayouts.Root>
|
||||
<SettingsLayouts.Header
|
||||
icon={SvgSlack}
|
||||
title="Edit Slack Bot"
|
||||
backButton
|
||||
separator
|
||||
/>
|
||||
<SettingsLayouts.Body>
|
||||
<SlackBotEditContent botId={unwrappedParams["bot-id"]} />
|
||||
</SettingsLayouts.Body>
|
||||
</SettingsLayouts.Root>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import BackButton from "@/refresh-components/buttons/BackButton";
|
||||
"use client";
|
||||
|
||||
import { NewSlackBotForm } from "../SlackBotCreationForm";
|
||||
|
||||
export default async function NewSlackBotPage() {
|
||||
return (
|
||||
<>
|
||||
<BackButton routerOverride="/admin/bots" />
|
||||
|
||||
<NewSlackBotForm />
|
||||
</>
|
||||
);
|
||||
export default function Page() {
|
||||
return <NewSlackBotForm />;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
QwenIcon,
|
||||
OllamaIcon,
|
||||
LMStudioIcon,
|
||||
LiteLLMIcon,
|
||||
ZAIIcon,
|
||||
} from "@/components/icons/icons";
|
||||
import {
|
||||
@@ -21,12 +22,14 @@ import {
|
||||
OpenRouterModelResponse,
|
||||
BedrockModelResponse,
|
||||
LMStudioModelResponse,
|
||||
LiteLLMProxyModelResponse,
|
||||
ModelConfiguration,
|
||||
LLMProviderName,
|
||||
BedrockFetchParams,
|
||||
OllamaFetchParams,
|
||||
LMStudioFetchParams,
|
||||
OpenRouterFetchParams,
|
||||
LiteLLMProxyFetchParams,
|
||||
} from "@/interfaces/llm";
|
||||
import { SvgAws, SvgOpenrouter } from "@opal/icons";
|
||||
|
||||
@@ -37,6 +40,7 @@ export const AGGREGATOR_PROVIDERS = new Set([
|
||||
"openrouter",
|
||||
"ollama_chat",
|
||||
"lm_studio",
|
||||
"litellm_proxy",
|
||||
"vertex_ai",
|
||||
]);
|
||||
|
||||
@@ -73,6 +77,7 @@ export const getProviderIcon = (
|
||||
bedrock: SvgAws,
|
||||
bedrock_converse: SvgAws,
|
||||
openrouter: SvgOpenrouter,
|
||||
litellm_proxy: LiteLLMIcon,
|
||||
vertex_ai: GeminiIcon,
|
||||
};
|
||||
|
||||
@@ -145,7 +150,7 @@ export const fetchBedrockModels = async (
|
||||
let errorMessage = "Failed to fetch models";
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
errorMessage = errorData.message || errorMessage;
|
||||
errorMessage = errorData.detail || errorData.message || errorMessage;
|
||||
} catch {
|
||||
// ignore JSON parsing errors
|
||||
}
|
||||
@@ -199,7 +204,7 @@ export const fetchOllamaModels = async (
|
||||
let errorMessage = "Failed to fetch models";
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
errorMessage = errorData.message || errorMessage;
|
||||
errorMessage = errorData.detail || errorData.message || errorMessage;
|
||||
} catch {
|
||||
// ignore JSON parsing errors
|
||||
}
|
||||
@@ -257,7 +262,7 @@ export const fetchOpenRouterModels = async (
|
||||
let errorMessage = "Failed to fetch models";
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
errorMessage = errorData.message || errorMessage;
|
||||
errorMessage = errorData.detail || errorData.message || errorMessage;
|
||||
} catch {
|
||||
// ignore JSON parsing errors
|
||||
}
|
||||
@@ -313,7 +318,7 @@ export const fetchLMStudioModels = async (
|
||||
let errorMessage = "Failed to fetch models";
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
errorMessage = errorData.message || errorMessage;
|
||||
errorMessage = errorData.detail || errorData.message || errorMessage;
|
||||
} catch {
|
||||
// ignore JSON parsing errors
|
||||
}
|
||||
@@ -338,6 +343,65 @@ export const fetchLMStudioModels = async (
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches LiteLLM Proxy models directly without any form state dependencies.
|
||||
* Uses snake_case params to match API structure.
|
||||
*/
|
||||
export const fetchLiteLLMProxyModels = async (
|
||||
params: LiteLLMProxyFetchParams
|
||||
): Promise<{ models: ModelConfiguration[]; error?: string }> => {
|
||||
const apiBase = params.api_base;
|
||||
const apiKey = params.api_key;
|
||||
if (!apiBase) {
|
||||
return { models: [], error: "API Base is required" };
|
||||
}
|
||||
if (!apiKey) {
|
||||
return { models: [], error: "API Key is required" };
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/admin/llm/litellm/available-models", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
api_base: apiBase,
|
||||
api_key: apiKey,
|
||||
provider_name: params.provider_name,
|
||||
}),
|
||||
signal: params.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorMessage = "Failed to fetch models";
|
||||
try {
|
||||
const errorData = await response.json();
|
||||
errorMessage = errorData.detail || errorData.message || errorMessage;
|
||||
} catch {
|
||||
// ignore JSON parsing errors
|
||||
}
|
||||
return { models: [], error: errorMessage };
|
||||
}
|
||||
|
||||
const data: LiteLLMProxyModelResponse[] = await response.json();
|
||||
const models: ModelConfiguration[] = data.map((modelData) => ({
|
||||
name: modelData.model_name,
|
||||
display_name: modelData.model_name,
|
||||
is_visible: true,
|
||||
max_input_tokens: null,
|
||||
supports_image_input: false,
|
||||
supports_reasoning: false,
|
||||
}));
|
||||
|
||||
return { models };
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : "Unknown error";
|
||||
return { models: [], error: errorMessage };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches models for a provider. Accepts form values directly and maps them
|
||||
* to the expected fetch params format internally.
|
||||
@@ -385,6 +449,13 @@ export const fetchModels = async (
|
||||
api_key: formValues.api_key,
|
||||
provider_name: formValues.name,
|
||||
});
|
||||
case LLMProviderName.LITELLM_PROXY:
|
||||
return fetchLiteLLMProxyModels({
|
||||
api_base: formValues.api_base,
|
||||
api_key: formValues.api_key,
|
||||
provider_name: formValues.name,
|
||||
signal,
|
||||
});
|
||||
default:
|
||||
return { models: [], error: `Unknown provider: ${providerName}` };
|
||||
}
|
||||
@@ -397,6 +468,7 @@ export function canProviderFetchModels(providerName?: string) {
|
||||
case LLMProviderName.OLLAMA_CHAT:
|
||||
case LLMProviderName.LM_STUDIO:
|
||||
case LLMProviderName.OPENROUTER:
|
||||
case LLMProviderName.LITELLM_PROXY:
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
|
||||
@@ -240,7 +240,7 @@ export default function AddConnector({
|
||||
toast.success("Credential deleted successfully!");
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
toast.error(errorData.message);
|
||||
toast.error(errorData.detail || errorData.message);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -444,7 +444,7 @@ export default function AddConnector({
|
||||
|
||||
if (!timeoutErrorHappenedRef.current) {
|
||||
// Only show error if timeout didn't happen
|
||||
toast.error(errorData.message || errorData.detail);
|
||||
toast.error(errorData.detail || errorData.message);
|
||||
}
|
||||
}
|
||||
} else if (isSuccess) {
|
||||
|
||||
@@ -7,6 +7,8 @@ interface CodeBlockProps {
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
codeText: string;
|
||||
showHeader?: boolean;
|
||||
noPadding?: boolean;
|
||||
}
|
||||
|
||||
const MemoizedCodeLine = memo(({ content }: { content: ReactNode }) => (
|
||||
@@ -17,6 +19,8 @@ export const CodeBlock = memo(function CodeBlock({
|
||||
className = "",
|
||||
children,
|
||||
codeText,
|
||||
showHeader = true,
|
||||
noPadding = false,
|
||||
}: CodeBlockProps) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
@@ -111,22 +115,32 @@ export const CodeBlock = memo(function CodeBlock({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-background-tint-00 px-1 pb-1 rounded-12 max-w-full min-w-0">
|
||||
{language && (
|
||||
<div className="flex items-center px-2 py-1 text-sm text-text-04 gap-x-2">
|
||||
<SvgCode
|
||||
height={12}
|
||||
width={12}
|
||||
stroke="currentColor"
|
||||
className="my-auto"
|
||||
/>
|
||||
<Text secondaryMono>{language}</Text>
|
||||
{codeText && <CopyButton />}
|
||||
<>
|
||||
{showHeader ? (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-background-tint-00 rounded-12 max-w-full min-w-0",
|
||||
!noPadding && "px-1 pb-1"
|
||||
)}
|
||||
>
|
||||
{language && (
|
||||
<div className="flex items-center px-2 py-1 text-sm text-text-04 gap-x-2">
|
||||
<SvgCode
|
||||
height={12}
|
||||
width={12}
|
||||
stroke="currentColor"
|
||||
className="my-auto"
|
||||
/>
|
||||
<Text secondaryMono>{language}</Text>
|
||||
{codeText && <CopyButton />}
|
||||
</div>
|
||||
)}
|
||||
<CodeContent />
|
||||
</div>
|
||||
) : (
|
||||
<CodeContent />
|
||||
)}
|
||||
|
||||
<CodeContent />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ReactNode, useState } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChatFileType, FileDescriptor } from "@/app/app/interfaces";
|
||||
import Attachment from "@/refresh-components/Attachment";
|
||||
import { InMessageImage } from "@/app/app/components/files/images/InMessageImage";
|
||||
@@ -9,10 +10,27 @@ import TextViewModal from "@/sections/modals/TextViewModal";
|
||||
import { MinimalOnyxDocument } from "@/lib/search/interfaces";
|
||||
import ExpandableContentWrapper from "@/components/tools/ExpandableContentWrapper";
|
||||
|
||||
interface FileContainerProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
interface FileDisplayProps {
|
||||
files: FileDescriptor[];
|
||||
}
|
||||
|
||||
function FileContainer({ children, className, id }: FileContainerProps) {
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
className={cn("flex w-full flex-col items-end gap-2 py-2", className)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FileDisplay({ files }: FileDisplayProps) {
|
||||
const [close, setClose] = useState(true);
|
||||
const [previewingFile, setPreviewingFile] = useState<FileDescriptor | null>(
|
||||
@@ -41,7 +59,7 @@ export default function FileDisplay({ files }: FileDisplayProps) {
|
||||
)}
|
||||
|
||||
{textFiles.length > 0 && (
|
||||
<div id="onyx-file" className="flex flex-col items-end gap-2 py-2">
|
||||
<FileContainer id="onyx-file">
|
||||
{textFiles.map((file) => (
|
||||
<Attachment
|
||||
key={file.id}
|
||||
@@ -49,40 +67,36 @@ export default function FileDisplay({ files }: FileDisplayProps) {
|
||||
open={() => setPreviewingFile(file)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</FileContainer>
|
||||
)}
|
||||
|
||||
{imageFiles.length > 0 && (
|
||||
<div id="onyx-image" className="flex flex-col items-end gap-2 py-2">
|
||||
<FileContainer id="onyx-image">
|
||||
{imageFiles.map((file) => (
|
||||
<InMessageImage key={file.id} fileId={file.id} />
|
||||
))}
|
||||
</div>
|
||||
</FileContainer>
|
||||
)}
|
||||
|
||||
{csvFiles.length > 0 && (
|
||||
<div className="flex flex-col items-end gap-2 py-2">
|
||||
{csvFiles.map((file) => {
|
||||
return (
|
||||
<div key={file.id} className="w-fit">
|
||||
{close ? (
|
||||
<>
|
||||
<ExpandableContentWrapper
|
||||
fileDescriptor={file}
|
||||
close={() => setClose(false)}
|
||||
ContentComponent={CsvContent}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Attachment
|
||||
open={() => setClose(true)}
|
||||
fileName={file.name || file.id}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<FileContainer className="overflow-auto">
|
||||
{csvFiles.map((file) =>
|
||||
close ? (
|
||||
<ExpandableContentWrapper
|
||||
key={file.id}
|
||||
fileDescriptor={file}
|
||||
close={() => setClose(false)}
|
||||
ContentComponent={CsvContent}
|
||||
/>
|
||||
) : (
|
||||
<Attachment
|
||||
key={file.id}
|
||||
open={() => setClose(true)}
|
||||
fileName={file.name || file.id}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</FileContainer>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
import React, { useRef, RefObject, useMemo } from "react";
|
||||
import { Packet, StopReason } from "@/app/app/services/streamingModels";
|
||||
import CustomToolAuthCard from "@/app/app/message/messageComponents/CustomToolAuthCard";
|
||||
import { FullChatState } from "@/app/app/message/messageComponents/interfaces";
|
||||
import { FeedbackType } from "@/app/app/interfaces";
|
||||
import { handleCopy } from "@/app/app/message/copyingUtils";
|
||||
import { useAuthErrors } from "@/app/app/message/messageComponents/hooks/useAuthErrors";
|
||||
import { useMessageSwitching } from "@/app/app/message/messageComponents/hooks/useMessageSwitching";
|
||||
import { RendererComponent } from "@/app/app/message/messageComponents/renderMessageComponent";
|
||||
import { usePacketProcessor } from "@/app/app/message/messageComponents/timeline/hooks/usePacketProcessor";
|
||||
@@ -146,6 +148,8 @@ const AgentMessage = React.memo(function AgentMessage({
|
||||
]
|
||||
);
|
||||
|
||||
const authErrors = useAuthErrors(rawPackets);
|
||||
|
||||
// Message switching logic
|
||||
const {
|
||||
currentMessageInd,
|
||||
@@ -189,7 +193,16 @@ const AgentMessage = React.memo(function AgentMessage({
|
||||
}}
|
||||
>
|
||||
{pacedDisplayGroups.length > 0 && (
|
||||
<div ref={finalAnswerRef}>
|
||||
<div ref={finalAnswerRef} className="flex flex-col gap-3">
|
||||
{authErrors.map((authError, i) => (
|
||||
<CustomToolAuthCard
|
||||
key={`auth-error-${i}`}
|
||||
toolName={authError.toolName}
|
||||
toolId={authError.toolId}
|
||||
tools={effectiveChatState.agent.tools}
|
||||
agentId={effectiveChatState.agent.id}
|
||||
/>
|
||||
))}
|
||||
{pacedDisplayGroups.map((displayGroup, index) => (
|
||||
<RendererComponent
|
||||
key={`${displayGroup.turn_index}-${displayGroup.tab_index}`}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import Message from "@/refresh-components/messages/Message";
|
||||
import { ToolSnapshot } from "@/lib/tools/interfaces";
|
||||
import { initiateOAuthFlow } from "@/lib/oauth/api";
|
||||
import { useToolOAuthStatus } from "@/lib/hooks/useToolOAuthStatus";
|
||||
import { SvgArrowExchange } from "@opal/icons";
|
||||
|
||||
interface CustomToolAuthCardProps {
|
||||
toolName: string;
|
||||
toolId: number | null;
|
||||
tools: ToolSnapshot[];
|
||||
agentId: number;
|
||||
}
|
||||
|
||||
function CustomToolAuthCard({
|
||||
toolName,
|
||||
toolId,
|
||||
tools,
|
||||
agentId,
|
||||
}: CustomToolAuthCardProps) {
|
||||
const { getToolAuthStatus } = useToolOAuthStatus(agentId);
|
||||
const matchedTool = useMemo(() => {
|
||||
if (toolId == null) return null;
|
||||
return tools.find((t) => t.id === toolId) ?? null;
|
||||
}, [toolId, tools]);
|
||||
|
||||
// Hide the card if the user already has a valid token
|
||||
const authStatus = matchedTool ? getToolAuthStatus(matchedTool) : undefined;
|
||||
if (authStatus?.hasToken && !authStatus.isTokenExpired) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const oauthConfigId = matchedTool?.oauth_config_id ?? null;
|
||||
|
||||
// No OAuth config — nothing actionable to show
|
||||
if (!oauthConfigId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleAuthenticate = () => {
|
||||
initiateOAuthFlow(
|
||||
oauthConfigId,
|
||||
window.location.pathname + window.location.search
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Message
|
||||
static
|
||||
large
|
||||
icon
|
||||
close={false}
|
||||
text={`${toolName} not connected`}
|
||||
description={`Connect to ${toolName} to enable this tool`}
|
||||
actions="Connect"
|
||||
actionPrimary
|
||||
actionIcon={SvgArrowExchange}
|
||||
onAction={handleAuthenticate}
|
||||
className="w-full"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default CustomToolAuthCard;
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useRef } from "react";
|
||||
import {
|
||||
CustomToolDelta,
|
||||
Packet,
|
||||
PacketType,
|
||||
} from "@/app/app/services/streamingModels";
|
||||
|
||||
interface AuthError {
|
||||
toolName: string;
|
||||
toolId: number | null;
|
||||
}
|
||||
|
||||
export function useAuthErrors(rawPackets: Packet[]): AuthError[] {
|
||||
const stateRef = useRef<{ processedCount: number; errors: AuthError[] }>({
|
||||
processedCount: 0,
|
||||
errors: [],
|
||||
});
|
||||
|
||||
// Reset if packets shrunk (e.g. new message)
|
||||
if (rawPackets.length < stateRef.current.processedCount) {
|
||||
stateRef.current = { processedCount: 0, errors: [] };
|
||||
}
|
||||
|
||||
// Process only new packets (incremental, like usePacketProcessor)
|
||||
if (rawPackets.length > stateRef.current.processedCount) {
|
||||
let newErrors = stateRef.current.errors;
|
||||
for (let i = stateRef.current.processedCount; i < rawPackets.length; i++) {
|
||||
const packet = rawPackets[i]!;
|
||||
if (packet.obj.type === PacketType.CUSTOM_TOOL_DELTA) {
|
||||
const delta = packet.obj as CustomToolDelta;
|
||||
if (delta.error?.is_auth_error) {
|
||||
const alreadyPresent = newErrors.some(
|
||||
(e) =>
|
||||
(delta.tool_id != null && e.toolId === delta.tool_id) ||
|
||||
(delta.tool_id == null && e.toolName === delta.tool_name)
|
||||
);
|
||||
if (!alreadyPresent) {
|
||||
newErrors = [
|
||||
...newErrors,
|
||||
{ toolName: delta.tool_name, toolId: delta.tool_id ?? null },
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
stateRef.current = {
|
||||
processedCount: rawPackets.length,
|
||||
errors: newErrors,
|
||||
};
|
||||
}
|
||||
|
||||
return stateRef.current.errors;
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { LlmDescriptor } from "@/lib/hooks";
|
||||
import { IconType } from "react-icons";
|
||||
import { OnyxIconType } from "@/components/icons/icons";
|
||||
import { CitationMap } from "../../interfaces";
|
||||
import { TimelineSurfaceBackground } from "@/app/app/message/messageComponents/timeline/primitives/TimelineSurface";
|
||||
|
||||
export enum RenderType {
|
||||
HIGHLIGHT = "highlight",
|
||||
@@ -51,6 +52,10 @@ export interface RendererResult {
|
||||
alwaysCollapsible?: boolean;
|
||||
/** Whether the result should be wrapped by timeline UI or rendered as-is */
|
||||
timelineLayout?: TimelineLayout;
|
||||
/** Remove right padding for long-form content (reasoning, deep research, memory). */
|
||||
noPaddingRight?: boolean;
|
||||
/** Override the surface background (e.g. "error" for auth failures). */
|
||||
surfaceBackground?: TimelineSurfaceBackground;
|
||||
}
|
||||
|
||||
// All renderers return an array of results (even single-step renderers return a 1-element array)
|
||||
|
||||
@@ -1,14 +1,58 @@
|
||||
import React, { useEffect, useMemo } from "react";
|
||||
import { FiExternalLink, FiDownload, FiTool } from "react-icons/fi";
|
||||
import {
|
||||
PacketType,
|
||||
CustomToolPacket,
|
||||
CustomToolStart,
|
||||
CustomToolArgs,
|
||||
CustomToolDelta,
|
||||
CustomToolErrorInfo,
|
||||
SectionEnd,
|
||||
} from "../../../services/streamingModels";
|
||||
import { MessageRenderer, RenderType } from "../interfaces";
|
||||
import { buildImgUrl } from "../../../components/files/images/utils";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import {
|
||||
SvgActions,
|
||||
SvgArrowExchange,
|
||||
SvgDownload,
|
||||
SvgExternalLink,
|
||||
} from "@opal/icons";
|
||||
import { CodeBlock } from "@/app/app/message/CodeBlock";
|
||||
import hljs from "highlight.js/lib/core";
|
||||
import json from "highlight.js/lib/languages/json";
|
||||
import FadingEdgeContainer from "@/refresh-components/FadingEdgeContainer";
|
||||
|
||||
// Lazy registration for hljs JSON language
|
||||
function ensureHljsRegistered() {
|
||||
if (!hljs.listLanguages().includes("json")) {
|
||||
hljs.registerLanguage("json", json);
|
||||
}
|
||||
}
|
||||
|
||||
// Component to render syntax-highlighted JSON
|
||||
interface HighlightedJsonCodeProps {
|
||||
code: string;
|
||||
}
|
||||
function HighlightedJsonCode({ code }: HighlightedJsonCodeProps) {
|
||||
const highlightedHtml = useMemo(() => {
|
||||
ensureHljsRegistered();
|
||||
try {
|
||||
return hljs.highlight(code, { language: "json" }).value;
|
||||
} catch {
|
||||
return code
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
}, [code]);
|
||||
|
||||
return (
|
||||
<span
|
||||
dangerouslySetInnerHTML={{ __html: highlightedHtml }}
|
||||
className="hljs"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function constructCustomToolState(packets: CustomToolPacket[]) {
|
||||
const toolStart = packets.find(
|
||||
@@ -23,19 +67,26 @@ function constructCustomToolState(packets: CustomToolPacket[]) {
|
||||
)?.obj as SectionEnd | null;
|
||||
|
||||
const toolName = toolStart?.tool_name || toolDeltas[0]?.tool_name || "Tool";
|
||||
const toolArgsPacket = packets.find(
|
||||
(p) => p.obj.type === PacketType.CUSTOM_TOOL_ARGS
|
||||
)?.obj as CustomToolArgs | null;
|
||||
const toolArgs = toolArgsPacket?.tool_args ?? null;
|
||||
const latestDelta = toolDeltas[toolDeltas.length - 1] || null;
|
||||
const responseType = latestDelta?.response_type || null;
|
||||
const data = latestDelta?.data;
|
||||
const fileIds = latestDelta?.file_ids || null;
|
||||
const error = latestDelta?.error || null;
|
||||
|
||||
const isRunning = Boolean(toolStart && !toolEnd);
|
||||
const isComplete = Boolean(toolStart && toolEnd);
|
||||
|
||||
return {
|
||||
toolName,
|
||||
toolArgs,
|
||||
responseType,
|
||||
data,
|
||||
fileIds,
|
||||
error,
|
||||
isRunning,
|
||||
isComplete,
|
||||
};
|
||||
@@ -47,8 +98,16 @@ export const CustomToolRenderer: MessageRenderer<CustomToolPacket, {}> = ({
|
||||
renderType,
|
||||
children,
|
||||
}) => {
|
||||
const { toolName, responseType, data, fileIds, isRunning, isComplete } =
|
||||
constructCustomToolState(packets);
|
||||
const {
|
||||
toolName,
|
||||
toolArgs,
|
||||
responseType,
|
||||
data,
|
||||
fileIds,
|
||||
error,
|
||||
isRunning,
|
||||
isComplete,
|
||||
} = constructCustomToolState(packets);
|
||||
|
||||
useEffect(() => {
|
||||
if (isComplete) {
|
||||
@@ -58,76 +117,192 @@ export const CustomToolRenderer: MessageRenderer<CustomToolPacket, {}> = ({
|
||||
|
||||
const status = useMemo(() => {
|
||||
if (isComplete) {
|
||||
if (error) {
|
||||
return error.is_auth_error
|
||||
? `${toolName} authentication failed (HTTP ${error.status_code})`
|
||||
: `${toolName} failed (HTTP ${error.status_code})`;
|
||||
}
|
||||
if (responseType === "image") return `${toolName} returned images`;
|
||||
if (responseType === "csv") return `${toolName} returned a file`;
|
||||
return `${toolName} completed`;
|
||||
}
|
||||
if (isRunning) return `${toolName} running...`;
|
||||
return null;
|
||||
}, [toolName, responseType, isComplete, isRunning]);
|
||||
}, [toolName, responseType, error, isComplete, isRunning]);
|
||||
|
||||
const icon = FiTool;
|
||||
const icon = SvgActions;
|
||||
|
||||
if (renderType === RenderType.COMPACT) {
|
||||
const toolArgsJson = useMemo(
|
||||
() => (toolArgs ? JSON.stringify(toolArgs, null, 2) : null),
|
||||
[toolArgs]
|
||||
);
|
||||
const dataJson = useMemo(
|
||||
() =>
|
||||
data !== undefined && data !== null && typeof data === "object"
|
||||
? JSON.stringify(data, null, 2)
|
||||
: null,
|
||||
[data]
|
||||
);
|
||||
|
||||
const content = useMemo(
|
||||
() => (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* Loading indicator */}
|
||||
{isRunning &&
|
||||
!error &&
|
||||
!fileIds &&
|
||||
(data === undefined || data === null) && (
|
||||
<div className="flex items-center gap-2 text-sm text-text-03">
|
||||
<div className="flex gap-0.5">
|
||||
<div className="w-1 h-1 bg-current rounded-full animate-pulse"></div>
|
||||
<div
|
||||
className="w-1 h-1 bg-current rounded-full animate-pulse"
|
||||
style={{ animationDelay: "0.1s" }}
|
||||
></div>
|
||||
<div
|
||||
className="w-1 h-1 bg-current rounded-full animate-pulse"
|
||||
style={{ animationDelay: "0.2s" }}
|
||||
></div>
|
||||
</div>
|
||||
<Text text03 secondaryBody>
|
||||
Waiting for response...
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tool arguments */}
|
||||
{toolArgsJson && (
|
||||
<div>
|
||||
<div className="flex items-center gap-1">
|
||||
<SvgArrowExchange className="w-3 h-3 text-text-02" />
|
||||
<Text text04 secondaryBody>
|
||||
Request
|
||||
</Text>
|
||||
</div>
|
||||
<div className="prose max-w-full">
|
||||
<CodeBlock
|
||||
className="font-secondary-mono"
|
||||
codeText={toolArgsJson}
|
||||
noPadding
|
||||
>
|
||||
<HighlightedJsonCode code={toolArgsJson} />
|
||||
</CodeBlock>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error display */}
|
||||
{error && (
|
||||
<div className="pl-[var(--timeline-common-text-padding)]">
|
||||
<Text text03 mainUiMuted>
|
||||
{error.message}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File responses */}
|
||||
{!error && fileIds && fileIds.length > 0 && (
|
||||
<div className="text-sm text-text-03 flex flex-col gap-2">
|
||||
{fileIds.map((fid, idx) => (
|
||||
<div key={fid} className="flex items-center gap-2 flex-wrap">
|
||||
<Text text03 secondaryBody className="whitespace-nowrap">
|
||||
File {idx + 1}
|
||||
</Text>
|
||||
<a
|
||||
href={buildImgUrl(fid)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs text-action-link-01 hover:underline whitespace-nowrap"
|
||||
>
|
||||
<SvgExternalLink className="w-3 h-3" /> Open
|
||||
</a>
|
||||
<a
|
||||
href={buildImgUrl(fid)}
|
||||
download
|
||||
className="inline-flex items-center gap-1 text-xs text-action-link-01 hover:underline whitespace-nowrap"
|
||||
>
|
||||
<SvgDownload className="w-3 h-3" /> Download
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* JSON/Text responses */}
|
||||
{!error && data !== undefined && data !== null && (
|
||||
<div>
|
||||
<div className="flex items-center gap-1">
|
||||
<SvgArrowExchange className="w-3 h-3 text-text-02" />
|
||||
<Text text04 secondaryBody>
|
||||
Response
|
||||
</Text>
|
||||
</div>
|
||||
<div className="prose max-w-full">
|
||||
{dataJson ? (
|
||||
<CodeBlock
|
||||
className="font-secondary-mono"
|
||||
codeText={dataJson}
|
||||
noPadding
|
||||
>
|
||||
<HighlightedJsonCode code={dataJson} />
|
||||
</CodeBlock>
|
||||
) : (
|
||||
<CodeBlock
|
||||
className="font-secondary-mono"
|
||||
codeText={String(data)}
|
||||
noPadding
|
||||
>
|
||||
{String(data)}
|
||||
</CodeBlock>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
[toolArgsJson, dataJson, data, fileIds, error, isRunning]
|
||||
);
|
||||
|
||||
// Auth error: always render FULL with error surface
|
||||
if (error?.is_auth_error) {
|
||||
return children([
|
||||
{
|
||||
icon,
|
||||
status: status,
|
||||
supportsCollapsible: true,
|
||||
// Status is already shown in the step header in compact mode.
|
||||
// Avoid duplicating the same line in the content body.
|
||||
content: <></>,
|
||||
status,
|
||||
supportsCollapsible: false,
|
||||
noPaddingRight: true,
|
||||
surfaceBackground: "error" as const,
|
||||
content,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
// FULL mode
|
||||
if (renderType === RenderType.FULL) {
|
||||
return children([
|
||||
{
|
||||
icon,
|
||||
status,
|
||||
supportsCollapsible: true,
|
||||
noPaddingRight: true,
|
||||
content,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
// COMPACT mode: wrap in fading container
|
||||
return children([
|
||||
{
|
||||
icon,
|
||||
status,
|
||||
supportsCollapsible: true,
|
||||
content: (
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* File responses */}
|
||||
{fileIds && fileIds.length > 0 && (
|
||||
<div className="text-sm text-muted-foreground flex flex-col gap-2">
|
||||
{fileIds.map((fid, idx) => (
|
||||
<div key={fid} className="flex items-center gap-2 flex-wrap">
|
||||
<span className="whitespace-nowrap">File {idx + 1}</span>
|
||||
<a
|
||||
href={buildImgUrl(fid)}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-1 text-xs text-blue-600 hover:underline whitespace-nowrap"
|
||||
>
|
||||
<FiExternalLink className="w-3 h-3" /> Open
|
||||
</a>
|
||||
<a
|
||||
href={buildImgUrl(fid)}
|
||||
download
|
||||
className="inline-flex items-center gap-1 text-xs text-blue-600 hover:underline whitespace-nowrap"
|
||||
>
|
||||
<FiDownload className="w-3 h-3" /> Download
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* JSON/Text responses */}
|
||||
{data !== undefined && data !== null && (
|
||||
<div className="text-xs bg-gray-50 dark:bg-gray-800 p-3 rounded border max-h-96 overflow-y-auto font-mono whitespace-pre-wrap break-all">
|
||||
{typeof data === "string" ? data : JSON.stringify(data, null, 2)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show placeholder if no response data yet */}
|
||||
{!fileIds && (data === undefined || data === null) && isRunning && (
|
||||
<div className="text-xs text-gray-500 italic">
|
||||
Waiting for response...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<FadingEdgeContainer
|
||||
direction="bottom"
|
||||
className="max-h-24 overflow-hidden"
|
||||
>
|
||||
{content}
|
||||
</FadingEdgeContainer>
|
||||
),
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -17,9 +17,6 @@ import { TimelineStepComposer } from "./TimelineStepComposer";
|
||||
import {
|
||||
isSearchToolPackets,
|
||||
isPythonToolPackets,
|
||||
isReasoningPackets,
|
||||
isDeepResearchPlanPackets,
|
||||
isMemoryToolPackets,
|
||||
} from "@/app/app/message/messageComponents/timeline/packetHelpers";
|
||||
|
||||
// =============================================================================
|
||||
@@ -51,24 +48,10 @@ const TimelineStep = React.memo(function TimelineStep({
|
||||
() => isSearchToolPackets(step.packets),
|
||||
[step.packets]
|
||||
);
|
||||
const isReasoning = useMemo(
|
||||
() => isReasoningPackets(step.packets),
|
||||
[step.packets]
|
||||
);
|
||||
const isPythonTool = useMemo(
|
||||
() => isPythonToolPackets(step.packets),
|
||||
[step.packets]
|
||||
);
|
||||
const isDeepResearchPlan = useMemo(
|
||||
() => isDeepResearchPlanPackets(step.packets),
|
||||
[step.packets]
|
||||
);
|
||||
|
||||
const isMemoryTool = useMemo(
|
||||
() => isMemoryToolPackets(step.packets),
|
||||
[step.packets]
|
||||
);
|
||||
|
||||
const getCollapsedIcon = useCallback(
|
||||
(result: TimelineRendererResult) =>
|
||||
isSearchTool ? (result.icon as FunctionComponent<IconProps>) : undefined,
|
||||
@@ -83,19 +66,10 @@ const TimelineStep = React.memo(function TimelineStep({
|
||||
isFirstStep={isFirstStep}
|
||||
isSingleStep={isSingleStep}
|
||||
collapsible={true}
|
||||
noPaddingRight={isReasoning || isDeepResearchPlan || isMemoryTool}
|
||||
getCollapsedIcon={getCollapsedIcon}
|
||||
/>
|
||||
),
|
||||
[
|
||||
isFirstStep,
|
||||
isLastStep,
|
||||
isSingleStep,
|
||||
isReasoning,
|
||||
isDeepResearchPlan,
|
||||
isMemoryTool,
|
||||
getCollapsedIcon,
|
||||
]
|
||||
[isFirstStep, isLastStep, isSingleStep, getCollapsedIcon]
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -14,11 +14,6 @@ import {
|
||||
TimelineRendererComponent,
|
||||
TimelineRendererOutput,
|
||||
} from "./TimelineRendererComponent";
|
||||
import {
|
||||
isReasoningPackets,
|
||||
isDeepResearchPlanPackets,
|
||||
isMemoryToolPackets,
|
||||
} from "./packetHelpers";
|
||||
import Tabs from "@/refresh-components/Tabs";
|
||||
import { SvgBranch, SvgFold, SvgExpand } from "@opal/icons";
|
||||
import { Button } from "@opal/components";
|
||||
@@ -65,13 +60,6 @@ export function ParallelTimelineTabs({
|
||||
[turnGroup.steps, activeTab]
|
||||
);
|
||||
|
||||
// Determine if the active step needs full-width content (no right padding)
|
||||
const noPaddingRight = activeStep
|
||||
? isReasoningPackets(activeStep.packets) ||
|
||||
isDeepResearchPlanPackets(activeStep.packets) ||
|
||||
isMemoryToolPackets(activeStep.packets)
|
||||
: false;
|
||||
|
||||
// Memoized loading states for each step
|
||||
const loadingStates = useMemo(
|
||||
() =>
|
||||
@@ -94,10 +82,9 @@ export function ParallelTimelineTabs({
|
||||
isFirstStep={false}
|
||||
isSingleStep={false}
|
||||
collapsible={true}
|
||||
noPaddingRight={noPaddingRight}
|
||||
/>
|
||||
),
|
||||
[isLastTurnGroup, noPaddingRight]
|
||||
[isLastTurnGroup]
|
||||
);
|
||||
|
||||
const hasActivePackets = Boolean(activeStep && activeStep.packets.length > 0);
|
||||
|
||||
@@ -2,7 +2,10 @@ import React, { FunctionComponent } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { IconProps } from "@opal/types";
|
||||
import { TimelineRow } from "@/app/app/message/messageComponents/timeline/primitives/TimelineRow";
|
||||
import { TimelineSurface } from "@/app/app/message/messageComponents/timeline/primitives/TimelineSurface";
|
||||
import {
|
||||
TimelineSurface,
|
||||
TimelineSurfaceBackground,
|
||||
} from "@/app/app/message/messageComponents/timeline/primitives/TimelineSurface";
|
||||
import { TimelineStepContent } from "@/app/app/message/messageComponents/timeline/primitives/TimelineStepContent";
|
||||
|
||||
export interface StepContainerProps {
|
||||
@@ -36,6 +39,8 @@ export interface StepContainerProps {
|
||||
noPaddingRight?: boolean;
|
||||
/** Render without rail (for nested/parallel content) */
|
||||
withRail?: boolean;
|
||||
/** Override the surface background variant */
|
||||
surfaceBackground?: TimelineSurfaceBackground;
|
||||
}
|
||||
|
||||
/** Visual wrapper for timeline steps - icon, connector line, header, and content */
|
||||
@@ -55,6 +60,7 @@ export function StepContainer({
|
||||
collapsedIcon: CollapsedIconComponent,
|
||||
noPaddingRight = false,
|
||||
withRail = true,
|
||||
surfaceBackground,
|
||||
}: StepContainerProps) {
|
||||
const iconNode = StepIconComponent ? (
|
||||
<StepIconComponent
|
||||
@@ -70,6 +76,7 @@ export function StepContainer({
|
||||
className="flex-1 flex flex-col"
|
||||
isHover={isHover}
|
||||
roundedBottom={isLastStep}
|
||||
background={surfaceBackground}
|
||||
>
|
||||
<TimelineStepContent
|
||||
header={header}
|
||||
@@ -81,6 +88,7 @@ export function StepContainer({
|
||||
hideHeader={hideHeader}
|
||||
collapsedIcon={CollapsedIconComponent}
|
||||
noPaddingRight={noPaddingRight}
|
||||
surfaceBackground={surfaceBackground}
|
||||
>
|
||||
{children}
|
||||
</TimelineStepContent>
|
||||
|
||||
@@ -17,8 +17,6 @@ export interface TimelineStepComposerProps {
|
||||
isSingleStep?: boolean;
|
||||
/** Whether StepContainer should show collapse controls. */
|
||||
collapsible?: boolean;
|
||||
/** Remove right padding for long-form content (reasoning, deep research). */
|
||||
noPaddingRight?: boolean;
|
||||
/** Optional resolver for custom collapsed icon per result. */
|
||||
getCollapsedIcon?: (
|
||||
result: TimelineRendererResult
|
||||
@@ -35,7 +33,6 @@ export function TimelineStepComposer({
|
||||
isFirstStep,
|
||||
isSingleStep = false,
|
||||
collapsible = true,
|
||||
noPaddingRight = false,
|
||||
getCollapsedIcon,
|
||||
}: TimelineStepComposerProps) {
|
||||
return (
|
||||
@@ -64,8 +61,9 @@ export function TimelineStepComposer({
|
||||
collapsedIcon={
|
||||
getCollapsedIcon ? getCollapsedIcon(result) : undefined
|
||||
}
|
||||
noPaddingRight={noPaddingRight}
|
||||
noPaddingRight={result.noPaddingRight ?? false}
|
||||
isHover={result.isHover}
|
||||
surfaceBackground={result.surfaceBackground}
|
||||
>
|
||||
{result.content}
|
||||
</StepContainer>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import React, { FunctionComponent } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { SvgFold, SvgExpand } from "@opal/icons";
|
||||
import { SvgFold, SvgExpand, SvgXOctagon } from "@opal/icons";
|
||||
import { IconProps } from "@opal/types";
|
||||
import { Button } from "@opal/components";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import { TimelineSurfaceBackground } from "@/app/app/message/messageComponents/timeline/primitives/TimelineSurface";
|
||||
|
||||
export interface TimelineStepContentProps {
|
||||
children?: React.ReactNode;
|
||||
@@ -16,6 +17,7 @@ export interface TimelineStepContentProps {
|
||||
hideHeader?: boolean;
|
||||
collapsedIcon?: FunctionComponent<IconProps>;
|
||||
noPaddingRight?: boolean;
|
||||
surfaceBackground?: TimelineSurfaceBackground;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -33,6 +35,7 @@ export function TimelineStepContent({
|
||||
hideHeader = false,
|
||||
collapsedIcon: CollapsedIconComponent,
|
||||
noPaddingRight = false,
|
||||
surfaceBackground,
|
||||
}: TimelineStepContentProps) {
|
||||
const showCollapseControls = collapsible && supportsCollapsible && onToggle;
|
||||
|
||||
@@ -47,8 +50,8 @@ export function TimelineStepContent({
|
||||
</div>
|
||||
|
||||
<div className="h-full w-[var(--timeline-step-header-right-section-width)] flex items-center justify-end">
|
||||
{showCollapseControls &&
|
||||
(buttonTitle ? (
|
||||
{showCollapseControls ? (
|
||||
buttonTitle ? (
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
size="md"
|
||||
@@ -68,7 +71,12 @@ export function TimelineStepContent({
|
||||
isExpanded ? SvgFold : CollapsedIconComponent || SvgExpand
|
||||
}
|
||||
/>
|
||||
))}
|
||||
)
|
||||
) : surfaceBackground === "error" ? (
|
||||
<div className="p-1.5">
|
||||
<SvgXOctagon className="h-4 w-4 text-status-error-05" />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type TimelineSurfaceBackground = "tint" | "transparent";
|
||||
export type TimelineSurfaceBackground = "tint" | "transparent" | "error";
|
||||
|
||||
export interface TimelineSurfaceProps {
|
||||
children: React.ReactNode;
|
||||
@@ -28,9 +28,16 @@ export function TimelineSurface({
|
||||
return null;
|
||||
}
|
||||
|
||||
const baseBackground = background === "tint" ? "bg-background-tint-00" : "";
|
||||
const baseBackground =
|
||||
background === "tint"
|
||||
? "bg-background-tint-00"
|
||||
: background === "error"
|
||||
? "bg-status-error-00"
|
||||
: "";
|
||||
const hoverBackground =
|
||||
background === "tint" && isHover ? "bg-background-tint-02" : "";
|
||||
(background === "tint" || background === "error") && isHover
|
||||
? "bg-background-tint-02"
|
||||
: "";
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -69,6 +69,7 @@ export const DeepResearchPlanRenderer: MessageRenderer<
|
||||
icon: SvgCircle,
|
||||
status: statusText,
|
||||
content: planContent,
|
||||
noPaddingRight: true,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
useMarkdownComponents,
|
||||
renderMarkdown,
|
||||
} from "@/app/app/message/messageComponents/markdownUtils";
|
||||
import { isReasoningPackets } from "../../packetHelpers";
|
||||
|
||||
interface NestedToolGroup {
|
||||
sub_turn_index: number;
|
||||
@@ -317,8 +316,6 @@ export const ResearchAgentRenderer: MessageRenderer<
|
||||
!fullReportContent &&
|
||||
!isComplete;
|
||||
|
||||
const isReasoning = isReasoningPackets(group.packets);
|
||||
|
||||
return (
|
||||
<TimelineRendererComponent
|
||||
key={group.sub_turn_index}
|
||||
@@ -337,7 +334,6 @@ export const ResearchAgentRenderer: MessageRenderer<
|
||||
isFirstStep={!researchTask && index === 0}
|
||||
isSingleStep={false}
|
||||
collapsible={true}
|
||||
noPaddingRight={isReasoning}
|
||||
/>
|
||||
)}
|
||||
</TimelineRendererComponent>
|
||||
|
||||
@@ -50,6 +50,7 @@ export const MemoryToolRenderer: MessageRenderer<MemoryToolPacket, {}> = ({
|
||||
content: <div />,
|
||||
supportsCollapsible: false,
|
||||
timelineLayout: "timeline",
|
||||
noPaddingRight: true,
|
||||
},
|
||||
]);
|
||||
}
|
||||
@@ -87,6 +88,7 @@ export const MemoryToolRenderer: MessageRenderer<MemoryToolPacket, {}> = ({
|
||||
status: "Memory",
|
||||
supportsCollapsible: false,
|
||||
timelineLayout: "timeline",
|
||||
noPaddingRight: true,
|
||||
content,
|
||||
},
|
||||
]);
|
||||
@@ -156,6 +158,7 @@ export const MemoryToolRenderer: MessageRenderer<MemoryToolPacket, {}> = ({
|
||||
status: statusLabel,
|
||||
supportsCollapsible: false,
|
||||
timelineLayout: "timeline",
|
||||
noPaddingRight: true,
|
||||
content: memoryContent,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -170,7 +170,12 @@ export const ReasoningRenderer: MessageRenderer<
|
||||
|
||||
if (!hasStart && !hasEnd && content.length === 0) {
|
||||
return children([
|
||||
{ icon: SvgCircle, status: THINKING_STATUS, content: <></> },
|
||||
{
|
||||
icon: SvgCircle,
|
||||
status: THINKING_STATUS,
|
||||
content: <></>,
|
||||
noPaddingRight: true,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -192,6 +197,7 @@ export const ReasoningRenderer: MessageRenderer<
|
||||
status: displayStatus,
|
||||
content: reasoningContent,
|
||||
expandedText: reasoningContent,
|
||||
noPaddingRight: true,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -308,15 +308,15 @@ export async function getProjectTokenCount(projectId: number): Promise<number> {
|
||||
|
||||
export async function getMaxSelectedDocumentTokens(
|
||||
personaId: number
|
||||
): Promise<number> {
|
||||
): Promise<number | null> {
|
||||
const response = await fetch(
|
||||
`/api/chat/max-selected-document-tokens?persona_id=${personaId}`
|
||||
);
|
||||
if (!response.ok) {
|
||||
return 128_000;
|
||||
return null;
|
||||
}
|
||||
const json = await response.json();
|
||||
return (json?.max_tokens as number) ?? 128_000;
|
||||
return (json?.max_tokens as number) ?? null;
|
||||
}
|
||||
|
||||
export async function moveChatSession(
|
||||
|
||||
@@ -288,15 +288,15 @@ export async function deleteAllChatSessions() {
|
||||
|
||||
export async function getAvailableContextTokens(
|
||||
chatSessionId: string
|
||||
): Promise<number> {
|
||||
): Promise<number | null> {
|
||||
const response = await fetch(
|
||||
`/api/chat/available-context-tokens/${chatSessionId}`
|
||||
);
|
||||
if (!response.ok) {
|
||||
return 0;
|
||||
return null;
|
||||
}
|
||||
const data = (await response.json()) as { available_tokens: number };
|
||||
return data?.available_tokens ?? 0;
|
||||
return data?.available_tokens ?? null;
|
||||
}
|
||||
|
||||
export function processRawChatHistory(
|
||||
|
||||
@@ -17,6 +17,7 @@ export function isToolPacket(
|
||||
PacketType.PYTHON_TOOL_START,
|
||||
PacketType.PYTHON_TOOL_DELTA,
|
||||
PacketType.CUSTOM_TOOL_START,
|
||||
PacketType.CUSTOM_TOOL_ARGS,
|
||||
PacketType.CUSTOM_TOOL_DELTA,
|
||||
PacketType.FILE_READER_START,
|
||||
PacketType.FILE_READER_RESULT,
|
||||
|
||||
@@ -29,6 +29,7 @@ export enum PacketType {
|
||||
|
||||
// Custom tool packets
|
||||
CUSTOM_TOOL_START = "custom_tool_start",
|
||||
CUSTOM_TOOL_ARGS = "custom_tool_args",
|
||||
CUSTOM_TOOL_DELTA = "custom_tool_delta",
|
||||
|
||||
// File reader tool packets
|
||||
@@ -164,17 +165,32 @@ export interface FetchToolDocuments extends BaseObj {
|
||||
}
|
||||
|
||||
// Custom Tool Packets
|
||||
export interface CustomToolErrorInfo {
|
||||
is_auth_error: boolean;
|
||||
status_code: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface CustomToolStart extends BaseObj {
|
||||
type: "custom_tool_start";
|
||||
tool_name: string;
|
||||
tool_id?: number | null;
|
||||
}
|
||||
|
||||
export interface CustomToolArgs extends BaseObj {
|
||||
type: "custom_tool_args";
|
||||
tool_name: string;
|
||||
tool_args: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface CustomToolDelta extends BaseObj {
|
||||
type: "custom_tool_delta";
|
||||
tool_name: string;
|
||||
tool_id?: number | null;
|
||||
response_type: string;
|
||||
data?: any;
|
||||
file_ids?: string[] | null;
|
||||
error?: CustomToolErrorInfo | null;
|
||||
}
|
||||
|
||||
// File Reader Packets
|
||||
@@ -304,6 +320,7 @@ export type FetchToolObj =
|
||||
| PacketError;
|
||||
export type CustomToolObj =
|
||||
| CustomToolStart
|
||||
| CustomToolArgs
|
||||
| CustomToolDelta
|
||||
| SectionEnd
|
||||
| PacketError;
|
||||
|
||||
@@ -368,8 +368,8 @@ function Main() {
|
||||
<ErrorCallout
|
||||
errorTitle="Error loading standard answers"
|
||||
errorMsg={
|
||||
standardAnswersError.info?.message ||
|
||||
standardAnswersError.message.info?.detail
|
||||
standardAnswersError.info?.detail ||
|
||||
standardAnswersError.info?.message
|
||||
}
|
||||
/>
|
||||
);
|
||||
@@ -380,8 +380,8 @@ function Main() {
|
||||
<ErrorCallout
|
||||
errorTitle="Error loading standard answer categories"
|
||||
errorMsg={
|
||||
standardAnswerCategoriesError.info?.message ||
|
||||
standardAnswerCategoriesError.message.info?.detail
|
||||
standardAnswerCategoriesError.info?.detail ||
|
||||
standardAnswerCategoriesError.info?.message
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -96,7 +96,9 @@ export default function NewTeamModal() {
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.message || "Failed to request invite");
|
||||
throw new Error(
|
||||
errorData.detail || errorData.message || "Failed to request invite"
|
||||
);
|
||||
}
|
||||
|
||||
setHasRequestedInvite(true);
|
||||
|
||||
@@ -162,12 +162,19 @@ export default function OAuthCallbackPage({ config }: OAuthCallbackPageProps) {
|
||||
|
||||
setServiceName(result.serviceName || "");
|
||||
// Respect backend-provided redirect path (from state.return_path)
|
||||
setRedirectPath(
|
||||
// Sanitize to prevent open redirects (e.g. "//evil.com")
|
||||
const rawPath =
|
||||
responseData.redirect_url ||
|
||||
searchParams?.get("return_path") ||
|
||||
config.defaultRedirectPath ||
|
||||
"/app"
|
||||
);
|
||||
searchParams?.get("return_path") ||
|
||||
config.defaultRedirectPath ||
|
||||
"/app";
|
||||
const sanitizedPath =
|
||||
rawPath.startsWith("http://") || rawPath.startsWith("https://")
|
||||
? "/app"
|
||||
: "/" + rawPath.replace(/^\/+/, "");
|
||||
const redirectUrl = new URL(sanitizedPath, window.location.origin);
|
||||
redirectUrl.searchParams.set("message", "oauth_connected");
|
||||
setRedirectPath(redirectUrl.pathname + redirectUrl.search);
|
||||
setStatusMessage(config.successMessage || "Success!");
|
||||
|
||||
const successDetails = config.successDetailsTemplate
|
||||
|
||||
@@ -60,27 +60,28 @@ const CsvContent: React.FC<ContentComponentProps> = ({
|
||||
}
|
||||
|
||||
const csvData = await response.text();
|
||||
const rows = csvData.trim().split("\n");
|
||||
const rows = parseCSV(csvData.trim());
|
||||
const firstRow = rows[0];
|
||||
if (!firstRow) {
|
||||
throw new Error("CSV file is empty");
|
||||
}
|
||||
const parsedHeaders = firstRow.split(",");
|
||||
const parsedHeaders = firstRow;
|
||||
setHeaders(parsedHeaders);
|
||||
|
||||
const parsedData: Record<string, string>[] = rows.slice(1).map((row) => {
|
||||
const values = row.split(",");
|
||||
return parsedHeaders.reduce<Record<string, string>>(
|
||||
(obj, header, index) => {
|
||||
const val = values[index];
|
||||
if (val !== undefined) {
|
||||
obj[header] = val;
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
{}
|
||||
);
|
||||
});
|
||||
const parsedData: Record<string, string>[] = rows
|
||||
.slice(1)
|
||||
.map((fields) => {
|
||||
return parsedHeaders.reduce<Record<string, string>>(
|
||||
(obj, header, index) => {
|
||||
const val = fields[index];
|
||||
if (val !== undefined) {
|
||||
obj[header] = val;
|
||||
}
|
||||
return obj;
|
||||
},
|
||||
{}
|
||||
);
|
||||
});
|
||||
setData(parsedData);
|
||||
csvCache.set(id, { headers: parsedHeaders, data: parsedData });
|
||||
} catch (error) {
|
||||
@@ -173,3 +174,53 @@ const csvCache = new Map<
|
||||
string,
|
||||
{ headers: string[]; data: Record<string, string>[] }
|
||||
>();
|
||||
|
||||
export function parseCSV(text: string): string[][] {
|
||||
const rows: string[][] = [];
|
||||
let field = "";
|
||||
let fields: string[] = [];
|
||||
let inQuotes = false;
|
||||
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const char = text[i];
|
||||
|
||||
if (inQuotes) {
|
||||
if (char === '"') {
|
||||
if (i + 1 < text.length && text[i + 1] === '"') {
|
||||
field += '"';
|
||||
i++;
|
||||
} else {
|
||||
inQuotes = false;
|
||||
}
|
||||
} else {
|
||||
field += char;
|
||||
}
|
||||
} else if (char === '"') {
|
||||
inQuotes = true;
|
||||
} else if (char === ",") {
|
||||
fields.push(field);
|
||||
field = "";
|
||||
} else if (char === "\n" || char === "\r") {
|
||||
if (char === "\r" && i + 1 < text.length && text[i + 1] === "\n") {
|
||||
i++;
|
||||
}
|
||||
fields.push(field);
|
||||
field = "";
|
||||
rows.push(fields);
|
||||
fields = [];
|
||||
} else {
|
||||
field += char;
|
||||
}
|
||||
}
|
||||
|
||||
if (inQuotes) {
|
||||
throw new Error("Malformed CSV: unterminated quoted field");
|
||||
}
|
||||
|
||||
if (field.length > 0 || fields.length > 0) {
|
||||
fields.push(field);
|
||||
rows.push(fields);
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
@@ -40,12 +40,7 @@ export default function ExpandableContentWrapper({
|
||||
};
|
||||
|
||||
const Content = (
|
||||
<div
|
||||
className={cn(
|
||||
!expanded ? "w-message-default" : "w-full",
|
||||
"!rounded !rounded-lg overflow-y-hidden h-full"
|
||||
)}
|
||||
>
|
||||
<div className="w-message-default max-w-full !rounded-lg overflow-y-hidden h-full">
|
||||
<CardHeader className="w-full bg-background-tint-02 top-0 p-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<Text className="text-ellipsis line-clamp-1" text03 mainUiAction>
|
||||
@@ -83,12 +78,10 @@ export default function ExpandableContentWrapper({
|
||||
)}
|
||||
>
|
||||
<CardContent className="p-0">
|
||||
{!expanded && (
|
||||
<ContentComponent
|
||||
fileDescriptor={fileDescriptor}
|
||||
expanded={expanded}
|
||||
/>
|
||||
)}
|
||||
<ContentComponent
|
||||
fileDescriptor={fileDescriptor}
|
||||
expanded={expanded}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
84
web/src/components/tools/parseCSV.test.ts
Normal file
84
web/src/components/tools/parseCSV.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { parseCSV } from "./CSVContent";
|
||||
|
||||
describe("parseCSV", () => {
|
||||
it("parses simple comma-separated rows", () => {
|
||||
expect(parseCSV("a,b,c\n1,2,3")).toEqual([
|
||||
["a", "b", "c"],
|
||||
["1", "2", "3"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves commas inside quoted fields", () => {
|
||||
expect(parseCSV('name,address\nAlice,"123 Main St, Apt 4"')).toEqual([
|
||||
["name", "address"],
|
||||
["Alice", "123 Main St, Apt 4"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles escaped double quotes inside quoted fields", () => {
|
||||
expect(parseCSV('a,b\n"say ""hello""",world')).toEqual([
|
||||
["a", "b"],
|
||||
['say "hello"', "world"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles newlines inside quoted fields", () => {
|
||||
expect(parseCSV('a,b\n"line1\nline2",val')).toEqual([
|
||||
["a", "b"],
|
||||
["line1\nline2", "val"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles CRLF line endings", () => {
|
||||
expect(parseCSV("a,b\r\n1,2\r\n3,4")).toEqual([
|
||||
["a", "b"],
|
||||
["1", "2"],
|
||||
["3", "4"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles empty fields", () => {
|
||||
expect(parseCSV("a,b,c\n1,,3")).toEqual([
|
||||
["a", "b", "c"],
|
||||
["1", "", "3"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles a single element", () => {
|
||||
expect(parseCSV("a")).toEqual([["a"]]);
|
||||
});
|
||||
|
||||
it("handles a single row with no newline", () => {
|
||||
expect(parseCSV("a,b,c")).toEqual([["a", "b", "c"]]);
|
||||
});
|
||||
|
||||
it("handles quoted fields that are entirely empty", () => {
|
||||
expect(parseCSV('a,b\n"",val')).toEqual([
|
||||
["a", "b"],
|
||||
["", "val"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles multiple quoted fields with commas", () => {
|
||||
expect(parseCSV('"foo, bar","baz, qux"\n"1, 2","3, 4"')).toEqual([
|
||||
["foo, bar", "baz, qux"],
|
||||
["1, 2", "3, 4"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("throws on unterminated quoted field", () => {
|
||||
expect(() => parseCSV('a,b\n"foo,bar')).toThrow(
|
||||
"Malformed CSV: unterminated quoted field"
|
||||
);
|
||||
});
|
||||
|
||||
it("throws on unterminated quote at end of input", () => {
|
||||
expect(() => parseCSV('"unterminated')).toThrow(
|
||||
"Malformed CSV: unterminated quoted field"
|
||||
);
|
||||
});
|
||||
|
||||
it("returns empty array for empty input", () => {
|
||||
expect(parseCSV("")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -198,16 +198,15 @@ export default function SearchUI({ onDocumentClick }: SearchResultsProps) {
|
||||
const showEmpty = !error && results.length === 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="flex-1 min-h-0 w-full grid gap-x-4"
|
||||
style={{
|
||||
gridTemplateColumns: showEmpty ? "1fr" : "3fr 1fr",
|
||||
gridTemplateRows: "auto 1fr auto",
|
||||
}}
|
||||
>
|
||||
{/* Top-left: Search filters */}
|
||||
<div className="row-start-1 col-start-1 flex flex-col justify-end gap-3">
|
||||
<div className="flex-1 min-h-0 w-full flex flex-col gap-3">
|
||||
{/* ── Top row: Filters + Result count ── */}
|
||||
<div className="flex-shrink-0 flex flex-row gap-x-4">
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col justify-end gap-3",
|
||||
showEmpty ? "flex-1" : "flex-[3]"
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-row gap-2">
|
||||
{/* Time filter */}
|
||||
<Popover open={timeFilterOpen} onOpenChange={setTimeFilterOpen}>
|
||||
@@ -307,9 +306,8 @@ export default function SearchUI({ onDocumentClick }: SearchResultsProps) {
|
||||
<Separator noPadding />
|
||||
</div>
|
||||
|
||||
{/* Top-right: Number of results */}
|
||||
{!showEmpty && (
|
||||
<div className="row-start-1 col-start-2 flex flex-col justify-end gap-3">
|
||||
<div className="flex-1 flex flex-col justify-end gap-3">
|
||||
<Section alignItems="start">
|
||||
<Text text03 mainUiMuted>
|
||||
{results.length} Results
|
||||
@@ -319,12 +317,14 @@ export default function SearchUI({ onDocumentClick }: SearchResultsProps) {
|
||||
<Separator noPadding />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Bottom-left: Search results */}
|
||||
{/* ── Middle row: Results + Source filter ── */}
|
||||
<div className="flex-1 min-h-0 flex flex-row gap-x-4">
|
||||
<div
|
||||
className={cn(
|
||||
"row-start-2 col-start-1 min-h-0 overflow-y-scroll py-3 flex flex-col gap-2",
|
||||
showEmpty && "justify-center"
|
||||
"min-h-0 overflow-y-scroll flex flex-col gap-2",
|
||||
showEmpty ? "flex-1 justify-center" : "flex-[3]"
|
||||
)}
|
||||
>
|
||||
{error ? (
|
||||
@@ -332,12 +332,16 @@ export default function SearchUI({ onDocumentClick }: SearchResultsProps) {
|
||||
) : paginatedResults.length > 0 ? (
|
||||
<>
|
||||
{paginatedResults.map((doc) => (
|
||||
<SearchCard
|
||||
<div
|
||||
key={`${doc.document_id}-${doc.chunk_ind}`}
|
||||
document={doc}
|
||||
isLlmSelected={llmSelectedSet.has(doc.document_id)}
|
||||
onDocumentClick={onDocumentClick}
|
||||
/>
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
<SearchCard
|
||||
document={doc}
|
||||
isLlmSelected={llmSelectedSet.has(doc.document_id)}
|
||||
onDocumentClick={onDocumentClick}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
@@ -349,20 +353,8 @@ export default function SearchUI({ onDocumentClick }: SearchResultsProps) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{!showEmpty && (
|
||||
<div className="row-start-3 col-start-1 col-span-2 pt-3">
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setCurrentPage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom-right: Source filter */}
|
||||
{!showEmpty && (
|
||||
<div className="row-start-2 col-start-2 min-h-0 overflow-y-auto flex flex-col gap-4 px-1 py-3">
|
||||
<div className="flex-1 min-h-0 overflow-y-auto flex flex-col gap-4 px-1">
|
||||
<Section gap={0.25} height="fit">
|
||||
{sourcesWithMeta.map(({ source, meta, count }) => (
|
||||
<LineItem
|
||||
@@ -386,6 +378,15 @@ export default function SearchUI({ onDocumentClick }: SearchResultsProps) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
||||
{/* ── Bottom row: Pagination ── */}
|
||||
{!showEmpty && (
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
onPageChange={setCurrentPage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
|
||||
import {
|
||||
buildChatUrl,
|
||||
getAvailableContextTokens,
|
||||
nameChatSession,
|
||||
updateLlmOverrideForChatSession,
|
||||
} from "@/app/app/services/lib";
|
||||
import { getMaxSelectedDocumentTokens } from "@/app/app/projects/projectsService";
|
||||
import { DEFAULT_CONTEXT_TOKENS } from "@/lib/constants";
|
||||
import { StreamStopInfo } from "@/lib/search/interfaces";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { Route } from "next";
|
||||
@@ -194,9 +197,6 @@ export default function useChatController({
|
||||
|
||||
const navigatingAway = useRef(false);
|
||||
|
||||
// Local state that doesn't need to be in the store
|
||||
const [_maxTokens, setMaxTokens] = useState<number>(4096);
|
||||
|
||||
// Sync store state changes
|
||||
useEffect(() => {
|
||||
if (currentSessionId) {
|
||||
@@ -1067,21 +1067,59 @@ export default function useChatController({
|
||||
handleSlackChatRedirect();
|
||||
}, [searchParams, router]);
|
||||
|
||||
// fetch # of allowed document tokens for the selected Persona
|
||||
useEffect(() => {
|
||||
if (!liveAgent?.id) return; // avoid calling with undefined persona id
|
||||
// Available context tokens: if a chat session exists, fetch from the session
|
||||
// API (dynamic per session/model). Otherwise derive from the persona's max
|
||||
// document tokens. The backend already accounts for system prompt, tools,
|
||||
// and user-message reservations.
|
||||
const [availableContextTokens, setAvailableContextTokens] = useState<number>(
|
||||
DEFAULT_CONTEXT_TOKENS
|
||||
);
|
||||
|
||||
async function fetchMaxTokens() {
|
||||
const response = await fetch(
|
||||
`/api/chat/max-selected-document-tokens?persona_id=${liveAgent?.id}`
|
||||
);
|
||||
if (response.ok) {
|
||||
const maxTokens = (await response.json()).max_tokens as number;
|
||||
setMaxTokens(maxTokens);
|
||||
useEffect(() => {
|
||||
if (!llmManager.hasAnyProvider) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
const setIfActive = (tokens: number) => {
|
||||
if (!cancelled) setAvailableContextTokens(tokens);
|
||||
};
|
||||
|
||||
// Prefer the Zustand session ID, but fall back to the URL-derived prop
|
||||
// so we don't incorrectly take the persona path while the store is
|
||||
// still initialising on navigation to an existing chat.
|
||||
const sessionId = currentSessionId || existingChatSessionId;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
if (sessionId) {
|
||||
const available = await getAvailableContextTokens(sessionId);
|
||||
setIfActive(available ?? DEFAULT_CONTEXT_TOKENS);
|
||||
return;
|
||||
}
|
||||
|
||||
const personaId = liveAgent?.id;
|
||||
if (personaId == null) {
|
||||
setIfActive(DEFAULT_CONTEXT_TOKENS);
|
||||
return;
|
||||
}
|
||||
|
||||
const maxTokens = await getMaxSelectedDocumentTokens(personaId);
|
||||
setIfActive(maxTokens ?? DEFAULT_CONTEXT_TOKENS);
|
||||
} catch (e) {
|
||||
console.error("Failed to fetch available context tokens:", e);
|
||||
setIfActive(DEFAULT_CONTEXT_TOKENS);
|
||||
}
|
||||
}
|
||||
fetchMaxTokens();
|
||||
}, [liveAgent]);
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [
|
||||
currentSessionId,
|
||||
existingChatSessionId,
|
||||
liveAgent?.id,
|
||||
llmManager.hasAnyProvider,
|
||||
]);
|
||||
|
||||
// check if there's an image file in the message history so that we know
|
||||
// which LLMs are available to use
|
||||
@@ -1110,5 +1148,7 @@ export default function useChatController({
|
||||
onSubmit,
|
||||
stopGenerating,
|
||||
handleMessageSpecificFileUpload,
|
||||
// data
|
||||
availableContextTokens,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ function measure(el: HTMLElement): { x: number; y: number } | null {
|
||||
*/
|
||||
export default function useContainerCenter(): ContainerCenter {
|
||||
const pathname = usePathname();
|
||||
const { isSmallScreen } = useScreenSize();
|
||||
const { isMediumScreen } = useScreenSize();
|
||||
const [center, setCenter] = useState<{ x: number | null; y: number | null }>(
|
||||
() => {
|
||||
if (typeof document === "undefined") return NULL_CENTER;
|
||||
@@ -68,9 +68,9 @@ export default function useContainerCenter(): ContainerCenter {
|
||||
}, [pathname]);
|
||||
|
||||
return {
|
||||
centerX: isSmallScreen ? null : center.x,
|
||||
centerY: isSmallScreen ? null : center.y,
|
||||
hasContainerCenter: isSmallScreen
|
||||
centerX: isMediumScreen ? null : center.x,
|
||||
centerY: isMediumScreen ? null : center.y,
|
||||
hasContainerCenter: isMediumScreen
|
||||
? false
|
||||
: center.x !== null && center.y !== null,
|
||||
};
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import {
|
||||
DESKTOP_SMALL_BREAKPOINT_PX,
|
||||
DESKTOP_MEDIUM_BREAKPOINT_PX,
|
||||
MOBILE_SIDEBAR_BREAKPOINT_PX,
|
||||
} from "@/lib/constants";
|
||||
import { useState, useCallback } from "react";
|
||||
@@ -12,6 +13,7 @@ export interface ScreenSize {
|
||||
width: number;
|
||||
isMobile: boolean;
|
||||
isSmallScreen: boolean;
|
||||
isMediumScreen: boolean;
|
||||
}
|
||||
|
||||
export default function useScreenSize(): ScreenSize {
|
||||
@@ -34,11 +36,13 @@ export default function useScreenSize(): ScreenSize {
|
||||
|
||||
const isMobile = sizes.width <= MOBILE_SIDEBAR_BREAKPOINT_PX;
|
||||
const isSmall = sizes.width <= DESKTOP_SMALL_BREAKPOINT_PX;
|
||||
const isMedium = sizes.width <= DESKTOP_MEDIUM_BREAKPOINT_PX;
|
||||
|
||||
return {
|
||||
height: sizes.height,
|
||||
width: sizes.width,
|
||||
isMobile: isMounted && isMobile,
|
||||
isSmallScreen: isMounted && isSmall,
|
||||
isMediumScreen: isMounted && isMedium,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { useEffect, useSyncExternalStore } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type { Route } from "next";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -174,15 +172,21 @@ interface ToastFromQueryMessages {
|
||||
* and strips the param from the URL.
|
||||
*/
|
||||
export function useToastFromQuery(messages: ToastFromQueryMessages) {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
const messageValue = searchParams?.get("message");
|
||||
|
||||
if (messageValue && messageValue in messages) {
|
||||
searchParams.delete("message");
|
||||
const newSearch = searchParams.toString()
|
||||
? "?" + searchParams.toString()
|
||||
: "";
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
window.location.pathname + newSearch
|
||||
);
|
||||
const spec = messages[messageValue];
|
||||
router.replace(window.location.pathname as Route);
|
||||
if (spec !== undefined) {
|
||||
toast({
|
||||
message: spec.message,
|
||||
|
||||
@@ -7,6 +7,7 @@ export enum LLMProviderName {
|
||||
OPENROUTER = "openrouter",
|
||||
VERTEX_AI = "vertex_ai",
|
||||
BEDROCK = "bedrock",
|
||||
LITELLM_PROXY = "litellm_proxy",
|
||||
CUSTOM = "custom",
|
||||
}
|
||||
|
||||
@@ -144,6 +145,18 @@ export interface OpenRouterFetchParams {
|
||||
provider_name?: string;
|
||||
}
|
||||
|
||||
export interface LiteLLMProxyFetchParams {
|
||||
api_base?: string;
|
||||
api_key?: string;
|
||||
provider_name?: string;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface LiteLLMProxyModelResponse {
|
||||
provider_name: string;
|
||||
model_name: string;
|
||||
}
|
||||
|
||||
export interface VertexAIFetchParams {
|
||||
model_configurations?: ModelConfiguration[];
|
||||
}
|
||||
@@ -153,4 +166,5 @@ export type FetchModelsParams =
|
||||
| OllamaFetchParams
|
||||
| LMStudioFetchParams
|
||||
| OpenRouterFetchParams
|
||||
| LiteLLMProxyFetchParams
|
||||
| VertexAIFetchParams;
|
||||
|
||||
@@ -123,6 +123,7 @@ export const MAX_FILES_TO_SHOW = 3;
|
||||
// SIZES
|
||||
export const MOBILE_SIDEBAR_BREAKPOINT_PX = 640;
|
||||
export const DESKTOP_SMALL_BREAKPOINT_PX = 912;
|
||||
export const DESKTOP_MEDIUM_BREAKPOINT_PX = 1232;
|
||||
export const DEFAULT_AGENT_AVATAR_SIZE_PX = 18;
|
||||
export const HORIZON_DISTANCE_PX = 800;
|
||||
export const LOGO_FOLDED_SIZE_PX = 24;
|
||||
|
||||
10
web/src/lib/error.ts
Normal file
10
web/src/lib/error.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Extract a human-readable error message from an SWR error object.
|
||||
* SWR errors from `errorHandlingFetcher` attach `info.message` or `info.detail`.
|
||||
*/
|
||||
export function getErrorMsg(
|
||||
error: { info?: { message?: string; detail?: string } } | null | undefined,
|
||||
fallback = "An unknown error occurred"
|
||||
): string {
|
||||
return error?.info?.message || error?.info?.detail || fallback;
|
||||
}
|
||||
@@ -22,6 +22,7 @@ const PROVIDER_ICONS: Record<string, IconFunctionComponent> = {
|
||||
[LLMProviderName.BEDROCK]: SvgAws,
|
||||
[LLMProviderName.AZURE]: SvgAzure,
|
||||
litellm: SvgLitellm,
|
||||
[LLMProviderName.LITELLM_PROXY]: SvgLitellm,
|
||||
[LLMProviderName.OLLAMA_CHAT]: SvgOllama,
|
||||
[LLMProviderName.OPENROUTER]: SvgOpenrouter,
|
||||
[LLMProviderName.LM_STUDIO]: SvgLmStudio,
|
||||
@@ -37,6 +38,7 @@ const PROVIDER_PRODUCT_NAMES: Record<string, string> = {
|
||||
[LLMProviderName.BEDROCK]: "Amazon Bedrock",
|
||||
[LLMProviderName.AZURE]: "Azure OpenAI",
|
||||
litellm: "LiteLLM",
|
||||
[LLMProviderName.LITELLM_PROXY]: "LiteLLM Proxy",
|
||||
[LLMProviderName.OLLAMA_CHAT]: "Ollama",
|
||||
[LLMProviderName.OPENROUTER]: "OpenRouter",
|
||||
[LLMProviderName.LM_STUDIO]: "LM Studio",
|
||||
@@ -52,6 +54,7 @@ const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
|
||||
[LLMProviderName.BEDROCK]: "AWS",
|
||||
[LLMProviderName.AZURE]: "Microsoft Azure",
|
||||
litellm: "LiteLLM",
|
||||
[LLMProviderName.LITELLM_PROXY]: "LiteLLM Proxy",
|
||||
[LLMProviderName.OLLAMA_CHAT]: "Ollama",
|
||||
[LLMProviderName.OPENROUTER]: "OpenRouter",
|
||||
[LLMProviderName.LM_STUDIO]: "LM Studio",
|
||||
|
||||
@@ -66,7 +66,7 @@ export default function Pagination({
|
||||
const pageNumbers = getPageNumbers();
|
||||
|
||||
return (
|
||||
<Section flexDirection="row" gap={0.25}>
|
||||
<Section flexDirection="row" height="auto" gap={0.25}>
|
||||
{/* Previous button */}
|
||||
<Disabled disabled={currentPage === 1}>
|
||||
<Button
|
||||
@@ -77,7 +77,7 @@ export default function Pagination({
|
||||
</Disabled>
|
||||
|
||||
{/* Page numbers */}
|
||||
<Section flexDirection="row" gap={0} width="fit">
|
||||
<Section flexDirection="row" height="auto" gap={0} width="fit">
|
||||
{pageNumbers.map((page, index) => {
|
||||
if (page === "...") {
|
||||
return (
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
import React, { useMemo } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import Text from "@/refresh-components/texts/Text";
|
||||
import IconButton from "@/refresh-components/buttons/IconButton";
|
||||
import Button from "@/refresh-components/buttons/Button";
|
||||
import { Button } from "@opal/components";
|
||||
import {
|
||||
SvgAlertCircle,
|
||||
SvgAlertTriangle,
|
||||
@@ -224,6 +223,10 @@ export interface MessageProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
actions?: boolean | string;
|
||||
close?: boolean;
|
||||
|
||||
// Action button customization:
|
||||
actionIcon?: IconFunctionComponent;
|
||||
actionPrimary?: boolean;
|
||||
|
||||
// Callbacks:
|
||||
onClose?: () => void;
|
||||
onAction?: () => void;
|
||||
@@ -251,6 +254,9 @@ function MessageInner(
|
||||
actions,
|
||||
close = true,
|
||||
|
||||
actionIcon,
|
||||
actionPrimary,
|
||||
|
||||
onClose,
|
||||
onAction,
|
||||
|
||||
@@ -337,11 +343,11 @@ function MessageInner(
|
||||
{/* Actions */}
|
||||
{actions && (
|
||||
<div className="flex items-center justify-end shrink-0 self-center pr-2">
|
||||
{/* TODO(@raunakab): migrate to opal Button once className/iconClassName is resolved */}
|
||||
<Button
|
||||
secondary
|
||||
prominence={actionPrimary ? "primary" : "secondary"}
|
||||
icon={actionIcon}
|
||||
onClick={onAction}
|
||||
className={size === "large" ? "p-2" : "p-1"}
|
||||
size={size === "large" ? "lg" : "md"}
|
||||
>
|
||||
{typeof actions === "string" ? actions : "Cancel"}
|
||||
</Button>
|
||||
@@ -352,13 +358,12 @@ function MessageInner(
|
||||
{close && (
|
||||
<div className="flex items-center justify-center shrink-0">
|
||||
<div className={cn("flex items-start", closeButtonSize)}>
|
||||
{/* TODO(@raunakab): migrate to opal Button once className/iconClassName is resolved */}
|
||||
<IconButton
|
||||
internal
|
||||
<Button
|
||||
prominence="internal"
|
||||
icon={SvgX}
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
className={size === "large" ? "p-2 rounded-12" : "p-1 rounded-08"}
|
||||
size={size === "large" ? "lg" : "sm"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -29,6 +29,7 @@ import { SourceMetadata } from "@/lib/search/interfaces";
|
||||
import { SourceIcon } from "@/components/SourceIcon";
|
||||
import { useAvailableTools } from "@/hooks/useAvailableTools";
|
||||
import useCCPairs from "@/hooks/useCCPairs";
|
||||
import { useLLMProviders } from "@/hooks/useLLMProviders";
|
||||
import { useVectorDbEnabled } from "@/providers/SettingsProvider";
|
||||
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
|
||||
import { useToolOAuthStatus } from "@/lib/hooks/useToolOAuthStatus";
|
||||
@@ -178,6 +179,10 @@ export default function ActionsPopover({
|
||||
// const [showTopShadow, setShowTopShadow] = useState(false);
|
||||
const { selectedSources, setSelectedSources } = filterManager;
|
||||
const [mcpServers, setMcpServers] = useState<MCPServer[]>([]);
|
||||
const { llmProviders, isLoading: isLLMLoading } = useLLMProviders(
|
||||
selectedAgent.id
|
||||
);
|
||||
const hasAnyProvider = !isLLMLoading && (llmProviders?.length ?? 0) > 0;
|
||||
|
||||
// Use the OAuth hook
|
||||
const { getToolAuthStatus, authenticateTool } = useToolOAuthStatus(
|
||||
@@ -493,7 +498,8 @@ export default function ActionsPopover({
|
||||
|
||||
// Fetch MCP servers for the agent on mount
|
||||
useEffect(() => {
|
||||
if (selectedAgent == null || selectedAgent.id == null) return;
|
||||
if (selectedAgent == null || selectedAgent.id == null || !hasAnyProvider)
|
||||
return;
|
||||
|
||||
const abortController = new AbortController();
|
||||
|
||||
@@ -534,7 +540,7 @@ export default function ActionsPopover({
|
||||
return () => {
|
||||
abortController.abort();
|
||||
};
|
||||
}, [selectedAgent?.id]);
|
||||
}, [selectedAgent?.id, hasAnyProvider]);
|
||||
|
||||
// No separate MCP tool loading; tools already exist in selectedAgent.tools
|
||||
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { redirect, useRouter, useSearchParams } from "next/navigation";
|
||||
import {
|
||||
personaIncludesRetrieval,
|
||||
getAvailableContextTokens,
|
||||
} from "@/app/app/services/lib";
|
||||
import { personaIncludesRetrieval } from "@/app/app/services/lib";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "@/hooks/useToast";
|
||||
import { toast, useToastFromQuery } from "@/hooks/useToast";
|
||||
import { SEARCH_PARAM_NAMES } from "@/app/app/services/searchParams";
|
||||
import { useFederatedConnectors, useFilters, useLlmManager } from "@/lib/hooks";
|
||||
import { useForcedTools } from "@/lib/hooks/useForcedTools";
|
||||
@@ -56,10 +53,7 @@ import ChatScrollContainer, {
|
||||
} from "@/sections/chat/ChatScrollContainer";
|
||||
import ProjectContextPanel from "@/app/app/components/projects/ProjectContextPanel";
|
||||
import { useProjectsContext } from "@/providers/ProjectsContext";
|
||||
import {
|
||||
getProjectTokenCount,
|
||||
getMaxSelectedDocumentTokens,
|
||||
} from "@/app/app/projects/projectsService";
|
||||
import { getProjectTokenCount } from "@/app/app/projects/projectsService";
|
||||
import ProjectChatSessionList from "@/app/app/components/projects/ProjectChatSessionList";
|
||||
import { cn } from "@/lib/utils";
|
||||
import Suggestions from "@/sections/Suggestions";
|
||||
@@ -70,7 +64,6 @@ import * as AppLayouts from "@/layouts/app-layouts";
|
||||
import { SvgChevronDown, SvgFileText } from "@opal/icons";
|
||||
import { Button } from "@opal/components";
|
||||
import Spacer from "@/refresh-components/Spacer";
|
||||
import { DEFAULT_CONTEXT_TOKENS } from "@/lib/constants";
|
||||
import useAppFocus from "@/hooks/useAppFocus";
|
||||
import { useQueryController } from "@/providers/QueryControllerProvider";
|
||||
import WelcomeMessage from "@/app/app/components/WelcomeMessage";
|
||||
@@ -129,6 +122,13 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
|
||||
|
||||
const router = useRouter();
|
||||
const appFocus = useAppFocus();
|
||||
|
||||
useToastFromQuery({
|
||||
oauth_connected: {
|
||||
message: "Authentication successful",
|
||||
type: "success",
|
||||
},
|
||||
});
|
||||
const { setAppMode } = useAppMode();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
@@ -367,18 +367,22 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
|
||||
const autoScrollEnabled = user?.preferences?.auto_scroll !== false;
|
||||
const isStreaming = currentChatState === "streaming";
|
||||
|
||||
const { onSubmit, stopGenerating, handleMessageSpecificFileUpload } =
|
||||
useChatController({
|
||||
filterManager,
|
||||
llmManager,
|
||||
availableAgents: agents,
|
||||
liveAgent,
|
||||
existingChatSessionId: currentChatSessionId,
|
||||
selectedDocuments,
|
||||
searchParams,
|
||||
resetInputBar,
|
||||
setSelectedAgentFromId,
|
||||
});
|
||||
const {
|
||||
onSubmit,
|
||||
stopGenerating,
|
||||
handleMessageSpecificFileUpload,
|
||||
availableContextTokens,
|
||||
} = useChatController({
|
||||
filterManager,
|
||||
llmManager,
|
||||
availableAgents: agents,
|
||||
liveAgent,
|
||||
existingChatSessionId: currentChatSessionId,
|
||||
selectedDocuments,
|
||||
searchParams,
|
||||
resetInputBar,
|
||||
setSelectedAgentFromId,
|
||||
});
|
||||
|
||||
const { onMessageSelection, currentSessionFileTokenCount } =
|
||||
useChatSessionController({
|
||||
@@ -595,43 +599,6 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
|
||||
};
|
||||
}, [currentChatSessionId, currentProjectId, currentProjectDetails?.files]);
|
||||
|
||||
// Available context tokens source of truth:
|
||||
// - If a chat session exists, fetch from session API (dynamic per session/model)
|
||||
// - If no session, derive from the default/current persona's max document tokens
|
||||
const [availableContextTokens, setAvailableContextTokens] = useState<number>(
|
||||
DEFAULT_CONTEXT_TOKENS * 0.5
|
||||
);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function run() {
|
||||
try {
|
||||
if (currentChatSessionId) {
|
||||
const available =
|
||||
await getAvailableContextTokens(currentChatSessionId);
|
||||
const capped_context_tokens =
|
||||
(available ?? DEFAULT_CONTEXT_TOKENS) * 0.5;
|
||||
if (!cancelled) setAvailableContextTokens(capped_context_tokens);
|
||||
} else {
|
||||
const personaId = (selectedAgent || liveAgent)?.id;
|
||||
if (personaId !== undefined && personaId !== null) {
|
||||
const maxTokens = await getMaxSelectedDocumentTokens(personaId);
|
||||
const capped_context_tokens =
|
||||
(maxTokens ?? DEFAULT_CONTEXT_TOKENS) * 0.5;
|
||||
if (!cancelled) setAvailableContextTokens(capped_context_tokens);
|
||||
} else if (!cancelled) {
|
||||
setAvailableContextTokens(DEFAULT_CONTEXT_TOKENS * 0.5);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
if (!cancelled) setAvailableContextTokens(DEFAULT_CONTEXT_TOKENS * 0.5);
|
||||
}
|
||||
}
|
||||
run();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [currentChatSessionId, selectedAgent?.id, liveAgent?.id]);
|
||||
|
||||
// handle error case where no assistants are available
|
||||
// Only show this after agents have loaded to prevent flash during initial load
|
||||
if (noAgents && !isLoadingAgents) {
|
||||
@@ -716,7 +683,7 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
|
||||
>
|
||||
{/* Main content grid — 3 rows, animated */}
|
||||
<div
|
||||
className="flex-1 w-full grid min-h-0 px-4 transition-[grid-template-rows] duration-150 ease-in-out"
|
||||
className="flex-1 w-full grid min-h-0 transition-[grid-template-rows] duration-150 ease-in-out"
|
||||
style={gridStyle}
|
||||
>
|
||||
{/* ── Top row: ChatUI / WelcomeMessage / ProjectUI ── */}
|
||||
@@ -781,7 +748,7 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
|
||||
</div>
|
||||
|
||||
{/* ── Middle-center: AppInputBar ── */}
|
||||
<div className="row-start-2 flex flex-col items-center">
|
||||
<div className="row-start-2 flex flex-col items-center px-4">
|
||||
<div className="relative w-full max-w-[var(--app-page-main-content-width)] flex flex-col">
|
||||
{/* Scroll to bottom button - positioned absolutely above AppInputBar */}
|
||||
{appFocus.isChat() && showScrollButton && (
|
||||
@@ -881,7 +848,7 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
|
||||
</div>
|
||||
|
||||
{/* ── Bottom: SearchResults + SourceFilter / Suggestions / ProjectChatList ── */}
|
||||
<div className="row-start-3 min-h-0 overflow-hidden flex flex-col items-center w-full">
|
||||
<div className="row-start-3 min-h-0 overflow-hidden flex flex-col items-center w-full px-4">
|
||||
{/* Agent description below input */}
|
||||
{(appFocus.isNewSession() || appFocus.isAgent()) &&
|
||||
!isDefaultAgent && (
|
||||
|
||||
@@ -44,6 +44,7 @@ import { VertexAIModal } from "@/sections/modals/llmConfig/VertexAIModal";
|
||||
import { OpenRouterModal } from "@/sections/modals/llmConfig/OpenRouterModal";
|
||||
import { CustomModal } from "@/sections/modals/llmConfig/CustomModal";
|
||||
import { LMStudioForm } from "@/sections/modals/llmConfig/LMStudioForm";
|
||||
import { LiteLLMProxyModal } from "@/sections/modals/llmConfig/LiteLLMProxyModal";
|
||||
import { Section } from "@/layouts/general-layouts";
|
||||
|
||||
const route = ADMIN_ROUTE_CONFIG[ADMIN_PATHS.LLM_MODELS]!;
|
||||
@@ -116,6 +117,13 @@ const PROVIDER_MODAL_MAP: Record<
|
||||
onOpenChange={onOpenChange}
|
||||
/>
|
||||
),
|
||||
litellm_proxy: (d, open, onOpenChange) => (
|
||||
<LiteLLMProxyModal
|
||||
shouldMarkAsDefault={d}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
/>
|
||||
),
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -366,7 +366,7 @@ const ChatScrollContainer = React.memo(
|
||||
>
|
||||
<div
|
||||
ref={contentWrapperRef}
|
||||
className="w-full flex-1 flex flex-col items-center"
|
||||
className="w-full flex-1 flex flex-col items-center px-4"
|
||||
data-scroll-ready={isScrollReady}
|
||||
style={{
|
||||
visibility: isScrollReady ? "visible" : "hidden",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user