Compare commits

..

4 Commits

Author SHA1 Message Date
Nik
6dbf26ca04 feat(chat): add DB schema and Pydantic models for multi-model answers - 1/8
Add the foundational schema and type changes needed for multi-model
answer generation, where users can compare responses from up to 3 LLMs
side-by-side.

- Alembic migration: add `preferred_response_id` FK and
  `model_display_name` columns to `chat_message`
- Extend `SendMessageRequest` with `llm_overrides: list[LLMOverride]`
- Add `model_index` to `Placement` for streaming packet routing
- New `MultiModelMessageResponseIDInfo` packet type
- Extend `ChatMessageDetail` with `preferred_response_id` and
  `model_display_name`
2026-03-12 12:21:23 -07:00
Nikolas Garza
809dab5746 feat(admin): add row actions with confirmation modals - 5/9 (#9180) 2026-03-12 17:46:12 +00:00
Wenxi
1649bed548 refactor: use ods latest-stable-tag to tag images in Docker Hub (#9281)
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-03-12 17:06:58 +00:00
Jamison Lahman
dd07b3cf27 fix(fe): prevent clicking InputSelect from selecting text (#9292) 2026-03-12 09:32:06 -07:00
16 changed files with 551 additions and 425 deletions

View File

@@ -29,15 +29,27 @@ jobs:
build-backend-craft: ${{ steps.check.outputs.build-backend-craft }}
build-model-server: ${{ steps.check.outputs.build-model-server }}
is-cloud-tag: ${{ steps.check.outputs.is-cloud-tag }}
is-stable: ${{ steps.check.outputs.is-stable }}
is-beta: ${{ steps.check.outputs.is-beta }}
is-stable-standalone: ${{ steps.check.outputs.is-stable-standalone }}
is-beta-standalone: ${{ steps.check.outputs.is-beta-standalone }}
is-craft-latest: ${{ steps.check.outputs.is-craft-latest }}
is-latest: ${{ steps.check.outputs.is-latest }}
is-test-run: ${{ steps.check.outputs.is-test-run }}
sanitized-tag: ${{ steps.check.outputs.sanitized-tag }}
short-sha: ${{ steps.check.outputs.short-sha }}
steps:
- name: Checkout (for git tags)
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # ratchet:actions/checkout@v6
with:
persist-credentials: false
fetch-depth: 0
fetch-tags: true
- name: Setup uv
uses: astral-sh/setup-uv@5a095e7a2014a4212f075830d4f7277575a9d098 # ratchet:astral-sh/setup-uv@v7
with:
version: "0.9.9"
enable-cache: false
- name: Check which components to build and version info
id: check
env:
@@ -54,9 +66,9 @@ jobs:
IS_VERSION_TAG=false
IS_STABLE=false
IS_BETA=false
IS_STABLE_STANDALONE=false
IS_BETA_STANDALONE=false
IS_CRAFT_LATEST=false
IS_LATEST=false
IS_PROD_TAG=false
IS_TEST_RUN=false
BUILD_DESKTOP=false
@@ -104,13 +116,22 @@ jobs:
fi
# Standalone version checks (for backend/model-server - version excluding cloud tags)
if [[ "$IS_STABLE" == "true" ]] && [[ "$IS_CLOUD" != "true" ]]; then
IS_STABLE_STANDALONE=true
fi
if [[ "$IS_BETA" == "true" ]] && [[ "$IS_CLOUD" != "true" ]]; then
IS_BETA_STANDALONE=true
fi
# Determine if this tag should get the "latest" Docker tag.
# Only the highest semver stable tag (vX.Y.Z exactly) gets "latest".
if [[ "$IS_STABLE" == "true" ]]; then
HIGHEST_STABLE=$(uv run --no-sync --with onyx-devtools ods latest-stable-tag) || {
echo "::error::Failed to determine highest stable tag via 'ods latest-stable-tag'"
exit 1
}
if [[ "$TAG" == "$HIGHEST_STABLE" ]]; then
IS_LATEST=true
fi
fi
# Determine if this is a production tag
# Production tags are: version tags (v1.2.3*) or nightly tags
if [[ "$IS_VERSION_TAG" == "true" ]] || [[ "$IS_NIGHTLY" == "true" ]]; then
@@ -129,11 +150,10 @@ jobs:
echo "build-backend-craft=$BUILD_BACKEND_CRAFT"
echo "build-model-server=$BUILD_MODEL_SERVER"
echo "is-cloud-tag=$IS_CLOUD"
echo "is-stable=$IS_STABLE"
echo "is-beta=$IS_BETA"
echo "is-stable-standalone=$IS_STABLE_STANDALONE"
echo "is-beta-standalone=$IS_BETA_STANDALONE"
echo "is-craft-latest=$IS_CRAFT_LATEST"
echo "is-latest=$IS_LATEST"
echo "is-test-run=$IS_TEST_RUN"
echo "sanitized-tag=$SANITIZED_TAG"
echo "short-sha=$SHORT_SHA"
@@ -600,7 +620,7 @@ jobs:
latest=false
tags: |
type=raw,value=${{ needs.determine-builds.outputs.is-test-run == 'true' && format('web-{0}', needs.determine-builds.outputs.sanitized-tag) || github.ref_name }}
type=raw,value=${{ needs.determine-builds.outputs.is-test-run != 'true' && needs.determine-builds.outputs.is-stable == 'true' && 'latest' || '' }}
type=raw,value=${{ needs.determine-builds.outputs.is-test-run != 'true' && needs.determine-builds.outputs.is-latest == 'true' && 'latest' || '' }}
type=raw,value=${{ needs.determine-builds.outputs.is-test-run != 'true' && env.EDGE_TAG == 'true' && 'edge' || '' }}
type=raw,value=${{ needs.determine-builds.outputs.is-test-run != 'true' && needs.determine-builds.outputs.is-beta == 'true' && 'beta' || '' }}
@@ -1037,7 +1057,7 @@ jobs:
latest=false
tags: |
type=raw,value=${{ needs.determine-builds.outputs.is-test-run == 'true' && format('backend-{0}', needs.determine-builds.outputs.sanitized-tag) || github.ref_name }}
type=raw,value=${{ needs.determine-builds.outputs.is-test-run != 'true' && needs.determine-builds.outputs.is-stable-standalone == 'true' && 'latest' || '' }}
type=raw,value=${{ needs.determine-builds.outputs.is-test-run != 'true' && needs.determine-builds.outputs.is-latest == 'true' && 'latest' || '' }}
type=raw,value=${{ needs.determine-builds.outputs.is-test-run != 'true' && env.EDGE_TAG == 'true' && 'edge' || '' }}
type=raw,value=${{ needs.determine-builds.outputs.is-test-run != 'true' && needs.determine-builds.outputs.is-beta-standalone == 'true' && 'beta' || '' }}
@@ -1473,7 +1493,7 @@ jobs:
latest=false
tags: |
type=raw,value=${{ needs.determine-builds.outputs.is-test-run == 'true' && format('model-server-{0}', needs.determine-builds.outputs.sanitized-tag) || github.ref_name }}
type=raw,value=${{ needs.determine-builds.outputs.is-test-run != 'true' && needs.determine-builds.outputs.is-stable-standalone == 'true' && 'latest' || '' }}
type=raw,value=${{ needs.determine-builds.outputs.is-test-run != 'true' && needs.determine-builds.outputs.is-latest == 'true' && 'latest' || '' }}
type=raw,value=${{ needs.determine-builds.outputs.is-test-run != 'true' && env.EDGE_TAG == 'true' && 'edge' || '' }}
type=raw,value=${{ needs.determine-builds.outputs.is-test-run != 'true' && needs.determine-builds.outputs.is-beta-standalone == 'true' && 'beta' || '' }}

View File

@@ -0,0 +1,42 @@
"""add multi-model columns to chat_message
Revision ID: a3f8b2c1d4e5
Revises: 27fb147a843f
Create Date: 2026-03-12 10:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "a3f8b2c1d4e5"
down_revision = "27fb147a843f"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"chat_message",
sa.Column(
"preferred_response_id",
sa.Integer(),
sa.ForeignKey("chat_message.id"),
nullable=True,
),
)
op.add_column(
"chat_message",
sa.Column(
"model_display_name",
sa.String(),
nullable=True,
),
)
def downgrade() -> None:
op.drop_column("chat_message", "model_display_name")
op.drop_column("chat_message", "preferred_response_id")

View File

@@ -8,6 +8,7 @@ from onyx.configs.constants import MessageType
from onyx.context.search.models import SearchDoc
from onyx.file_store.models import InMemoryChatFile
from onyx.server.query_and_chat.models import MessageResponseIDInfo
from onyx.server.query_and_chat.models import MultiModelMessageResponseIDInfo
from onyx.server.query_and_chat.streaming_models import CitationInfo
from onyx.server.query_and_chat.streaming_models import GeneratedImage
from onyx.server.query_and_chat.streaming_models import Packet
@@ -35,7 +36,13 @@ class CreateChatSessionID(BaseModel):
chat_session_id: UUID
AnswerStreamPart = Packet | MessageResponseIDInfo | StreamingError | CreateChatSessionID
AnswerStreamPart = (
Packet
| MessageResponseIDInfo
| MultiModelMessageResponseIDInfo
| StreamingError
| CreateChatSessionID
)
AnswerStream = Iterator[AnswerStreamPart]

View File

@@ -2622,6 +2622,18 @@ class ChatMessage(Base):
ForeignKey("chat_message.id"), nullable=True
)
# For multi-model turns: the user message points to which assistant response
# was selected as the preferred one to continue the conversation with.
# Only set on user messages that triggered a multi-model generation.
preferred_response_id: Mapped[int | None] = mapped_column(
ForeignKey("chat_message.id"), nullable=True
)
# The display name of the model that generated this assistant message
# (e.g. "GPT-4", "Claude Opus"). Used on session reload to label
# multi-model response panels and <> navigation arrows.
model_display_name: Mapped[str | None] = mapped_column(String, nullable=True)
# Only set on summary messages - the ID of the last message included in this summary
# Used for chat history compression
last_summarized_message_id: Mapped[int | None] = mapped_column(
@@ -2696,6 +2708,12 @@ class ChatMessage(Base):
remote_side="ChatMessage.id",
)
preferred_response: Mapped["ChatMessage | None"] = relationship(
"ChatMessage",
foreign_keys=[preferred_response_id],
remote_side="ChatMessage.id",
)
# Chat messages only need to know their immediate tool call children
# If there are nested tool calls, they are stored in the tool_call_children relationship.
tool_calls: Mapped[list["ToolCall"] | None] = relationship(

View File

@@ -1,6 +1,3 @@
import logging
import re
from dataclasses import dataclass
from datetime import datetime
from typing import cast
@@ -49,8 +46,6 @@ from onyx.onyxbot.slack.utils import remove_slack_text_interactions
from onyx.onyxbot.slack.utils import translate_vespa_highlight_to_slack
from onyx.utils.text_processing import decode_escapes
logger = logging.getLogger(__name__)
_MAX_BLURB_LEN = 45
@@ -81,99 +76,6 @@ def get_feedback_reminder_blocks(thread_link: str, include_followup: bool) -> Bl
return SectionBlock(text=text)
@dataclass
class CodeSnippet:
"""A code block extracted from the answer to be uploaded as a Slack file."""
code: str
language: str
filename: str
_SECTION_BLOCK_LIMIT = 3000
# Matches fenced code blocks: ```lang\n...\n```
# The opening fence must start at the beginning of a line (^), may have an
# optional language specifier (\w*), followed by a newline. The closing fence
# is ``` at the beginning of a line. We also handle an optional trailing
# newline on the closing fence line to be robust against different formatting.
_CODE_FENCE_RE = re.compile(
r"^```(\w*)\n(.*?)^```\s*$",
re.MULTILINE | re.DOTALL,
)
def _extract_code_snippets(
text: str, limit: int = _SECTION_BLOCK_LIMIT
) -> tuple[str, list[CodeSnippet]]:
"""Extract code blocks that would push the text over *limit*.
Returns (cleaned_text, snippets) where *cleaned_text* has large code
blocks replaced with a placeholder and *snippets* contains the extracted
code to be uploaded as Slack file snippets.
Uses a two-pass approach: first collect all matches, then decide which
to extract based on cumulative removal so each decision accounts for
previously extracted blocks.
Pass *limit=0* to force-extract ALL code blocks unconditionally.
"""
if limit > 0 and len(text) <= limit:
return text, []
# Pass 1: collect all code-fence matches
matches = list(_CODE_FENCE_RE.finditer(text))
if not matches:
return text, []
# Pass 2: decide which blocks to extract, accounting for cumulative removal.
# Only extract if the text is still over the limit OR the block is very large.
# With limit=0, extract everything unconditionally.
extract_indices: set[int] = set()
removed_chars = 0
for i, match in enumerate(matches):
full_block = match.group(0)
if limit == 0:
extract_indices.add(i)
removed_chars += len(full_block)
else:
current_len = len(text) - removed_chars
if current_len > limit and current_len - len(full_block) <= limit:
extract_indices.add(i)
removed_chars += len(full_block)
elif len(full_block) > limit // 2:
extract_indices.add(i)
removed_chars += len(full_block)
if not extract_indices:
return text, []
# Build cleaned text and snippets by processing matches in reverse
# so character offsets remain valid.
snippets: list[CodeSnippet] = []
cleaned = text
for i in sorted(extract_indices, reverse=True):
match = matches[i]
lang = match.group(1) or ""
code = match.group(2)
ext = lang if lang else "txt"
snippets.append(
CodeSnippet(
code=code.strip(),
language=lang or "text",
filename=f"code_{len(extract_indices) - len(snippets)}.{ext}",
)
)
cleaned = cleaned[: match.start()] + cleaned[match.end() :]
# Snippets were appended in reverse order — flip to match document order
snippets.reverse()
# Clean up any triple+ blank lines left by extraction
cleaned = re.sub(r"\n{3,}", "\n\n", cleaned).strip()
return cleaned, snippets
def _split_text(text: str, limit: int = 3000) -> list[str]:
if len(text) <= limit:
return [text]
@@ -515,7 +417,7 @@ def _build_citations_blocks(
def _build_main_response_blocks(
answer: ChatBasicResponse,
) -> tuple[list[Block], list[CodeSnippet]]:
) -> list[Block]:
# TODO: add back in later when auto-filtering is implemented
# if (
# retrieval_info.applied_time_cutoff
@@ -546,45 +448,9 @@ def _build_main_response_blocks(
# replaces markdown links with slack format links
formatted_answer = format_slack_message(answer.answer)
answer_processed = decode_escapes(remove_slack_text_interactions(formatted_answer))
answer_blocks = [SectionBlock(text=text) for text in _split_text(answer_processed)]
# Extract large code blocks as snippets to upload separately,
# avoiding broken code fences when splitting across SectionBlocks.
cleaned_text, code_snippets = _extract_code_snippets(answer_processed)
logger.info(
"Code extraction: input=%d chars, cleaned=%d chars, snippets=%d",
len(answer_processed),
len(cleaned_text),
len(code_snippets),
)
if len(cleaned_text) <= _SECTION_BLOCK_LIMIT:
answer_blocks = [SectionBlock(text=cleaned_text)]
elif "```" not in cleaned_text:
# No code fences — safe to split at word boundaries.
answer_blocks = [
SectionBlock(text=text)
for text in _split_text(cleaned_text, limit=_SECTION_BLOCK_LIMIT)
]
else:
# Text still has code fences after extraction and exceeds the
# SectionBlock limit. Splitting would break the fences, so fall
# back to uploading the entire remaining code as another snippet
# and keeping only the prose in the blocks.
logger.warning(
"Cleaned text still has code fences (%d chars); "
"force-extracting remaining code blocks",
len(cleaned_text),
)
remaining_cleaned, remaining_snippets = _extract_code_snippets(
cleaned_text, limit=0
)
code_snippets.extend(remaining_snippets)
answer_blocks = [
SectionBlock(text=text)
for text in _split_text(remaining_cleaned, limit=_SECTION_BLOCK_LIMIT)
]
return cast(list[Block], answer_blocks), code_snippets
return cast(list[Block], answer_blocks)
def _build_continue_in_web_ui_block(
@@ -665,13 +531,10 @@ def build_slack_response_blocks(
skip_ai_feedback: bool = False,
offer_ephemeral_publication: bool = False,
skip_restated_question: bool = False,
) -> tuple[list[Block], list[CodeSnippet]]:
) -> list[Block]:
"""
This function is a top level function that builds all the blocks for the Slack response.
It also handles combining all the blocks together.
Returns (blocks, code_snippets) where code_snippets should be uploaded
as Slack file snippets in the same thread.
"""
# If called with the OnyxBot slash command, the question is lost so we have to reshow it
if not skip_restated_question:
@@ -681,7 +544,7 @@ def build_slack_response_blocks(
else:
restate_question_block = []
answer_blocks, code_snippets = _build_main_response_blocks(answer)
answer_blocks = _build_main_response_blocks(answer)
web_follow_up_block = []
if channel_conf and channel_conf.get("show_continue_in_web_ui"):
@@ -747,4 +610,4 @@ def build_slack_response_blocks(
+ follow_up_block
)
return all_blocks, code_snippets
return all_blocks

View File

@@ -282,7 +282,7 @@ def handle_publish_ephemeral_message_button(
logger.error(f"Failed to send webhook: {e}")
# remove handling of empheremal block and add AI feedback.
all_blocks, _ = build_slack_response_blocks(
all_blocks = build_slack_response_blocks(
answer=onyx_bot_answer,
message_info=slack_message_info,
channel_conf=channel_conf,
@@ -311,7 +311,7 @@ def handle_publish_ephemeral_message_button(
elif action_id == KEEP_TO_YOURSELF_ACTION_ID:
# Keep as ephemeral message in channel or thread, but remove the publish button and add feedback button
changed_blocks, _ = build_slack_response_blocks(
changed_blocks = build_slack_response_blocks(
answer=onyx_bot_answer,
message_info=slack_message_info,
channel_conf=channel_conf,

View File

@@ -25,7 +25,6 @@ from onyx.db.models import User
from onyx.db.persona import get_persona_by_id
from onyx.db.users import get_user_by_email
from onyx.onyxbot.slack.blocks import build_slack_response_blocks
from onyx.onyxbot.slack.blocks import CodeSnippet
from onyx.onyxbot.slack.constants import SLACK_CHANNEL_REF_PATTERN
from onyx.onyxbot.slack.handlers.utils import send_team_member_message
from onyx.onyxbot.slack.models import SlackMessageInfo
@@ -135,65 +134,6 @@ def build_slack_context_str(
return slack_context_str + "\n\n".join(message_strs)
# Normalize common LLM language aliases to Slack's expected snippet_type values.
# Slack silently falls back to plain text for unrecognized types, so this map
# only needs to cover the most common mismatches.
_SNIPPET_TYPE_MAP: dict[str, str] = {
"py": "python",
"js": "javascript",
"ts": "typescript",
"tsx": "typescript",
"jsx": "javascript",
"sh": "shell",
"bash": "shell",
"zsh": "shell",
"yml": "yaml",
"rb": "ruby",
"rs": "rust",
"cs": "csharp",
"md": "markdown",
"txt": "text",
"text": "plain_text",
}
def _upload_code_snippets(
client: WebClient,
channel: str,
thread_ts: str,
snippets: list[CodeSnippet],
logger: OnyxLoggingAdapter,
receiver_ids: list[str] | None = None,
send_as_ephemeral: bool | None = None,
) -> None:
"""Upload extracted code blocks as Slack file snippets in the thread."""
for snippet in snippets:
try:
snippet_type = _SNIPPET_TYPE_MAP.get(snippet.language, snippet.language)
client.files_upload_v2(
channel=channel,
thread_ts=thread_ts,
content=snippet.code,
filename=snippet.filename,
snippet_type=snippet_type,
)
except Exception:
logger.warning(
f"Failed to upload code snippet {snippet.filename}, "
"falling back to inline code block"
)
# Fall back to posting as a regular message with code fences,
# preserving the same visibility as the primary response.
respond_in_thread_or_channel(
client=client,
channel=channel,
receiver_ids=receiver_ids,
text=f"```{snippet.language}\n{snippet.code}\n```",
thread_ts=thread_ts,
send_as_ephemeral=send_as_ephemeral,
)
def handle_regular_answer(
message_info: SlackMessageInfo,
slack_channel_config: SlackChannelConfig,
@@ -447,7 +387,7 @@ def handle_regular_answer(
offer_ephemeral_publication = False
skip_ai_feedback = False
all_blocks, code_snippets = build_slack_response_blocks(
all_blocks = build_slack_response_blocks(
message_info=message_info,
answer=answer,
channel_conf=channel_conf,
@@ -474,20 +414,6 @@ def handle_regular_answer(
send_as_ephemeral=send_as_ephemeral,
)
# Upload extracted code blocks as Slack file snippets so they
# render as collapsible, syntax-highlighted blocks in the thread.
snippet_thread_ts = target_thread_ts or message_ts_to_respond_to
if code_snippets and snippet_thread_ts:
_upload_code_snippets(
client=client,
channel=channel,
thread_ts=snippet_thread_ts,
snippets=code_snippets,
logger=logger,
receiver_ids=target_receiver_ids,
send_as_ephemeral=send_as_ephemeral,
)
# For DM (ephemeral message), we need to create a thread via a normal message so the user can see
# the ephemeral message. This also will give the user a notification which ephemeral message does not.
# if there is no message_ts_to_respond_to, and we have made it this far, then this is a /onyx message

View File

@@ -41,6 +41,16 @@ class MessageResponseIDInfo(BaseModel):
reserved_assistant_message_id: int
class MultiModelMessageResponseIDInfo(BaseModel):
"""Sent at the start of a multi-model streaming response.
Contains the user message ID and the reserved assistant message IDs
for each model being run in parallel."""
user_message_id: int | None
reserved_assistant_message_ids: list[int]
model_names: list[str]
class SourceTag(Tag):
source: DocumentSource
@@ -86,6 +96,10 @@ class SendMessageRequest(BaseModel):
message: str
llm_override: LLMOverride | None = None
# For multi-model mode: up to 3 LLM overrides to run in parallel.
# When provided with >1 entry, triggers multi-model streaming.
# Backward-compat: if only `llm_override` is set, single-model path is used.
llm_overrides: list[LLMOverride] | None = None
# Test-only override for deterministic LiteLLM mock responses.
mock_llm_response: str | None = None
@@ -211,6 +225,10 @@ class ChatMessageDetail(BaseModel):
error: str | None = None
current_feedback: str | None = None # "like" | "dislike" | null
processing_duration_seconds: float | None = None
# For multi-model turns: the preferred assistant response ID (set on user messages only)
preferred_response_id: int | None = None
# The display name of the model that generated this message (e.g. "GPT-4", "Claude Opus")
model_display_name: str | None = None
def model_dump(self, *args: list, **kwargs: dict[str, Any]) -> dict[str, Any]: # type: ignore
initial_dict = super().model_dump(mode="json", *args, **kwargs) # type: ignore

View File

@@ -8,3 +8,6 @@ class Placement(BaseModel):
tab_index: int = 0
# Used for tools/agents that call other tools, this currently doesn't support nested agents but can be added later
sub_turn_index: int | None = None
# For multi-model streaming: identifies which model (0, 1, 2) this packet belongs to.
# None for single-model (default) responses.
model_index: int | None = None

View File

@@ -0,0 +1,112 @@
"""Unit tests for multi-model schema and Pydantic model additions."""
from datetime import datetime
from onyx.configs.constants import MessageType
from onyx.llm.override_models import LLMOverride
from onyx.server.query_and_chat.models import ChatMessageDetail
from onyx.server.query_and_chat.models import MultiModelMessageResponseIDInfo
from onyx.server.query_and_chat.models import SendMessageRequest
from onyx.server.query_and_chat.placement import Placement
def test_placement_model_index_default_none() -> None:
p = Placement(turn_index=0)
assert p.model_index is None
def test_placement_model_index_set() -> None:
p = Placement(turn_index=0, model_index=2)
assert p.model_index == 2
def test_placement_serialization_with_model_index() -> None:
p = Placement(turn_index=1, tab_index=0, model_index=1)
data = p.model_dump()
assert data["model_index"] == 1
restored = Placement(**data)
assert restored.model_index == 1
def test_multi_model_message_response_id_info() -> None:
info = MultiModelMessageResponseIDInfo(
user_message_id=42,
reserved_assistant_message_ids=[100, 101, 102],
model_names=["gpt-4", "claude-3-opus", "gemini-pro"],
)
data = info.model_dump()
assert data["user_message_id"] == 42
assert len(data["reserved_assistant_message_ids"]) == 3
assert len(data["model_names"]) == 3
def test_multi_model_message_response_id_info_null_user() -> None:
info = MultiModelMessageResponseIDInfo(
user_message_id=None,
reserved_assistant_message_ids=[10],
model_names=["gpt-4"],
)
assert info.user_message_id is None
def test_send_message_request_llm_overrides_none_by_default() -> None:
req = SendMessageRequest(
message="hello",
chat_session_id="00000000-0000-0000-0000-000000000001",
)
assert req.llm_overrides is None
assert req.llm_override is None
def test_send_message_request_with_llm_overrides() -> None:
overrides = [
LLMOverride(model_provider="openai", model_version="gpt-4"),
LLMOverride(model_provider="anthropic", model_version="claude-3-opus"),
]
req = SendMessageRequest(
message="compare these",
chat_session_id="00000000-0000-0000-0000-000000000001",
llm_overrides=overrides,
)
assert req.llm_overrides is not None
assert len(req.llm_overrides) == 2
def test_send_message_request_backward_compat_single_override() -> None:
"""Existing single llm_override still works alongside new llm_overrides field."""
req = SendMessageRequest(
message="single model",
chat_session_id="00000000-0000-0000-0000-000000000001",
llm_override=LLMOverride(model_provider="openai", model_version="gpt-4"),
)
assert req.llm_override is not None
assert req.llm_overrides is None
def test_chat_message_detail_multi_model_fields_default_none() -> None:
detail = ChatMessageDetail(
message_id=1,
message="hello",
message_type=MessageType.USER,
time_sent=datetime.now(),
files=[],
)
assert detail.preferred_response_id is None
assert detail.model_display_name is None
def test_chat_message_detail_multi_model_fields_set() -> None:
detail = ChatMessageDetail(
message_id=1,
message="response from gpt-4",
message_type=MessageType.ASSISTANT,
time_sent=datetime.now(),
files=[],
preferred_response_id=42,
model_display_name="GPT-4",
)
assert detail.preferred_response_id == 42
assert detail.model_display_name == "GPT-4"
data = detail.model_dump()
assert data["preferred_response_id"] == 42
assert data["model_display_name"] == "GPT-4"

View File

@@ -7,9 +7,6 @@ import timeago # type: ignore
from onyx.configs.constants import DocumentSource
from onyx.context.search.models import SavedSearchDoc
from onyx.onyxbot.slack.blocks import _build_documents_blocks
from onyx.onyxbot.slack.blocks import _extract_code_snippets
from onyx.onyxbot.slack.blocks import _split_text
from onyx.onyxbot.slack.handlers.handle_regular_answer import _SNIPPET_TYPE_MAP
def _make_saved_doc(updated_at: datetime | None) -> SavedSearchDoc:
@@ -72,148 +69,3 @@ def test_build_documents_blocks_formats_naive_timestamp(
formatted_timestamp: datetime = captured["doc"]
expected_timestamp: datetime = naive_timestamp.replace(tzinfo=pytz.utc)
assert formatted_timestamp == expected_timestamp
# ---------------------------------------------------------------------------
# _split_text tests
# ---------------------------------------------------------------------------
class TestSplitText:
def test_short_text_returns_single_chunk(self) -> None:
result = _split_text("hello world", limit=100)
assert result == ["hello world"]
def test_splits_at_space_boundary(self) -> None:
text = "aaa bbb ccc ddd"
result = _split_text(text, limit=8)
assert len(result) >= 2
def test_no_code_fences_splits_normally(self) -> None:
text = "word " * 100 # 500 chars
result = _split_text(text, limit=100)
assert len(result) >= 5
for chunk in result:
assert "```" not in chunk
# ---------------------------------------------------------------------------
# _extract_code_snippets tests
# ---------------------------------------------------------------------------
class TestExtractCodeSnippets:
def test_short_text_no_extraction(self) -> None:
text = "short answer with ```python\nprint('hi')\n``` inline"
cleaned, snippets = _extract_code_snippets(text, limit=3000)
assert cleaned == text
assert snippets == []
def test_large_code_block_extracted(self) -> None:
code = "x = 1\n" * 200 # ~1200 chars of code
text = f"Here is the solution:\n```python\n{code}```\nHope that helps!"
cleaned, snippets = _extract_code_snippets(text, limit=200)
assert len(snippets) == 1
assert snippets[0].language == "python"
assert snippets[0].filename == "code_1.python"
assert "x = 1" in snippets[0].code
# Code block should be removed from cleaned text
assert "```" not in cleaned
assert "Here is the solution" in cleaned
assert "Hope that helps!" in cleaned
def test_multiple_code_blocks_only_large_ones_extracted(self) -> None:
small_code = "print('hi')"
large_code = "x = 1\n" * 300
text = (
f"First:\n```python\n{small_code}\n```\n"
f"Second:\n```javascript\n{large_code}\n```\n"
"Done!"
)
cleaned, snippets = _extract_code_snippets(text, limit=500)
# The large block should be extracted
assert len(snippets) >= 1
langs = [s.language for s in snippets]
assert "javascript" in langs
def test_language_specifier_captured(self) -> None:
code = "fn main() {}\n" * 100
text = f"```rust\n{code}```"
_, snippets = _extract_code_snippets(text, limit=100)
assert len(snippets) == 1
assert snippets[0].language == "rust"
assert snippets[0].filename == "code_1.rust"
def test_no_language_defaults_to_text(self) -> None:
code = "some output\n" * 100
text = f"```\n{code}```"
_, snippets = _extract_code_snippets(text, limit=100)
assert len(snippets) == 1
assert snippets[0].language == "text"
assert snippets[0].filename == "code_1.txt"
def test_cleaned_text_has_no_triple_blank_lines(self) -> None:
code = "x = 1\n" * 200
text = f"Before\n\n```python\n{code}```\n\nAfter"
cleaned, _ = _extract_code_snippets(text, limit=100)
assert "\n\n\n" not in cleaned
def test_multiple_blocks_cumulative_removal(self) -> None:
"""When multiple code blocks exist, extraction decisions should
account for previously extracted blocks (two-pass logic).
Blocks must be smaller than limit//2 so the 'very large block'
override doesn't trigger — we're testing the cumulative logic only."""
# Each fenced block is ~103 chars (```python\n + 15*6 + ```)
block_a = "a = 1\n" * 15 # 90 chars of code
block_b = "b = 2\n" * 15 # 90 chars of code
prose = "x" * 200
# Total: ~200 + 103 + 103 + overhead ≈ 420 chars
# limit=300, limit//2=150. Each block (~103) < 150, so only
# the cumulative check applies. Removing block_a (~103 chars)
# brings us to ~317 > 300, so block_b should also be extracted.
# But with limit=350: removing block_a → ~317 ≤ 350, stop.
text = f"{prose}\n```python\n{block_a}```\n```python\n{block_b}```\nEnd"
cleaned, snippets = _extract_code_snippets(text, limit=350)
# After extracting block_a the text is ≤ 350, so block_b stays.
assert len(snippets) == 1
assert snippets[0].filename == "code_1.python"
# block_b should still be in the cleaned text
assert "b = 2" in cleaned
# ---------------------------------------------------------------------------
# _SNIPPET_TYPE_MAP tests
# ---------------------------------------------------------------------------
class TestSnippetTypeMap:
@pytest.mark.parametrize(
"alias,expected",
[
("py", "python"),
("js", "javascript"),
("ts", "typescript"),
("tsx", "typescript"),
("jsx", "javascript"),
("sh", "shell"),
("bash", "shell"),
("yml", "yaml"),
("rb", "ruby"),
("rs", "rust"),
("cs", "csharp"),
("md", "markdown"),
("text", "plain_text"),
],
)
def test_common_aliases_normalized(self, alias: str, expected: str) -> None:
assert _SNIPPET_TYPE_MAP[alias] == expected
def test_unknown_language_passes_through(self) -> None:
unknown = "haskell"
assert _SNIPPET_TYPE_MAP.get(unknown, unknown) == "haskell"

View File

@@ -1,5 +1,6 @@
"use client";
import { useCallback } from "react";
import useSWR from "swr";
import { errorHandlingFetcher } from "@/lib/fetcher";
import { NEXT_PUBLIC_CLOUD_ENABLED } from "@/lib/constants";
@@ -112,11 +113,11 @@ export default function useAdminUsers() {
const isLoading = acceptedLoading || invitedLoading || requestedLoading;
const error = acceptedError ?? invitedError ?? requestedError;
function refresh() {
const refresh = useCallback(() => {
acceptedMutate();
invitedMutate();
requestedMutate();
}
}, [acceptedMutate, invitedMutate, requestedMutate]);
return { users, isLoading, error, refresh };
}

View File

@@ -2,7 +2,7 @@
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { cn, noProp } from "@/lib/utils";
import { cn } from "@/lib/utils";
import LineItem, { LineItemProps } from "@/refresh-components/buttons/LineItem";
import Text from "@/refresh-components/texts/Text";
import type { IconProps } from "@opal/types";
@@ -298,7 +298,10 @@ function InputSelectContent({
)}
sideOffset={4}
position="popper"
onMouseDown={noProp()}
onMouseDown={(e) => {
e.stopPropagation();
e.preventDefault();
}}
{...props}
>
<SelectPrimitive.Viewport className="flex flex-col gap-1">

View File

@@ -0,0 +1,210 @@
"use client";
import { useState } from "react";
import { Button } from "@opal/components";
import { SvgMoreHorizontal, SvgXCircle, SvgTrash, SvgCheck } from "@opal/icons";
import { Disabled } from "@opal/core";
import Popover from "@/refresh-components/Popover";
import ConfirmationModalLayout from "@/refresh-components/layouts/ConfirmationModalLayout";
import Text from "@/refresh-components/texts/Text";
import { UserStatus } from "@/lib/types";
import { toast } from "@/hooks/useToast";
import { deactivateUser, activateUser, deleteUser } from "./svc";
import type { UserRow } from "./interfaces";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type ModalType = "deactivate" | "activate" | "delete" | null;
interface UserRowActionsProps {
user: UserRow;
onMutate: () => void;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export default function UserRowActions({
user,
onMutate,
}: UserRowActionsProps) {
const [modal, setModal] = useState<ModalType>(null);
const [popoverOpen, setPopoverOpen] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
async function handleAction(
action: () => Promise<void>,
successMessage: string
) {
setIsSubmitting(true);
try {
await action();
onMutate();
toast.success(successMessage);
setModal(null);
} catch (err) {
toast.error(err instanceof Error ? err.message : "An error occurred");
} finally {
setIsSubmitting(false);
}
}
// Only show actions for accepted users (active or inactive).
// Invited/requested users have no row actions in this PR.
if (
user.status !== UserStatus.ACTIVE &&
user.status !== UserStatus.INACTIVE
) {
return null;
}
// SCIM-managed users cannot be modified from the UI — changes would be
// overwritten on the next IdP sync.
if (user.is_scim_synced) {
return null;
}
return (
<>
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
<Popover.Trigger asChild>
<Button prominence="tertiary" icon={SvgMoreHorizontal} />
</Popover.Trigger>
<Popover.Content align="end">
<div className="flex flex-col gap-0.5 p-1">
{user.status === UserStatus.ACTIVE ? (
<Button
prominence="tertiary"
icon={SvgXCircle}
onClick={() => {
setPopoverOpen(false);
setModal("deactivate");
}}
>
Deactivate User
</Button>
) : (
<>
<Button
prominence="tertiary"
icon={SvgCheck}
onClick={() => {
setPopoverOpen(false);
setModal("activate");
}}
>
Activate User
</Button>
<Button
prominence="tertiary"
variant="danger"
icon={SvgTrash}
onClick={() => {
setPopoverOpen(false);
setModal("delete");
}}
>
Delete User
</Button>
</>
)}
</div>
</Popover.Content>
</Popover>
{modal === "deactivate" && (
<ConfirmationModalLayout
icon={SvgXCircle}
title="Deactivate User"
onClose={isSubmitting ? undefined : () => setModal(null)}
submit={
<Disabled disabled={isSubmitting}>
<Button
variant="danger"
onClick={async () => {
await handleAction(
() => deactivateUser(user.email),
"User deactivated"
);
}}
>
Deactivate
</Button>
</Disabled>
}
>
<Text as="p" text03>
<Text as="span" text05>
{user.email}
</Text>{" "}
will immediately lose access to Onyx. Their sessions and agents will
be preserved. You can reactivate this account later.
</Text>
</ConfirmationModalLayout>
)}
{modal === "activate" && (
<ConfirmationModalLayout
icon={SvgCheck}
title="Activate User"
onClose={isSubmitting ? undefined : () => setModal(null)}
submit={
<Disabled disabled={isSubmitting}>
<Button
onClick={async () => {
await handleAction(
() => activateUser(user.email),
"User activated"
);
}}
>
Activate
</Button>
</Disabled>
}
>
<Text as="p" text03>
<Text as="span" text05>
{user.email}
</Text>{" "}
will regain access to Onyx.
</Text>
</ConfirmationModalLayout>
)}
{modal === "delete" && (
<ConfirmationModalLayout
icon={SvgTrash}
title="Delete User"
onClose={isSubmitting ? undefined : () => setModal(null)}
submit={
<Disabled disabled={isSubmitting}>
<Button
variant="danger"
onClick={async () => {
await handleAction(
() => deleteUser(user.email),
"User deleted"
);
}}
>
Delete
</Button>
</Disabled>
}
>
<Text as="p" text03>
<Text as="span" text05>
{user.email}
</Text>{" "}
will be permanently removed from Onyx. All of their session history
will be deleted. This cannot be undone.
</Text>
</ConfirmationModalLayout>
)}
</>
);
}

View File

@@ -21,6 +21,7 @@ import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
import useAdminUsers from "@/hooks/useAdminUsers";
import useGroups from "@/hooks/useGroups";
import UserFilters from "./UserFilters";
import UserRowActions from "./UserRowActions";
import type {
UserRow,
UserGroupInfo,
@@ -133,50 +134,54 @@ function renderLastUpdatedColumn(value: string | null) {
}
// ---------------------------------------------------------------------------
// Columns (stable reference — defined at module scope)
// Columns
// ---------------------------------------------------------------------------
const tc = createTableColumns<UserRow>();
const columns = [
tc.qualifier({
content: "avatar-user",
getInitials: (row) => getInitials(row.personal_name, row.email),
selectable: false,
}),
tc.column("email", {
header: "Name",
weight: 22,
minWidth: 140,
cell: renderNameColumn,
}),
tc.column("groups", {
header: "Groups",
weight: 24,
minWidth: 200,
enableSorting: false,
cell: renderGroupsColumn,
}),
tc.column("role", {
header: "Account Type",
weight: 16,
minWidth: 180,
cell: renderRoleColumn,
}),
tc.column("status", {
header: "Status",
weight: 14,
minWidth: 100,
cell: renderStatusColumn,
}),
tc.column("updated_at", {
header: "Last Updated",
weight: 14,
minWidth: 100,
cell: renderLastUpdatedColumn,
}),
tc.actions(),
];
function buildColumns(onMutate: () => void) {
return [
tc.qualifier({
content: "avatar-user",
getInitials: (row) => getInitials(row.personal_name, row.email),
selectable: false,
}),
tc.column("email", {
header: "Name",
weight: 22,
minWidth: 140,
cell: renderNameColumn,
}),
tc.column("groups", {
header: "Groups",
weight: 24,
minWidth: 200,
enableSorting: false,
cell: renderGroupsColumn,
}),
tc.column("role", {
header: "Account Type",
weight: 16,
minWidth: 180,
cell: renderRoleColumn,
}),
tc.column("status", {
header: "Status",
weight: 14,
minWidth: 100,
cell: renderStatusColumn,
}),
tc.column("updated_at", {
header: "Last Updated",
weight: 14,
minWidth: 100,
cell: renderLastUpdatedColumn,
}),
tc.actions({
cell: (row) => <UserRowActions user={row} onMutate={onMutate} />,
}),
];
}
// ---------------------------------------------------------------------------
// Component
@@ -213,7 +218,9 @@ export default function UsersTable({
[allGroups]
);
const { users, isLoading, error } = useAdminUsers();
const { users, isLoading, error, refresh } = useAdminUsers();
const columns = useMemo(() => buildColumns(refresh), [refresh]);
// Client-side filtering
const filteredUsers = useMemo(() => {

View File

@@ -0,0 +1,44 @@
async function parseErrorDetail(
res: Response,
fallback: string
): Promise<string> {
try {
const body = await res.json();
return body?.detail ?? fallback;
} catch {
return fallback;
}
}
export async function deactivateUser(email: string): Promise<void> {
const res = await fetch("/api/manage/admin/deactivate-user", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user_email: email }),
});
if (!res.ok) {
throw new Error(await parseErrorDetail(res, "Failed to deactivate user"));
}
}
export async function activateUser(email: string): Promise<void> {
const res = await fetch("/api/manage/admin/activate-user", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user_email: email }),
});
if (!res.ok) {
throw new Error(await parseErrorDetail(res, "Failed to activate user"));
}
}
export async function deleteUser(email: string): Promise<void> {
const res = await fetch("/api/manage/admin/delete-user", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user_email: email }),
});
if (!res.ok) {
throw new Error(await parseErrorDetail(res, "Failed to delete user"));
}
}