Compare commits

...

22 Commits
main ... v3.0.1

Author SHA1 Message Date
Justin Tahara
79b615db46 feat(litellm): Adding FE Provider workflow (#9264) 2026-03-11 11:56:04 -07:00
Wenxi
98756bccd4 fix: discord connector async resource cleanup (#9203) 2026-03-11 11:53:57 -07:00
Wenxi
418f84ccdf fix: don't fetch mcp tools when no llms are configured (#9173) 2026-03-11 11:53:57 -07:00
Wenxi
d37756a884 fix(mcp): use CE-compatible chat endpoint for search_indexed_documents (#9193)
Co-authored-by: Fizza-Mukhtar <fizzamukhtar01@gmail.com>
2026-03-11 11:53:57 -07:00
Wenxi
9cdc92441b fix: fallback doc access when drive item is externally owned (#9053) 2026-03-11 11:53:57 -07:00
Wenxi
b8ed30644a fix: move available context tokens to useChatController and remove arbitrary 50% cap (#9174) 2026-03-11 11:53:57 -07:00
Danelegend
d7d19e5a28 feat(llm-provider): fetch litellm models (#8418) 2026-03-11 10:55:51 -07:00
github-actions[bot]
948650829d chore(release): upgrade release-tag (#9257) to release v3.0 (#9261)
Co-authored-by: Jamison Lahman <jamison@lahman.dev>
2026-03-10 18:16:55 -07:00
Evan Lohn
b6e689be0f fix: update jira group sync endpoint (#9241) 2026-03-10 17:13:10 -07:00
github-actions[bot]
85877408c8 fix(fe): increase responsive breakpoint for centering modals (#9250) to release v3.0 (#9251)
Co-authored-by: Jamison Lahman <jamison@lahman.dev>
2026-03-10 14:59:46 -07:00
github-actions[bot]
c00df75c79 fix(fe): correctly parse comma literals in CSVs (#9245) to release v3.0 (#9249)
Co-authored-by: Jamison Lahman <jamison@lahman.dev>
2026-03-10 14:13:40 -07:00
github-actions[bot]
6352c9a09e fix(fe): make CSV inline display responsive (#9242) to release v3.0 (#9246)
Co-authored-by: Jamison Lahman <jamison@lahman.dev>
2026-03-10 13:19:54 -07:00
github-actions[bot]
3065f70d7d fix: Prevent the removal and hiding of default model (#9131) to release v3.0 (#9225)
Co-authored-by: Danelegend <43459662+Danelegend@users.noreply.github.com>
2026-03-10 10:54:21 -07:00
Jamison Lahman
4befbc49dc chore(release): run playwright on release pushes (#9233) to release v3.0 (#9238) 2026-03-10 10:29:22 -07:00
Jamison Lahman
ae9679e8c4 fix(safari): Search results dont shrink (#9126) to release v3.0 (#9210) 2026-03-10 09:11:32 -07:00
SubashMohan
ea0ddee5c8 feat(custom-tools): enhance custom tool error handling and timeline UI (#9189) 2026-03-10 17:04:15 +05:30
Nikolas Garza
2826405dd2 fix: use detail instead of message in OnyxError response shape (#9214) to release v3.0 (#9220) 2026-03-09 19:15:29 -07:00
github-actions[bot]
8485bf4368 fix(fe): fix chat content padding (#9216) to release v3.0 (#9218)
Co-authored-by: Jamison Lahman <jamison@lahman.dev>
2026-03-09 17:57:37 -07:00
github-actions[bot]
7bb52b0839 fix(code-interpreter): set default CODE_INTERPRETER_BASE_URL w/ docke… (#9215) to release v3.0 (#9219)
Co-authored-by: Jamison Lahman <jamison@lahman.dev>
2026-03-09 17:57:21 -07:00
github-actions[bot]
85a54c01f1 feat(opensearch): Enable by default (#9211) to release v3.0 (#9217)
Co-authored-by: acaprau <48705707+acaprau@users.noreply.github.com>
2026-03-09 17:35:44 -07:00
github-actions[bot]
e4577bd564 fix(fe): move app padding inside overflow container (#9206) to release v3.0 (#9207)
Co-authored-by: Jamison Lahman <jamison@lahman.dev>
2026-03-09 13:47:36 -07:00
Nikolas Garza
f150a7b940 fix(fe): fix broken slack bot admin pages (#9168) 2026-03-09 13:01:58 -07:00
107 changed files with 3326 additions and 941 deletions

View File

@@ -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} \

View File

@@ -12,6 +12,9 @@ on:
push:
tags:
- "v*.*.*"
# TODO: Remove this if we enable merge-queues for release branches.
branches:
- "release/**"
permissions:
contents: read

View File

@@ -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

View File

@@ -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()

View File

@@ -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),
)

View File

@@ -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

View File

@@ -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:

View File

@@ -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

View File

@@ -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()

View File

@@ -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)

View File

@@ -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

View File

@@ -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()

View File

@@ -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,
}

View File

@@ -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),
)

View File

@@ -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

View File

@@ -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,

View File

@@ -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:

View File

@@ -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)

View File

@@ -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}",
)

View File

@@ -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")

View File

@@ -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,
)
)

View File

@@ -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,

View File

@@ -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],

View File

@@ -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
)

View File

@@ -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):

View File

@@ -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,
)

View File

@@ -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

View File

@@ -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()

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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"]

View File

@@ -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)

View File

@@ -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:

View File

@@ -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(

View File

@@ -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(

View File

@@ -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"

View File

@@ -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)

View File

@@ -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:

View File

@@ -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:

View File

@@ -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:

View File

@@ -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:

View File

@@ -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/

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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
View File

@@ -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]]

View File

@@ -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
],
},

View File

@@ -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>
);
};
}

View File

@@ -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}

View File

@@ -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} />;
}

View File

@@ -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;

View File

@@ -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>
);
}

View File

@@ -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 />;
}

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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>
</>
);
});

View File

@@ -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>
)}
</>
);

View File

@@ -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}`}

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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)

View File

@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
}, [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>
),
},
]);

View File

@@ -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 (

View File

@@ -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);

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
)}

View File

@@ -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

View File

@@ -69,6 +69,7 @@ export const DeepResearchPlanRenderer: MessageRenderer<
icon: SvgCircle,
status: statusText,
content: planContent,
noPaddingRight: true,
},
]);
};

View File

@@ -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>

View File

@@ -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,
},
]);

View File

@@ -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,
},
]);
};

View File

@@ -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(

View File

@@ -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(

View File

@@ -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,

View File

@@ -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;

View File

@@ -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
}
/>
);

View File

@@ -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);

View File

@@ -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

View File

@@ -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;
}

View File

@@ -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>

View 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([]);
});
});

View File

@@ -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>
);
}

View File

@@ -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,
};
}

View File

@@ -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,
};

View File

@@ -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,
};
}

View File

@@ -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,

View File

@@ -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;

View File

@@ -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
View 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;
}

View File

@@ -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",

View File

@@ -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 (

View File

@@ -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>

View File

@@ -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

View File

@@ -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 && (

View File

@@ -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}
/>
),
};
// ============================================================================

View File

@@ -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