Compare commits

..

23 Commits

Author SHA1 Message Date
Raunak Bhagat
550dc42e81 refactor: rename WidthVariant autofit
Rename the `auto` key to `fit` in `widthVariants` to match the actual
CSS behavior (`w-fit` shrink-wraps to content, `w-auto` does not).
Update all defaults, JSDoc, and READMEs across opal.

No call sites use `"auto"` explicitly — all consumers relied on the
default value.
2026-03-06 14:38:56 -08:00
Raunak Bhagat
92e87f211e fix: Content title/icon colors, update LineItemButton docs
- Add explicit text-text-04 to Xl, Lg, Md title rules (previously inherited)
- Add muted prominence icon rule for ContentSm (text-text-02)
- Remove redundant stroke-text-02 from ContentSm icon rules
- Update LineItemButton JSDoc and README to reflect size → roundingVariant rename
2026-03-06 12:37:06 -08:00
Raunak Bhagat
69db726dde fix: explicit Content title/icon colors in styles.css
- Add text-text-04 to Xl, Lg, Md title rules (previously inherited)
- Add muted prominence icon rule for ContentSm (text-text-02)
- Remove redundant stroke-text-02 from ContentSm icon rules
2026-03-06 12:34:09 -08:00
Raunak Bhagat
c238a97867 Add manual rounding-variant 2026-03-06 12:13:34 -08:00
Raunak Bhagat
daab5c15af docs: clarify LineItemButton size prop controls rounding, not height
The container uses `heightVariant="fit"` (content-driven height), so
`size` only selects the rounding preset. Update JSDoc and README to
reflect this accurately.
2026-03-06 12:02:20 -08:00
Raunak Bhagat
27d17533df Restore width variant value 2026-03-06 11:58:35 -08:00
Raunak Bhagat
1d6ea15200 revert: extract WidthVariant autofit rename into separate PR
Temporarily revert the rename so it can be reviewed independently
in a child PR (refactor/width-variant-fit).
2026-03-06 11:40:33 -08:00
Raunak Bhagat
9d9376deee Merge branch 'main' into feat/line-item-button 2026-03-06 11:38:29 -08:00
Raunak Bhagat
b64db5e55b refactor: rename WidthVariant autofit, fix LineItemButton README
Rename the `auto` key to `fit` in `widthVariants` to match the actual
CSS behavior (`w-fit` shrink-wraps, `w-auto` does not). Update all
defaults, JSDoc, and READMEs across opal.

Also fix LineItemButton README: replace nonexistent `selected` prop with
`state`, add `interaction` prop, correct `paddingVariant` from `"fit"`
to `"lg"`, remove stale `Disabled` wrapper from architecture diagram,
and update usage examples.
2026-03-06 11:11:31 -08:00
Raunak Bhagat
0b4cefe0ae fix: keep action-link-05 foreground on filled:active state 2026-03-06 10:38:48 -08:00
Raunak Bhagat
8fad5b456e fix: use DistributiveOmit to preserve ContentProps union, move to @opal/types 2026-03-06 10:26:06 -08:00
Raunak Bhagat
f462d54fbf fix: update filled state colors to action-link-05, add icon interactive rules, expose interaction prop 2026-03-06 10:20:59 -08:00
Raunak Bhagat
6a6ae5c952 fix: drop redundant widthVariant, fix HoverableRoot default className 2026-03-06 09:51:15 -08:00
Raunak Bhagat
1ea5545abd fix: address PR review comments on LineItemButton
- Add var(--text-04) fallback to --interactive-foreground CSS rules
- Default type to "button" for proper semantics
- Exclude "fit" from size prop
- Fix README: withInteractive is hardcoded, not a passthrough
2026-03-06 03:57:14 -08:00
Raunak Bhagat
27a6b1eb6b Merge branch 'main' into feat/line-item-button 2026-03-06 03:53:27 -08:00
Raunak Bhagat
0137c8a514 docs: add README for LineItemButton component
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 03:36:12 -08:00
Raunak Bhagat
ba7f1882d5 feat: add LineItemButton component and Content withInteractive variant
- Add LineItemButton to @opal/components — wraps Interactive.Stateful >
  Interactive.Container > ContentAction into a single API for selectable
  list rows (model pickers, menus, etc.)
- Add withInteractive prop to Content (Sm/Md/Lg/Xl) so the title color
  hooks into Interactive's --interactive-foreground CSS variable
- Add ref prop to all Content layout components and Hoverable (React 19
  ref-as-prop pattern)
- Add widthVariant prop to Hoverable.Root for controlling container width
- Add icon-row gap to ContentXl
2026-03-06 03:36:11 -08:00
Raunak Bhagat
fe341784a0 fix: restore dropped docxVariant import in PreviewModal 2026-03-06 03:29:28 -08:00
Raunak Bhagat
d63888667d Remove bun lock 2026-03-06 03:13:00 -08:00
Raunak Bhagat
78f4c19a27 fix: add aria-disabled to Disabled component for accessibility
The Disabled component was only setting data-opal-disabled but not
aria-disabled, causing Playwright tests checking aria-disabled="true"
on disabled elements to fail.
2026-03-06 02:32:21 -08:00
Raunak Bhagat
0c3332082b fix: always set native disabled on submit buttons regardless of allowClick
Submit buttons must remain natively disabled to prevent form submission,
even when allowClick is true. Only type="button" and type="reset" skip
native disabled for allowClick tooltip/error interactions.
2026-03-06 02:12:12 -08:00
Raunak Bhagat
58e8c16f6d fix: address review comments — allowClick support, children type
- Change Disabled children type from ReactNode to ReactElement (Slot requirement)
- Consume allowClick from context in InteractiveStateless/Stateful —
  preserve onClick when allowClick is true instead of unconditionally stripping it
- Consume allowClick in InteractiveContainer — skip native HTML disabled
  on <button> when allowClick is true so browser doesn't block events
2026-03-06 02:04:10 -08:00
Raunak Bhagat
8a268decb6 refactor: add Disabled primitive to @opal/core and migrate all callsites
- Add Disabled component using Radix Slot (no DOM node) with context-based
  disabled state propagation via useDisabled() hook
- CSS uses [data-opal-disabled]:not(.interactive) for opacity-50 on
  non-Interactive elements; Interactive variants handle their own disabled colors
- Remove opacity from stateless variant disabled rules to prevent double opacity
- Migrate ~100 callsites from <Button disabled={x}> to <Disabled disabled={x}><Button></Disabled>
- Delete old src/refresh-components/Disabled.tsx bridge file
2026-03-06 01:56:54 -08:00
65 changed files with 1128 additions and 1382 deletions

View File

@@ -106,34 +106,13 @@ onyx-cli ask --json "What authentication methods do we support?"
Outputs JSON-encoded parsed stream events (one object per line). Key event objects include message deltas, stop, errors, search-start, and citation payloads.
Each line is a JSON object with this envelope:
```json
{"type": "<event_type>", "event": { ... }}
```
| Event Type | Description |
|------------|-------------|
| `message_delta` | Content token — concatenate all `content` fields for the full answer |
| `stop` | Stream complete |
| `error` | Error with `error` message field |
| `search_tool_start` | Onyx started searching documents |
| `citation_info` | Source citation — see shape below |
`citation_info` event shape:
```json
{
"type": "citation_info",
"event": {
"citation_number": 1,
"document_id": "abc123def456",
"placement": {"turn_index": 0, "tab_index": 0, "sub_turn_index": null}
}
}
```
`placement` is metadata about where in the conversation the citation appeared and can be ignored for most use cases.
| `citation_info` | Source citation with `citation_number` and `document_id` |
### Specify an agent
@@ -150,10 +129,6 @@ Uses a specific Onyx agent/persona instead of the default.
| `--agent-id` | int | Agent ID to use (overrides default) |
| `--json` | bool | Output raw NDJSON events instead of plain text |
## Statelessness
Each `onyx-cli ask` call creates an independent chat session. There is no built-in way to chain context across multiple `ask` invocations — every call starts fresh. If you need multi-turn conversation with memory, use the interactive TUI (`onyx-cli` or `onyx-cli chat`) instead.
## When to Use
Use `onyx-cli ask` when:

View File

@@ -57,7 +57,7 @@ jobs:
cache-dependency-path: ./desktop/package-lock.json
- name: Setup Rust
uses: dtolnay/rust-toolchain@efa25f7f19611383d5b0ccf2d1c8914531636bf9
uses: dtolnay/rust-toolchain@4be9e76fd7c4901c61fb841f559994984270fce7
with:
toolchain: stable
targets: ${{ matrix.target }}

View File

@@ -1,39 +0,0 @@
name: Release CLI
on:
push:
tags:
- "cli/v*.*.*"
jobs:
pypi:
runs-on: ubuntu-latest
environment:
name: release-cli
permissions:
id-token: write
timeout-minutes: 10
strategy:
matrix:
os-arch:
- { goos: "linux", goarch: "amd64" }
- { goos: "linux", goarch: "arm64" }
- { goos: "windows", goarch: "amd64" }
- { goos: "windows", goarch: "arm64" }
- { goos: "darwin", goarch: "amd64" }
- { goos: "darwin", goarch: "arm64" }
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # ratchet:actions/checkout@v6
with:
persist-credentials: false
- uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # ratchet:astral-sh/setup-uv@v7
with:
enable-cache: false
version: "0.9.9"
- run: |
GOOS="${{ matrix.os-arch.goos }}" \
GOARCH="${{ matrix.os-arch.goarch }}" \
uv build --wheel
working-directory: cli
- run: uv publish
working-directory: cli

View File

@@ -22,10 +22,12 @@ jobs:
- { goos: "windows", goarch: "arm64" }
- { goos: "darwin", goarch: "amd64" }
- { goos: "darwin", goarch: "arm64" }
- { goos: "", goarch: "" }
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # ratchet:actions/checkout@v6
with:
persist-credentials: false
fetch-depth: 0
- uses: astral-sh/setup-uv@61cb8a9741eeb8a550a1b8544337180c0fc8476b # ratchet:astral-sh/setup-uv@v7
with:
enable-cache: false

View File

@@ -1,103 +0,0 @@
# Vector DB Filter Semantics
How `IndexFilters` fields combine into the final query filter. Applies to both Vespa and OpenSearch.
## Filter categories
| Category | Fields | Join logic |
|---|---|---|
| **Visibility** | `hidden` | Always applied (unless `include_hidden`) |
| **Tenant** | `tenant_id` | AND (multi-tenant only) |
| **ACL** | `access_control_list` | OR within, AND with rest |
| **Narrowing** | `source_type`, `tags`, `time_cutoff` | Each OR within, AND with rest |
| **Knowledge scope** | `document_set`, `user_file_ids`, `attached_document_ids`, `hierarchy_node_ids` | OR within group, AND with rest |
| **Additive scope** | `project_id`, `persona_id` | OR'd into knowledge scope **only when** a knowledge scope filter already exists |
## How filters combine
All categories are AND'd together. Within the knowledge scope category, individual filters are OR'd.
```
NOT hidden
AND tenant = T -- if multi-tenant
AND (acl contains A1 OR acl contains A2)
AND (source_type = S1 OR ...) -- if set
AND (tag = T1 OR ...) -- if set
AND <knowledge scope> -- see below
AND time >= cutoff -- if set
```
## Knowledge scope rules
The knowledge scope filter controls **what knowledge an assistant can access**.
### No explicit knowledge attached
When `document_set`, `user_file_ids`, `attached_document_ids`, and `hierarchy_node_ids` are all empty/None:
- **No knowledge scope filter is applied.** The assistant can see everything (subject to ACL).
- `project_id` and `persona_id` are ignored — they never restrict on their own.
### One explicit knowledge type
```
-- Only document sets
AND (document_sets contains "Engineering" OR document_sets contains "Legal")
-- Only user files
AND (document_id = "uuid-1" OR document_id = "uuid-2")
```
### Multiple explicit knowledge types (OR'd)
```
-- Document sets + user files
AND (
document_sets contains "Engineering"
OR document_id = "uuid-1"
)
```
### Explicit knowledge + overflowing user files
When an explicit knowledge restriction is in effect **and** `project_id` or `persona_id` is set (user files overflowed the LLM context window), the additive scopes widen the filter:
```
-- Document sets + persona user files overflowed
AND (
document_sets contains "Engineering"
OR personas contains 42
)
-- User files + project files overflowed
AND (
document_id = "uuid-1"
OR user_project contains 7
)
```
### Only project_id or persona_id (no explicit knowledge)
No knowledge scope filter. The assistant searches everything.
```
-- Just ACL, no restriction
NOT hidden
AND (acl contains ...)
```
## Field reference
| Filter field | Vespa field | Vespa type | Purpose |
|---|---|---|---|
| `document_set` | `document_sets` | `weightedset<string>` | Connector doc sets attached to assistant |
| `user_file_ids` | `document_id` | `string` | User files uploaded to assistant |
| `attached_document_ids` | `document_id` | `string` | Documents explicitly attached (OpenSearch only) |
| `hierarchy_node_ids` | `ancestor_hierarchy_node_ids` | `array<int>` | Folder/space nodes (OpenSearch only) |
| `project_id` | `user_project` | `array<int>` | Project tag for overflowing user files |
| `persona_id` | `personas` | `array<int>` | Persona tag for overflowing user files |
| `access_control_list` | `access_control_list` | `weightedset<string>` | ACL entries for the requesting user |
| `source_type` | `source_type` | `string` | Connector source type (e.g. `web`, `jira`) |
| `tags` | `metadata_list` | `array<string>` | Document metadata tags |
| `time_cutoff` | `doc_updated_at` | `long` | Minimum document update timestamp |
| `tenant_id` | `tenant_id` | `string` | Tenant isolation (multi-tenant) |

View File

@@ -698,6 +698,41 @@ class DocumentQuery:
"""
return {"terms": {ANCESTOR_HIERARCHY_NODE_IDS_FIELD_NAME: node_ids}}
def _get_assistant_knowledge_filter(
attached_doc_ids: list[str] | None,
node_ids: list[int] | None,
file_ids: list[UUID] | None,
document_sets: list[str] | None,
) -> dict[str, Any]:
"""Combined filter for assistant knowledge.
When an assistant has attached knowledge, search should be scoped to:
- Documents explicitly attached (by document ID), OR
- Documents under attached hierarchy nodes (by ancestor node IDs), OR
- User-uploaded files attached to the assistant, OR
- Documents in the assistant's document sets (if any)
"""
knowledge_filter: dict[str, Any] = {
"bool": {"should": [], "minimum_should_match": 1}
}
if attached_doc_ids:
knowledge_filter["bool"]["should"].append(
_get_attached_document_id_filter(attached_doc_ids)
)
if node_ids:
knowledge_filter["bool"]["should"].append(
_get_hierarchy_node_filter(node_ids)
)
if file_ids:
knowledge_filter["bool"]["should"].append(
_get_user_file_id_filter(file_ids)
)
if document_sets:
knowledge_filter["bool"]["should"].append(
_get_document_set_filter(document_sets)
)
return knowledge_filter
filter_clauses: list[dict[str, Any]] = []
if not include_hidden:
@@ -723,53 +758,41 @@ class DocumentQuery:
# document's metadata list.
filter_clauses.append(_get_tag_filter(tags))
# Knowledge scope: explicit knowledge attachments restrict what
# an assistant can see. When none are set the assistant
# searches everything.
#
# project_id / persona_id are additive: they make overflowing
# user files findable but must NOT trigger the restriction on
# their own (an agent with no explicit knowledge should search
# everything).
has_knowledge_scope = (
# Check if this is an assistant knowledge search (has any assistant-scoped knowledge)
has_assistant_knowledge = (
attached_document_ids
or hierarchy_node_ids
or user_file_ids
or document_sets
)
if has_knowledge_scope:
knowledge_filter: dict[str, Any] = {
"bool": {"should": [], "minimum_should_match": 1}
}
if attached_document_ids:
knowledge_filter["bool"]["should"].append(
_get_attached_document_id_filter(attached_document_ids)
if has_assistant_knowledge:
# If assistant has attached knowledge, scope search to that knowledge.
# Document sets are included in the OR filter so directly attached
# docs are always findable even if not in the document sets.
filter_clauses.append(
_get_assistant_knowledge_filter(
attached_document_ids,
hierarchy_node_ids,
user_file_ids,
document_sets,
)
if hierarchy_node_ids:
knowledge_filter["bool"]["should"].append(
_get_hierarchy_node_filter(hierarchy_node_ids)
)
if user_file_ids:
knowledge_filter["bool"]["should"].append(
_get_user_file_id_filter(user_file_ids)
)
if document_sets:
knowledge_filter["bool"]["should"].append(
_get_document_set_filter(document_sets)
)
# Additive: widen scope to also cover overflowing user
# files, but only when an explicit restriction is already
# in effect.
if project_id is not None:
knowledge_filter["bool"]["should"].append(
_get_user_project_filter(project_id)
)
if persona_id is not None:
knowledge_filter["bool"]["should"].append(
_get_persona_filter(persona_id)
)
filter_clauses.append(knowledge_filter)
)
elif user_file_ids:
# Fallback for non-assistant user file searches (e.g., project searches)
# If at least one user file ID is provided, the caller will only
# retrieve documents where the document ID is in this input list of
# file IDs.
filter_clauses.append(_get_user_file_id_filter(user_file_ids))
if project_id is not None:
# If a project ID is provided, the caller will only retrieve
# documents where the project ID provided here is present in the
# document's user projects list.
filter_clauses.append(_get_user_project_filter(project_id))
if persona_id is not None:
filter_clauses.append(_get_persona_filter(persona_id))
if time_cutoff is not None:
# If a time cutoff is provided, the caller will only retrieve

View File

@@ -23,8 +23,11 @@ from shared_configs.configs import MULTI_TENANT
logger = setup_logger()
def build_tenant_id_filter(tenant_id: str) -> str:
return f'({TENANT_ID} contains "{tenant_id}")'
def build_tenant_id_filter(tenant_id: str, include_trailing_and: bool = False) -> str:
filter_str = f'({TENANT_ID} contains "{tenant_id}")'
if include_trailing_and:
filter_str += " and "
return filter_str
def build_vespa_filters(
@@ -34,22 +37,30 @@ def build_vespa_filters(
remove_trailing_and: bool = False, # Set to True when using as a complete Vespa query
) -> str:
def _build_or_filters(key: str, vals: list[str] | None) -> str:
"""For string-based 'contains' filters, e.g. WSET fields or array<string> fields.
Returns a bare clause like '(key contains "v1" or key contains "v2")' or ""."""
"""For string-based 'contains' filters, e.g. WSET fields or array<string> fields."""
if not key or not vals:
return ""
eq_elems = [f'{key} contains "{val}"' for val in vals if val]
if not eq_elems:
return ""
return f"({' or '.join(eq_elems)})"
or_clause = " or ".join(eq_elems)
return f"({or_clause}) and "
def _build_int_or_filters(key: str, vals: list[int] | None) -> str:
"""For an integer field filter.
Returns a bare clause or ""."""
"""
For an integer field filter.
If vals is not None, we want *only* docs whose key matches one of vals.
"""
# If `vals` is None => skip the filter entirely
if vals is None or not vals:
return ""
# Otherwise build the OR filter
eq_elems = [f"{key} = {val}" for val in vals]
return f"({' or '.join(eq_elems)})"
or_clause = " or ".join(eq_elems)
result = f"({or_clause}) and "
return result
def _build_kg_filter(
kg_entities: list[str] | None,
@@ -62,12 +73,16 @@ def build_vespa_filters(
combined_filter_parts = []
def _build_kge(entity: str) -> str:
# TYPE-SUBTYPE::ID -> "TYPE-SUBTYPE::ID"
# TYPE-SUBTYPE::* -> ({prefix: true}"TYPE-SUBTYPE")
# TYPE::* -> ({prefix: true}"TYPE")
GENERAL = "::*"
if entity.endswith(GENERAL):
return f'({{prefix: true}}"{entity.split(GENERAL, 1)[0]}")'
else:
return f'"{entity}"'
# OR the entities (give new design)
if kg_entities:
filter_parts = []
for kg_entity in kg_entities:
@@ -89,7 +104,8 @@ def build_vespa_filters(
# TODO: remove kg terms entirely from prompts and codebase
return f"({' and '.join(combined_filter_parts)})"
# AND the combined filter parts
return f"({' and '.join(combined_filter_parts)}) and "
def _build_kg_source_filters(
kg_sources: list[str] | None,
@@ -98,14 +114,16 @@ def build_vespa_filters(
return ""
source_phrases = [f'{DOCUMENT_ID} contains "{source}"' for source in kg_sources]
return f"({' or '.join(source_phrases)})"
return f"({' or '.join(source_phrases)}) and "
def _build_kg_chunk_id_zero_only_filter(
kg_chunk_id_zero_only: bool,
) -> str:
if not kg_chunk_id_zero_only:
return ""
return "(chunk_id = 0)"
return "(chunk_id = 0 ) and "
def _build_time_filter(
cutoff: datetime | None,
@@ -117,8 +135,8 @@ def build_vespa_filters(
cutoff_secs = int(cutoff.timestamp())
if include_untimed:
return f"!({DOC_UPDATED_AT} < {cutoff_secs})"
return f"({DOC_UPDATED_AT} >= {cutoff_secs})"
return f"!({DOC_UPDATED_AT} < {cutoff_secs}) and "
return f"({DOC_UPDATED_AT} >= {cutoff_secs}) and "
def _build_user_project_filter(
project_id: int | None,
@@ -129,7 +147,8 @@ def build_vespa_filters(
pid = int(project_id)
except Exception:
return ""
return f'({USER_PROJECT} contains "{pid}")'
# Vespa YQL 'contains' expects a string literal; quote the integer
return f'({USER_PROJECT} contains "{pid}") and '
def _build_persona_filter(
persona_id: int | None,
@@ -141,94 +160,73 @@ def build_vespa_filters(
except Exception:
logger.warning(f"Invalid persona ID: {persona_id}")
return ""
return f'({PERSONAS} contains "{pid}")'
return f'({PERSONAS} contains "{pid}") and '
def _append(parts: list[str], clause: str) -> None:
if clause:
parts.append(clause)
# Collect all top-level filter clauses, then join with " and " at the end.
filter_parts: list[str] = []
if not include_hidden:
filter_parts.append(f"!({HIDDEN}=true)")
# Start building the filter string
filter_str = f"!({HIDDEN}=true) and " if not include_hidden else ""
# TODO: add error condition if MULTI_TENANT and no tenant_id filter is set
# If running in multi-tenant mode
if filters.tenant_id and MULTI_TENANT:
filter_parts.append(build_tenant_id_filter(filters.tenant_id))
filter_str += build_tenant_id_filter(
filters.tenant_id, include_trailing_and=True
)
# ACL filters
if filters.access_control_list is not None:
_append(
filter_parts,
_build_or_filters(ACCESS_CONTROL_LIST, filters.access_control_list),
filter_str += _build_or_filters(
ACCESS_CONTROL_LIST, filters.access_control_list
)
# Source type filters
source_strs = (
[s.value for s in filters.source_type] if filters.source_type else None
)
_append(filter_parts, _build_or_filters(SOURCE_TYPE, source_strs))
filter_str += _build_or_filters(SOURCE_TYPE, source_strs)
# Tag filters
tag_attributes = None
if filters.tags:
# build e.g. "tag_key|tag_value"
tag_attributes = [
f"{tag.tag_key}{INDEX_SEPARATOR}{tag.tag_value}" for tag in filters.tags
]
_append(filter_parts, _build_or_filters(METADATA_LIST, tag_attributes))
filter_str += _build_or_filters(METADATA_LIST, tag_attributes)
# Knowledge scope: explicit knowledge attachments (document_sets,
# user_file_ids) restrict what an assistant can see. When none are
# set, the assistant can see everything.
#
# project_id / persona_id are additive: they make overflowing user
# files findable in Vespa but must NOT trigger the restriction on
# their own (an agent with no explicit knowledge should search
# everything).
knowledge_scope_parts: list[str] = []
_append(
knowledge_scope_parts, _build_or_filters(DOCUMENT_SETS, filters.document_set)
)
# Document sets
filter_str += _build_or_filters(DOCUMENT_SETS, filters.document_set)
# Convert UUIDs to strings for user_file_ids
user_file_ids_str = (
[str(uuid) for uuid in filters.user_file_ids] if filters.user_file_ids else None
)
_append(knowledge_scope_parts, _build_or_filters(DOCUMENT_ID, user_file_ids_str))
filter_str += _build_or_filters(DOCUMENT_ID, user_file_ids_str)
# Only include project/persona scopes when an explicit knowledge
# restriction is already in effect — they widen the scope to also
# cover overflowing user files but never restrict on their own.
if knowledge_scope_parts:
_append(knowledge_scope_parts, _build_user_project_filter(filters.project_id))
_append(knowledge_scope_parts, _build_persona_filter(filters.persona_id))
# User project filter (array<int> attribute membership)
filter_str += _build_user_project_filter(filters.project_id)
if len(knowledge_scope_parts) > 1:
filter_parts.append("(" + " or ".join(knowledge_scope_parts) + ")")
elif len(knowledge_scope_parts) == 1:
filter_parts.append(knowledge_scope_parts[0])
# Persona filter (array<int> attribute membership)
filter_str += _build_persona_filter(filters.persona_id)
# Time filter
_append(filter_parts, _build_time_filter(filters.time_cutoff))
filter_str += _build_time_filter(filters.time_cutoff)
# # Knowledge Graph Filters
# _append(filter_parts, _build_kg_filter(
# filter_str += _build_kg_filter(
# kg_entities=filters.kg_entities,
# kg_relationships=filters.kg_relationships,
# kg_terms=filters.kg_terms,
# ))
# )
# _append(filter_parts, _build_kg_source_filters(filters.kg_sources))
# filter_str += _build_kg_source_filters(filters.kg_sources)
# _append(filter_parts, _build_kg_chunk_id_zero_only_filter(
# filter_str += _build_kg_chunk_id_zero_only_filter(
# filters.kg_chunk_id_zero_only or False
# ))
# )
filter_str = " and ".join(filter_parts)
if filter_str and not remove_trailing_and:
filter_str += " and "
# Trim trailing " and "
if remove_trailing_and and filter_str.endswith(" and "):
filter_str = filter_str[:-5]
return filter_str

View File

@@ -1512,10 +1512,6 @@
"display_name": "Claude Opus 4.5",
"model_vendor": "anthropic"
},
"claude-opus-4-6": {
"display_name": "Claude Opus 4.6",
"model_vendor": "anthropic"
},
"claude-opus-4-5-20251101": {
"display_name": "Claude Opus 4.5",
"model_vendor": "anthropic",
@@ -1530,10 +1526,6 @@
"display_name": "Claude Sonnet 4.5",
"model_vendor": "anthropic"
},
"claude-sonnet-4-6": {
"display_name": "Claude Sonnet 4.6",
"model_vendor": "anthropic"
},
"claude-sonnet-4-5-20250929": {
"display_name": "Claude Sonnet 4.5",
"model_vendor": "anthropic",

View File

@@ -1,8 +1,37 @@
from onyx.llm.constants import LlmProviderNames
OPENAI_PROVIDER_NAME = "openai"
# Curated list of OpenAI models to show by default in the UI
OPENAI_VISIBLE_MODEL_NAMES = {
"gpt-5",
"gpt-5-mini",
"o1",
"o3-mini",
"gpt-4o",
"gpt-4o-mini",
}
BEDROCK_PROVIDER_NAME = "bedrock"
BEDROCK_DEFAULT_MODEL = "anthropic.claude-3-5-sonnet-20241022-v2:0"
def _fallback_bedrock_regions() -> list[str]:
# Fall back to a conservative set of well-known Bedrock regions if boto3 data isn't available.
return [
"us-east-1",
"us-east-2",
"us-gov-east-1",
"us-gov-west-1",
"us-west-2",
"ap-northeast-1",
"ap-south-1",
"ap-southeast-1",
"ap-southeast-2",
"ap-east-1",
"ca-central-1",
"eu-central-1",
"eu-west-2",
]
OLLAMA_PROVIDER_NAME = "ollama_chat"
@@ -22,6 +51,13 @@ OPENROUTER_PROVIDER_NAME = "openrouter"
ANTHROPIC_PROVIDER_NAME = "anthropic"
# Curated list of Anthropic models to show by default in the UI
ANTHROPIC_VISIBLE_MODEL_NAMES = {
"claude-opus-4-5",
"claude-sonnet-4-5",
"claude-haiku-4-5",
}
AZURE_PROVIDER_NAME = "azure"
@@ -29,6 +65,13 @@ VERTEXAI_PROVIDER_NAME = "vertex_ai"
VERTEX_CREDENTIALS_FILE_KWARG = "vertex_credentials"
VERTEX_CREDENTIALS_FILE_KWARG_ENV_VAR_FORMAT = "CREDENTIALS_FILE"
VERTEX_LOCATION_KWARG = "vertex_location"
VERTEXAI_DEFAULT_MODEL = "gemini-2.5-flash"
# Curated list of Vertex AI models to show by default in the UI
VERTEXAI_VISIBLE_MODEL_NAMES = {
"gemini-2.5-flash",
"gemini-2.5-flash-lite",
"gemini-2.5-pro",
}
AWS_REGION_NAME_KWARG = "aws_region_name"
AWS_REGION_NAME_KWARG_ENV_VAR_FORMAT = "AWS_REGION_NAME"

View File

@@ -16,10 +16,6 @@
"name": "claude-opus-4-6",
"display_name": "Claude Opus 4.6"
},
{
"name": "claude-sonnet-4-6",
"display_name": "Claude Sonnet 4.6"
},
{
"name": "claude-opus-4-5",
"display_name": "Claude Opus 4.5"

View File

@@ -6758,12 +6758,12 @@
}
},
"node_modules/express-rate-limit": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.3.0.tgz",
"integrity": "sha512-KJzBawY6fB9FiZGdE/0aftepZ91YlaGIrV8vgblRM3J8X+dHx/aiowJWwkx6LIGyuqGiANsjSwwrbb8mifOJ4Q==",
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz",
"integrity": "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==",
"license": "MIT",
"dependencies": {
"ip-address": "10.1.0"
"ip-address": "10.0.1"
},
"engines": {
"node": ">= 16"
@@ -7556,9 +7556,9 @@
}
},
"node_modules/ip-address": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.0.1.tgz",
"integrity": "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==",
"license": "MIT",
"engines": {
"node": ">= 12"

View File

@@ -20,6 +20,8 @@ from onyx.document_index.vespa_constants import TENANT_ID
from onyx.document_index.vespa_constants import USER_PROJECT
from shared_configs.configs import MULTI_TENANT
# Import the function under test
class TestBuildVespaFilters:
def test_empty_filters(self) -> None:
@@ -177,27 +179,11 @@ class TestBuildVespaFilters:
assert f"!({HIDDEN}=true) and " == result
def test_user_project_filter(self) -> None:
"""Test user project filtering.
project_id alone does NOT trigger a knowledge scope restriction
(an agent with no explicit knowledge should search everything).
It only participates when explicit knowledge filters are present.
"""
# project_id alone → no restriction
"""Test user project filtering (replacement for user folder IDs)."""
# Single project id
filters = IndexFilters(access_control_list=[], project_id=789)
result = build_vespa_filters(filters)
assert f"!({HIDDEN}=true) and " == result
# project_id with user_file_ids → both OR'd
id1 = UUID("00000000-0000-0000-0000-000000000123")
filters = IndexFilters(
access_control_list=[], project_id=789, user_file_ids=[id1]
)
result = build_vespa_filters(filters)
assert (
f'!({HIDDEN}=true) and (({DOCUMENT_ID} contains "{str(id1)}") or ({USER_PROJECT} contains "789")) and '
== result
)
assert f'!({HIDDEN}=true) and ({USER_PROJECT} contains "789") and ' == result
# No project id
filters = IndexFilters(access_control_list=[], project_id=None)
@@ -231,11 +217,7 @@ class TestBuildVespaFilters:
)
def test_combined_filters(self) -> None:
"""Test combining multiple filter types.
Knowledge-scope filters (document_set, user_file_ids, project_id,
persona_id) are OR'd together, while all other filters are AND'd.
"""
"""Test combining multiple filter types."""
id1 = UUID("00000000-0000-0000-0000-000000000123")
filters = IndexFilters(
access_control_list=["user1", "group1"],
@@ -249,6 +231,7 @@ class TestBuildVespaFilters:
result = build_vespa_filters(filters)
# Build expected result piece by piece for readability
expected = f"!({HIDDEN}=true) and "
expected += (
'(access_control_list contains "user1" or '
@@ -256,13 +239,9 @@ class TestBuildVespaFilters:
)
expected += f'({SOURCE_TYPE} contains "web") and '
expected += f'({METADATA_LIST} contains "color{INDEX_SEPARATOR}red") and '
# Knowledge scope filters are OR'd together
expected += (
f'(({DOCUMENT_SETS} contains "set1")'
f' or ({DOCUMENT_ID} contains "{str(id1)}")'
f' or ({USER_PROJECT} contains "789")'
f") and "
)
expected += f'({DOCUMENT_SETS} contains "set1") and '
expected += f'({DOCUMENT_ID} contains "{str(id1)}") and '
expected += f'({USER_PROJECT} contains "789") and '
cutoff_secs = int(datetime(2023, 1, 1, tzinfo=timezone.utc).timestamp())
expected += f"!({DOC_UPDATED_AT} < {cutoff_secs}) and "
@@ -272,32 +251,6 @@ class TestBuildVespaFilters:
result_no_trailing = build_vespa_filters(filters, remove_trailing_and=True)
assert expected[:-5] == result_no_trailing # Remove trailing " and "
def test_knowledge_scope_single_filter_not_wrapped(self) -> None:
"""When only one knowledge-scope filter is present it should not
be wrapped in an extra OR group."""
filters = IndexFilters(access_control_list=[], document_set=["set1"])
result = build_vespa_filters(filters)
assert f'!({HIDDEN}=true) and ({DOCUMENT_SETS} contains "set1") and ' == result
def test_knowledge_scope_document_set_and_user_files_ored(self) -> None:
"""Document set filter and user file IDs must be OR'd so that
connector documents (in the set) and user files (with specific
IDs) can both be found."""
id1 = UUID("00000000-0000-0000-0000-000000000123")
filters = IndexFilters(
access_control_list=[],
document_set=["engineering"],
user_file_ids=[id1],
)
result = build_vespa_filters(filters)
expected = (
f"!({HIDDEN}=true) and "
f'(({DOCUMENT_SETS} contains "engineering")'
f' or ({DOCUMENT_ID} contains "{str(id1)}")'
f") and "
)
assert expected == result
def test_empty_or_none_values(self) -> None:
"""Test with empty or None values in filter lists."""
# Empty strings in document set

View File

@@ -1,8 +1,5 @@
# Onyx CLI
[![Release CLI](https://github.com/onyx-dot-app/onyx/actions/workflows/release-cli.yml/badge.svg)](https://github.com/onyx-dot-app/onyx/actions/workflows/release-cli.yml)
[![PyPI](https://img.shields.io/pypi/v/onyx-cli.svg)](https://pypi.org/project/onyx-cli/)
A terminal interface for chatting with your [Onyx](https://github.com/onyx-dot-app/onyx) agent. Built with Go using [Bubble Tea](https://github.com/charmbracelet/bubbletea) for the TUI framework.
## Installation
@@ -31,7 +28,7 @@ Environment variables override config file values:
| Variable | Required | Description |
|----------|----------|-------------|
| `ONYX_SERVER_URL` | No | Server base URL (default: `https://cloud.onyx.app`) |
| `ONYX_SERVER_URL` | No | Server base URL (default: `http://localhost:3000`) |
| `ONYX_API_KEY` | Yes | API key for authentication |
| `ONYX_PERSONA_ID` | No | Default agent/persona ID |
@@ -71,17 +68,17 @@ onyx-cli agents --json
| `ask` | Ask a one-shot question (non-interactive) |
| `agents` | List available agents |
| `configure` | Configure server URL and API key |
| `validate-config` | Validate configuration and test connection |
## Slash Commands (in TUI)
| Command | Description |
|---------|-------------|
| `/help` | Show help message |
| `/clear` | Clear chat and start a new session |
| `/new` | Start a new chat session |
| `/agent` | List and switch agents |
| `/attach <path>` | Attach a file to next message |
| `/sessions` | List recent chat sessions |
| `/clear` | Clear the chat display |
| `/configure` | Re-run connection setup |
| `/connectors` | Open connectors in browser |
| `/settings` | Open settings in browser |
@@ -119,43 +116,3 @@ go build -o onyx-cli .
# Lint
staticcheck ./...
```
## Publishing to PyPI
The CLI is distributed as a Python package via [PyPI](https://pypi.org/project/onyx-cli/). The build system uses [hatchling](https://hatch.pypa.io/) with [manygo](https://github.com/nicholasgasior/manygo) to cross-compile Go binaries into platform-specific wheels.
### CI release (recommended)
Tag a release and push — the `release-cli.yml` workflow builds wheels for all platforms and publishes to PyPI automatically:
```shell
tag --prefix cli
```
To do this manually:
```shell
git tag cli/v0.1.0
git push origin cli/v0.1.0
```
The workflow builds wheels for: linux/amd64, linux/arm64, darwin/amd64, darwin/arm64, windows/amd64, windows/arm64.
### Manual release
Build a wheel locally with `uv`. Set `GOOS` and `GOARCH` to cross-compile for other platforms (Go handles this natively — no cross-compiler needed):
```shell
# Build for current platform
uv build --wheel
# Cross-compile for a different platform
GOOS=linux GOARCH=amd64 uv build --wheel
# Upload to PyPI
uv publish
```
### Versioning
Versions are derived from git tags with the `cli/` prefix (e.g. `cli/v0.1.0`). The tag is parsed by `internal/_version.py` and injected into the Go binary via `-ldflags` at build time.

View File

@@ -1,43 +0,0 @@
from __future__ import annotations
import os
import subprocess
from typing import Any
import manygo
from hatchling.builders.hooks.plugin.interface import BuildHookInterface
class CustomBuildHook(BuildHookInterface):
"""Build hook to compile the Go binary and include it in the wheel."""
def initialize(self, version: Any, build_data: Any) -> None: # noqa: ARG002
"""Build the Go binary before packaging."""
build_data["pure_python"] = False
# Set platform tag for cross-compilation
goos = os.getenv("GOOS")
goarch = os.getenv("GOARCH")
if manygo.is_goos(goos) and manygo.is_goarch(goarch):
build_data["tag"] = "py3-none-" + manygo.get_platform_tag(
goos=goos,
goarch=goarch,
)
# Get config and environment
binary_name = self.config["binary_name"]
tag_prefix = self.config.get("tag_prefix", binary_name)
tag = os.getenv("GITHUB_REF_NAME", "dev").removeprefix(f"{tag_prefix}/")
commit = os.getenv("GITHUB_SHA", "none")
# Build the Go binary if it doesn't exist
# Build the Go binary (always rebuild to ensure correct version injection)
if not os.path.exists(binary_name):
print(f"Building Go binary '{binary_name}'...")
pkg = "github.com/onyx-dot-app/onyx/cli/cmd"
ldflags = f"-X {pkg}.version={tag}" f" -X {pkg}.commit={commit}" " -s -w"
subprocess.check_call( # noqa: S603
["go", "build", f"-ldflags={ldflags}", "-o", binary_name],
)
build_data["shared_scripts"] = {binary_name: binary_name}

View File

@@ -1,11 +0,0 @@
from __future__ import annotations
import os
import re
# Must match tag_prefix in pyproject.toml [tool.hatch.build.targets.wheel.hooks.custom]
TAG_PREFIX = "cli"
_tag = os.environ.get("GITHUB_REF_NAME", "v0.0.0-dev").removeprefix(f"{TAG_PREFIX}/")
_match = re.search(r"v?(\d+\.\d+\.\d+)", _tag)
__version__ = _match.group(1) if _match else "0.0.0"

View File

@@ -1,39 +0,0 @@
[build-system]
requires = ["hatchling", "go-bin~=1.24.11", "manygo"]
build-backend = "hatchling.build"
[project]
name = "onyx-cli"
readme = "README.md"
description = "Terminal interface for chatting with your Onyx agent"
authors = [{ name = "Onyx AI", email = "founders@onyx.app" }]
requires-python = ">=3.9"
keywords = [
"onyx", "cli", "chat", "ai", "enterprise-search",
]
classifiers = [
"Programming Language :: Go",
"License :: OSI Approved :: MIT License",
"Operating System :: POSIX :: Linux",
"Operating System :: MacOS",
"Operating System :: Microsoft :: Windows",
]
dynamic = ["version"]
[project.urls]
Repository = "https://github.com/onyx-dot-app/onyx"
[tool.hatch.build]
include = ["go.mod", "go.sum", "main.go", "**/*.go", "**/*.py", "README.md"]
[tool.hatch.version]
source = "code"
path = "internal/_version.py"
[tool.hatch.build.targets.wheel.hooks.custom]
path = "hatch_build.py"
binary_name = "onyx-cli"
tag_prefix = "cli"
[tool.uv]
managed = false

View File

@@ -26,16 +26,20 @@ class CustomBuildHook(BuildHookInterface):
# Get config and environment
binary_name = self.config["binary_name"]
tag_prefix = self.config.get("tag_prefix", binary_name)
tag = os.getenv("GITHUB_REF_NAME", "dev").removeprefix(f"{tag_prefix}/")
tag = os.getenv("GITHUB_REF_NAME", "dev").removeprefix(f"{binary_name}/")
commit = os.getenv("GITHUB_SHA", "none")
# Build the Go binary if it doesn't exist
if not os.path.exists(binary_name):
print(f"Building Go binary '{binary_name}'...")
ldflags = f"-X main.version={tag} -X main.commit={commit} -s -w"
subprocess.check_call( # noqa: S603
["go", "build", f"-ldflags={ldflags}", "-o", binary_name],
[
"go",
"build",
f"-ldflags=-X main.version={tag} -X main.commit={commit} -s -w",
"-o",
binary_name,
],
)
build_data["shared_scripts"] = {binary_name: binary_name}

View File

@@ -3,9 +3,6 @@ from __future__ import annotations
import os
import re
# Must match tag_prefix in pyproject.toml [tool.hatch.build.targets.wheel.hooks.custom]
TAG_PREFIX: str = "ods"
_tag = os.environ.get("GITHUB_REF_NAME", "v0.0.0-dev").removeprefix(f"{TAG_PREFIX}/")
_tag = os.environ.get("GITHUB_REF_NAME", "v0.0.0-dev").removeprefix("ods/")
_match = re.search(r"v?(\d+\.\d+\.\d+)", _tag)
__version__ = _match.group(1) if _match else "0.0.0"

View File

@@ -14,9 +14,7 @@ keywords = [
classifiers = [
"Programming Language :: Go",
"License :: OSI Approved :: MIT License",
"Operating System :: POSIX :: Linux",
"Operating System :: MacOS",
"Operating System :: Microsoft :: Windows",
"Operating System :: OS Independent",
]
dynamic = ["version"]
dependencies = [
@@ -29,7 +27,7 @@ dependencies = [
Repository = "https://github.com/onyx-dot-app/onyx"
[tool.hatch.build]
include = ["go.mod", "go.sum", "main.go", "**/*.go", "**/*.py", "README.md"]
include = ["go.mod", "go.sum", "main.go", "**/*.go", "**/*.py"]
[tool.hatch.version]
source = "code"
@@ -38,7 +36,6 @@ path = "internal/_version.py"
[tool.hatch.build.targets.wheel.hooks.custom]
path = "hatch_build.py"
binary_name = "ods"
tag_prefix = "ods"
[tool.uv]
managed = false

View File

@@ -39,7 +39,7 @@ type ButtonProps = InteractiveStatelessProps &
/** Tooltip text shown on hover. */
tooltip?: string;
/** Width preset. `"auto"` shrink-wraps, `"full"` stretches to parent width. */
/** Width preset. `"fit"` shrink-wraps, `"full"` stretches to parent width. */
width?: WidthVariant;
/** Which side the tooltip appears on. */

View File

@@ -0,0 +1,83 @@
# LineItemButton
**Import:** `import { LineItemButton, type LineItemButtonProps } from "@opal/components";`
A composite component that wraps `Interactive.Stateful > Interactive.Container > ContentAction` into a single API. Use it for selectable list rows such as model pickers, menu items, or any row that acts like a button.
## Architecture
```
Interactive.Stateful <- selectVariant, state, interaction, onClick, href, ref
└─ Interactive.Container <- type, width, roundingVariant
└─ ContentAction <- withInteractive, paddingVariant="lg"
├─ Content <- icon, title, description, sizePreset, variant, ...
└─ rightChildren
```
`paddingVariant` is hardcoded to `"lg"` and `withInteractive` is always `true`. These are not exposed as props.
## Props
### Interactive surface
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `selectVariant` | `"select-light" \| "select-heavy"` | `"select-light"` | Interactive select variant |
| `state` | `InteractiveStatefulState` | `"empty"` | Value state (`"empty"`, `"filled"`, `"selected"`) |
| `interaction` | `InteractiveStatefulInteraction` | `"rest"` | JS-controlled interaction state override |
| `onClick` | `MouseEventHandler<HTMLElement>` | — | Click handler |
| `href` | `string` | — | Renders an anchor instead of a div |
| `target` | `string` | — | Anchor target (e.g. `"_blank"`) |
| `group` | `string` | — | Interactive group key |
| `ref` | `React.Ref<HTMLElement>` | — | Forwarded ref |
### Sizing
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `roundingVariant` | `InteractiveContainerRoundingVariant` | `"default"` | Corner rounding preset (height is content-driven) |
| `width` | `WidthVariant` | `"full"` | Container width |
| `type` | `"submit" \| "button" \| "reset"` | `"button"` | HTML button type |
| `tooltip` | `string` | — | Tooltip text shown on hover |
| `tooltipSide` | `TooltipSide` | `"top"` | Tooltip side |
### Content (pass-through to ContentAction)
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `title` | `string` | **(required)** | Row label |
| `icon` | `IconFunctionComponent` | — | Left icon |
| `description` | `string` | — | Description below the title |
| `sizePreset` | `SizePreset` | `"headline"` | Content size preset |
| `variant` | `ContentVariant` | `"heading"` | Content layout variant |
| `rightChildren` | `ReactNode` | — | Content after the label (e.g. action button) |
All other `ContentAction` / `Content` props (`editable`, `onTitleChange`, `optional`, `auxIcon`, `tag`, etc.) are also passed through. Note: `withInteractive` is always `true` inside `LineItemButton` and cannot be overridden.
## Usage
```tsx
import { LineItemButton } from "@opal/components";
// Simple selectable row
<LineItemButton
selectVariant="select-heavy"
state={isSelected ? "selected" : "empty"}
roundingVariant="compact"
onClick={handleClick}
title="gpt-4o"
sizePreset="main-ui"
variant="section"
/>
// With right-side action
<LineItemButton
selectVariant="select-heavy"
state={isSelected ? "selected" : "empty"}
onClick={handleClick}
title="claude-opus-4"
sizePreset="main-ui"
variant="section"
rightChildren={<Tag title="Default" color="blue" />}
/>
```

View File

@@ -0,0 +1,137 @@
import "@opal/components/tooltip.css";
import {
Interactive,
type InteractiveStatefulState,
type InteractiveStatefulInteraction,
type InteractiveStatefulProps,
InteractiveContainerRoundingVariant,
} from "@opal/core";
import { type WidthVariant } from "@opal/shared";
import type { TooltipSide } from "@opal/components";
import type { DistributiveOmit } from "@opal/types";
import type { ContentActionProps } from "@opal/layouts/content-action/components";
import { ContentAction } from "@opal/layouts";
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type ContentPassthroughProps = DistributiveOmit<
ContentActionProps,
"paddingVariant" | "widthVariant" | "ref" | "withInteractive"
>;
type LineItemButtonOwnProps = {
/** Interactive select variant. @default "select-light" */
selectVariant?: "select-light" | "select-heavy";
/** Value state. @default "empty" */
state?: InteractiveStatefulState;
/** JS-controllable interaction state override. @default "rest" */
interaction?: InteractiveStatefulInteraction;
/** Click handler. */
onClick?: InteractiveStatefulProps["onClick"];
/** When provided, renders an anchor instead of a div. */
href?: string;
/** Anchor target (e.g. "_blank"). */
target?: string;
/** Interactive group key. */
group?: string;
/** Forwarded ref. */
ref?: React.Ref<HTMLElement>;
/** Corner rounding preset (height is always content-driven). @default "default" */
roundingVariant?: InteractiveContainerRoundingVariant;
/** Container width. @default "full" */
width?: WidthVariant;
/** HTML button type. @default "button" */
type?: "submit" | "button" | "reset";
/** Tooltip text shown on hover. */
tooltip?: string;
/** Which side the tooltip appears on. @default "top" */
tooltipSide?: TooltipSide;
};
type LineItemButtonProps = ContentPassthroughProps & LineItemButtonOwnProps;
// ---------------------------------------------------------------------------
// LineItemButton
// ---------------------------------------------------------------------------
function LineItemButton({
// Interactive surface
selectVariant = "select-light",
state,
interaction,
onClick,
href,
target,
group,
ref,
// Sizing
roundingVariant = "default",
width = "full",
type = "button",
tooltip,
tooltipSide = "top",
// ContentAction pass-through
...contentActionProps
}: LineItemButtonProps) {
const item = (
<Interactive.Stateful
variant={selectVariant}
state={state}
interaction={interaction}
onClick={onClick}
href={href}
target={target}
group={group}
ref={ref}
>
<Interactive.Container
type={type}
widthVariant={width}
heightVariant="fit"
roundingVariant={roundingVariant}
>
<ContentAction
{...(contentActionProps as ContentActionProps)}
withInteractive
paddingVariant="lg"
/>
</Interactive.Container>
</Interactive.Stateful>
);
if (!tooltip) return item;
return (
<TooltipPrimitive.Root>
<TooltipPrimitive.Trigger asChild>{item}</TooltipPrimitive.Trigger>
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
className="opal-tooltip"
side={tooltipSide}
sideOffset={4}
>
{tooltip}
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
</TooltipPrimitive.Root>
);
}
export { LineItemButton, type LineItemButtonProps };

View File

@@ -56,7 +56,7 @@ type SelectButtonProps = InteractiveStatefulProps &
/** Tooltip text shown on hover. */
tooltip?: string;
/** Width preset. `"auto"` shrink-wraps, `"full"` stretches to parent width. */
/** Width preset. `"fit"` shrink-wraps, `"full"` stretches to parent width. */
width?: WidthVariant;
/** Which side the tooltip appears on. */

View File

@@ -19,6 +19,12 @@ export {
type OpenButtonProps,
} from "@opal/components/buttons/open-button/components";
/* LineItemButton */
export {
LineItemButton,
type LineItemButtonProps,
} from "@opal/components/buttons/line-item-button/components";
/* Tag */
export {
Tag,

View File

@@ -2,6 +2,7 @@ import "@opal/core/animations/styles.css";
import React, { createContext, useContext, useState, useCallback } from "react";
import { cn } from "@opal/utils";
import type { WithoutStyles } from "@opal/types";
import { widthVariants, type WidthVariant } from "@opal/shared";
// ---------------------------------------------------------------------------
// Context-per-group registry
@@ -38,6 +39,10 @@ interface HoverableRootProps
extends WithoutStyles<React.HTMLAttributes<HTMLDivElement>> {
children: React.ReactNode;
group: string;
/** Width preset. @default "fit" */
widthVariant?: WidthVariant;
/** Ref forwarded to the root `<div>`. */
ref?: React.Ref<HTMLDivElement>;
}
type HoverableItemVariant = "opacity-on-hover";
@@ -47,6 +52,8 @@ interface HoverableItemProps
children: React.ReactNode;
group?: string;
variant?: HoverableItemVariant;
/** Ref forwarded to the item `<div>`. */
ref?: React.Ref<HTMLDivElement>;
}
// ---------------------------------------------------------------------------
@@ -77,6 +84,8 @@ interface HoverableItemProps
function HoverableRoot({
group,
children,
widthVariant = "fit",
ref,
onMouseEnter: consumerMouseEnter,
onMouseLeave: consumerMouseLeave,
...props
@@ -103,7 +112,15 @@ function HoverableRoot({
return (
<GroupContext.Provider value={hovered}>
<div {...props} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}>
<div
{...props}
ref={ref}
className={
widthVariant !== "fit" ? cn(widthVariants[widthVariant]) : undefined
}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
{children}
</div>
</GroupContext.Provider>
@@ -147,6 +164,7 @@ function HoverableItem({
group,
variant = "opacity-on-hover",
children,
ref,
...props
}: HoverableItemProps) {
const contextValue = useContext(
@@ -165,6 +183,7 @@ function HoverableItem({
return (
<div
{...props}
ref={ref}
className={cn("hoverable-item")}
data-hoverable-variant={variant}
data-hoverable-active={

View File

@@ -10,7 +10,7 @@ Structural container shared by both `Interactive.Stateless` and `Interactive.Sta
|------|------|---------|-------------|
| `heightVariant` | `SizeVariant` | `"lg"` | Height preset (`2xs``lg`, `fit`) |
| `roundingVariant` | `"default" \| "compact" \| "mini"` | `"default"` | Border-radius preset |
| `widthVariant` | `WidthVariant` | — | Width preset (`auto`, `full`) |
| `widthVariant` | `WidthVariant` | — | Width preset (`"fit"`, `"full"`) |
| `border` | `boolean` | `false` | Renders a 1px border |
| `type` | `"submit" \| "button" \| "reset"` | — | When set, renders a `<button>` element |

View File

@@ -78,7 +78,7 @@ interface InteractiveContainerProps
/**
* Width preset controlling the container's horizontal size.
*
* @default "auto"
* @default "fit"
*/
widthVariant?: WidthVariant;
}
@@ -101,7 +101,7 @@ function InteractiveContainer({
border,
roundingVariant = "default",
heightVariant = "lg",
widthVariant = "auto",
widthVariant = "fit",
...props
}: InteractiveContainerProps) {
const { allowClick } = useDisabled();

View File

@@ -59,8 +59,8 @@
--------------------------------------------------------------------------- */
.interactive[data-interactive-variant="select-heavy"][data-interactive-state="filled"] {
@apply bg-transparent;
--interactive-foreground: var(--text-04);
--interactive-foreground-icon: var(--text-04);
--interactive-foreground: var(--action-link-05);
--interactive-foreground-icon: var(--action-link-05);
}
.interactive[data-interactive-variant="select-heavy"][data-interactive-state="filled"]:hover:not(
[data-disabled]
@@ -76,9 +76,7 @@
.interactive[data-interactive-variant="select-heavy"][data-interactive-state="filled"][data-interaction="active"]:not(
[data-disabled]
) {
@apply bg-background-neutral-00;
--interactive-foreground: var(--text-05);
--interactive-foreground-icon: var(--text-05);
@apply bg-background-tint-00;
}
.interactive[data-interactive-variant="select-heavy"][data-interactive-state="filled"][data-disabled] {
@apply bg-transparent;
@@ -158,8 +156,8 @@
--------------------------------------------------------------------------- */
.interactive[data-interactive-variant="select-light"][data-interactive-state="filled"] {
@apply bg-transparent;
--interactive-foreground: var(--text-04);
--interactive-foreground-icon: var(--text-04);
--interactive-foreground: var(--action-link-05);
--interactive-foreground-icon: var(--action-link-05);
}
.interactive[data-interactive-variant="select-light"][data-interactive-state="filled"]:hover:not(
[data-disabled]
@@ -175,9 +173,7 @@
.interactive[data-interactive-variant="select-light"][data-interactive-state="filled"][data-interaction="active"]:not(
[data-disabled]
) {
@apply bg-background-neutral-00;
--interactive-foreground: var(--text-05);
--interactive-foreground-icon: var(--text-05);
@apply bg-background-tint-00;
}
.interactive[data-interactive-variant="select-light"][data-interactive-state="filled"][data-disabled] {
@apply bg-transparent;

View File

@@ -40,6 +40,9 @@ interface BodyLayoutProps {
/** Title prominence. Default: `"default"`. */
prominence?: BodyProminence;
/** Ref forwarded to the root `<div>`. */
ref?: React.Ref<HTMLDivElement>;
}
// ---------------------------------------------------------------------------
@@ -80,6 +83,7 @@ function BodyLayout({
sizePreset = "main-ui",
orientation = "inline",
prominence = "default",
ref,
}: BodyLayoutProps) {
const config = BODY_PRESETS[sizePreset];
const titleColorClass =
@@ -87,6 +91,7 @@ function BodyLayout({
return (
<div
ref={ref}
className="opal-content-body"
data-orientation={orientation}
style={{ gap: config.gap }}

View File

@@ -48,6 +48,12 @@ interface ContentLgProps {
/** Size preset. Default: `"headline"`. */
sizePreset?: ContentLgSizePreset;
/** When `true`, the title color hooks into `Interactive`'s `--interactive-foreground` variable. */
withInteractive?: boolean;
/** Ref forwarded to the root `<div>`. */
ref?: React.Ref<HTMLDivElement>;
}
// ---------------------------------------------------------------------------
@@ -86,6 +92,8 @@ function ContentLg({
description,
editable,
onTitleChange,
withInteractive,
ref,
}: ContentLgProps) {
const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState(title);
@@ -104,7 +112,12 @@ function ContentLg({
}
return (
<div className="opal-content-lg" style={{ gap: config.gap }}>
<div
ref={ref}
className="opal-content-lg"
data-interactive={withInteractive || undefined}
style={{ gap: config.gap }}
>
{Icon && (
<div
className={cn(

View File

@@ -61,6 +61,12 @@ interface ContentMdProps {
/** Size preset. Default: `"main-ui"`. */
sizePreset?: ContentMdSizePreset;
/** When `true`, the title color hooks into `Interactive`'s `--interactive-foreground` variable. */
withInteractive?: boolean;
/** Ref forwarded to the root `<div>`. */
ref?: React.Ref<HTMLDivElement>;
}
// ---------------------------------------------------------------------------
@@ -130,6 +136,8 @@ function ContentMd({
auxIcon,
tag,
sizePreset = "main-ui",
withInteractive,
ref,
}: ContentMdProps) {
const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState(title);
@@ -149,7 +157,12 @@ function ContentMd({
}
return (
<div className="opal-content-md" style={{ gap: config.gap }}>
<div
ref={ref}
className="opal-content-md"
data-interactive={withInteractive || undefined}
style={{ gap: config.gap }}
>
{Icon && (
<div
className={cn(

View File

@@ -40,6 +40,12 @@ interface ContentSmProps {
/** Title prominence. Default: `"default"`. */
prominence?: ContentSmProminence;
/** When `true`, the title color hooks into `Interactive`'s `--interactive-foreground` variable. */
withInteractive?: boolean;
/** Ref forwarded to the root `<div>`. */
ref?: React.Ref<HTMLDivElement>;
}
// ---------------------------------------------------------------------------
@@ -80,14 +86,18 @@ function ContentSm({
sizePreset = "main-ui",
orientation = "inline",
prominence = "default",
withInteractive,
ref,
}: ContentSmProps) {
const config = CONTENT_SM_PRESETS[sizePreset];
return (
<div
ref={ref}
className="opal-content-sm"
data-orientation={orientation}
data-prominence={prominence}
data-interactive={withInteractive || undefined}
style={{ gap: config.gap }}
>
{Icon && (

View File

@@ -60,6 +60,12 @@ interface ContentXlProps {
/** Optional tertiary icon rendered in the icon row. */
moreIcon2?: IconFunctionComponent;
/** When `true`, the title color hooks into `Interactive`'s `--interactive-foreground` variable. */
withInteractive?: boolean;
/** Ref forwarded to the root `<div>`. */
ref?: React.Ref<HTMLDivElement>;
}
// ---------------------------------------------------------------------------
@@ -106,6 +112,8 @@ function ContentXl({
onTitleChange,
moreIcon1: MoreIcon1,
moreIcon2: MoreIcon2,
withInteractive,
ref,
}: ContentXlProps) {
const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState(title);
@@ -124,7 +132,11 @@ function ContentXl({
}
return (
<div className="opal-content-xl">
<div
ref={ref}
className="opal-content-xl"
data-interactive={withInteractive || undefined}
>
{(Icon || MoreIcon1 || MoreIcon2) && (
<div className="opal-content-xl-icon-row">
{Icon && (

View File

@@ -52,6 +52,9 @@ interface HeadingLayoutProps {
/** Variant controls icon placement. `"heading"` = top, `"section"` = inline. Default: `"heading"`. */
variant?: HeadingVariant;
/** Ref forwarded to the root `<div>`. */
ref?: React.Ref<HTMLDivElement>;
}
// ---------------------------------------------------------------------------
@@ -91,6 +94,7 @@ function HeadingLayout({
description,
editable,
onTitleChange,
ref,
}: HeadingLayoutProps) {
const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState(title);
@@ -112,6 +116,7 @@ function HeadingLayout({
return (
<div
ref={ref}
className="opal-content-heading"
data-icon-placement={iconPlacement}
style={{ gap: iconPlacement === "left" ? config.gap : undefined }}

View File

@@ -61,6 +61,9 @@ interface LabelLayoutProps {
/** Size preset. Default: `"main-ui"`. */
sizePreset?: LabelSizePreset;
/** Ref forwarded to the root `<div>`. */
ref?: React.Ref<HTMLDivElement>;
}
// ---------------------------------------------------------------------------
@@ -130,6 +133,7 @@ function LabelLayout({
auxIcon,
tag,
sizePreset = "main-ui",
ref,
}: LabelLayoutProps) {
const [editing, setEditing] = useState(false);
const [editValue, setEditValue] = useState(title);
@@ -149,7 +153,7 @@ function LabelLayout({
}
return (
<div className="opal-content-label" style={{ gap: config.gap }}>
<div ref={ref} className="opal-content-label" style={{ gap: config.gap }}>
{Icon && (
<div
className={cn(

View File

@@ -53,12 +53,18 @@ interface ContentBaseProps {
* Width preset controlling the component's horizontal size.
* Uses the shared `WidthVariant` scale from `@opal/shared`.
*
* - `"auto"` — Shrink-wraps to content width
* - `"fit"` — Shrink-wraps to content width
* - `"full"` — Stretches to fill the parent's width
*
* @default "auto"
* @default "fit"
*/
widthVariant?: WidthVariant;
/** When `true`, the title color hooks into `Interactive.Stateful`/`Interactive.Stateless`'s `--interactive-foreground` variable. */
withInteractive?: boolean;
/** Ref forwarded to the root `<div>` of the resolved layout. */
ref?: React.Ref<HTMLDivElement>;
}
// ---------------------------------------------------------------------------
@@ -121,7 +127,9 @@ function Content(props: ContentProps) {
const {
sizePreset = "headline",
variant = "heading",
widthVariant = "auto",
widthVariant = "fit",
withInteractive,
ref,
...rest
} = props;
@@ -135,6 +143,8 @@ function Content(props: ContentProps) {
layout = (
<ContentXl
sizePreset={sizePreset}
withInteractive={withInteractive}
ref={ref}
{...(rest as Omit<ContentXlProps, "sizePreset">)}
/>
);
@@ -142,6 +152,8 @@ function Content(props: ContentProps) {
layout = (
<ContentLg
sizePreset={sizePreset}
withInteractive={withInteractive}
ref={ref}
{...(rest as Omit<ContentLgProps, "sizePreset">)}
/>
);
@@ -154,6 +166,8 @@ function Content(props: ContentProps) {
layout = (
<ContentMd
sizePreset={sizePreset}
withInteractive={withInteractive}
ref={ref}
{...(rest as Omit<ContentMdProps, "sizePreset">)}
/>
);
@@ -164,6 +178,8 @@ function Content(props: ContentProps) {
layout = (
<ContentSm
sizePreset={sizePreset}
withInteractive={withInteractive}
ref={ref}
{...(rest as Omit<
React.ComponentProps<typeof ContentSm>,
"sizePreset"
@@ -178,9 +194,9 @@ function Content(props: ContentProps) {
`Content: no layout matched for sizePreset="${sizePreset}" variant="${variant}"`
);
// "auto" → return layout directly (a block div with w-auto still
// stretches to its parent, defeating shrink-to-content).
if (widthVariant === "auto") return layout;
// "fit" → return layout directly (no wrapper needed; the layout
// element itself shrink-wraps to its content).
if (widthVariant === "fit") return layout;
return <div className={widthClass}>{layout}</div>;
}

View File

@@ -22,6 +22,7 @@
.opal-content-xl-icon-row {
@apply flex flex-row items-center;
gap: 0.25rem;
}
/* ---------------------------------------------------------------------------
@@ -63,7 +64,7 @@
}
.opal-content-xl-title {
@apply text-left overflow-hidden;
@apply text-left overflow-hidden text-text-04;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
@@ -162,7 +163,7 @@
}
.opal-content-lg-title {
@apply text-left overflow-hidden;
@apply text-left overflow-hidden text-text-04;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
@@ -255,7 +256,7 @@
}
.opal-content-md-title {
@apply text-left overflow-hidden;
@apply text-left overflow-hidden text-text-04;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
@@ -361,8 +362,12 @@
@apply text-text-03;
}
.opal-content-sm[data-prominence="muted"] .opal-content-sm-icon {
@apply text-text-02;
}
.opal-content-sm[data-prominence="muted-2x"] .opal-content-sm-icon {
@apply text-text-02 stroke-text-02;
@apply text-text-02;
}
/* ---------------------------------------------------------------------------
@@ -385,3 +390,44 @@
.opal-content-sm[data-prominence="muted-2x"] .opal-content-sm-title {
@apply text-text-02;
}
/* ===========================================================================
Interactive-foreground opt-in
When a Content variant is nested inside an Interactive and
`withInteractive` is set, the title and icon delegate their color to the
`--interactive-foreground` / `--interactive-foreground-icon` CSS variables
controlled by the ancestor Interactive variant.
=========================================================================== */
.opal-content-xl[data-interactive] .opal-content-xl-title {
color: var(--interactive-foreground);
}
.opal-content-xl[data-interactive] .opal-content-xl-icon {
color: var(--interactive-foreground-icon);
}
.opal-content-lg[data-interactive] .opal-content-lg-title {
color: var(--interactive-foreground);
}
.opal-content-lg[data-interactive] .opal-content-lg-icon {
color: var(--interactive-foreground-icon);
}
.opal-content-md[data-interactive] .opal-content-md-title {
color: var(--interactive-foreground);
}
.opal-content-md[data-interactive] .opal-content-md-icon {
color: var(--interactive-foreground-icon);
}
.opal-content-sm[data-interactive] .opal-content-sm-title {
color: var(--interactive-foreground);
}
.opal-content-sm[data-interactive] .opal-content-sm-icon {
color: var(--interactive-foreground-icon);
}

View File

@@ -66,11 +66,11 @@ type SizeVariant = keyof typeof sizeVariants;
*
* | Key | Tailwind class |
* |--------|----------------|
* | `auto` | `w-auto` |
* | `fit` | `w-fit` |
* | `full` | `w-full` |
*/
const widthVariants = {
auto: "w-auto",
fit: "w-fit",
full: "w-full",
} as const;

View File

@@ -30,6 +30,11 @@ export interface IconProps extends SVGProps<SVGSVGElement> {
/** Strips `className` and `style` from a props type to enforce design-system styling. */
export type WithoutStyles<T> = Omit<T, "className" | "style">;
/** Like `Omit` but distributes over union types, preserving discriminated unions. */
export type DistributiveOmit<T, K extends keyof any> = T extends any
? Omit<T, K>
: never;
/**
* A React function component that accepts {@link IconProps}.
*

928
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -132,7 +132,7 @@
"eslint-plugin-unused-imports": "^4.1.4",
"identity-obj-proxy": "^3.0.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^30.2.0",
"jest-environment-jsdom": "^29.7.0",
"prettier": "3.1.0",
"stats.js": "^0.17.0",
"tailwindcss": "^3.4.17",

View File

@@ -6,7 +6,7 @@ import { Button } from "@opal/components";
import { Disabled } from "@opal/core";
import Text from "@/refresh-components/texts/Text";
import InputTypeIn from "@/refresh-components/inputs/InputTypeIn";
import InputSelect from "@/refresh-components/inputs/InputSelect";
import InputComboBox from "@/refresh-components/inputs/InputComboBox";
import { FormikField } from "@/refresh-components/form/FormikField";
import { FormField } from "@/refresh-components/form/FormField";
import { USER_ROLE_LABELS, UserRole } from "@/lib/types";
@@ -107,25 +107,26 @@ export default function OnyxApiKeyForm({
<FormField name="role" state={state} className="w-full">
<FormField.Label>Role:</FormField.Label>
<FormField.Control>
<InputSelect
<InputComboBox
value={field.value}
onValueChange={(value) => helper.setValue(value)}
>
<InputSelect.Trigger placeholder="Select a role" />
<InputSelect.Content>
<InputSelect.Item
value={UserRole.LIMITED.toString()}
>
{USER_ROLE_LABELS[UserRole.LIMITED]}
</InputSelect.Item>
<InputSelect.Item value={UserRole.BASIC.toString()}>
{USER_ROLE_LABELS[UserRole.BASIC]}
</InputSelect.Item>
<InputSelect.Item value={UserRole.ADMIN.toString()}>
{USER_ROLE_LABELS[UserRole.ADMIN]}
</InputSelect.Item>
</InputSelect.Content>
</InputSelect>
options={[
{
label: USER_ROLE_LABELS[UserRole.LIMITED],
value: UserRole.LIMITED.toString(),
},
{
label: USER_ROLE_LABELS[UserRole.BASIC],
value: UserRole.BASIC.toString(),
},
{
label: USER_ROLE_LABELS[UserRole.ADMIN],
value: UserRole.ADMIN.toString(),
},
]}
placeholder="Select a role"
strict
/>
</FormField.Control>
<FormField.Description>
Select the role for this API key. Limited has access to

View File

@@ -22,6 +22,9 @@ describe("Email/Password Login Workflow", () => {
beforeEach(() => {
jest.clearAllMocks();
fetchSpy = jest.spyOn(global, "fetch");
// Mock window.location.href for redirect testing
delete (window as any).location;
window.location = { href: "" } as any;
});
afterEach(() => {
@@ -50,9 +53,9 @@ describe("Email/Password Login Workflow", () => {
const loginButton = screen.getByRole("button", { name: /sign in/i });
await user.click(loginButton);
// Verify success message is shown after login
// After successful login, user should be redirected to /chat
await waitFor(() => {
expect(screen.getByText(/signed in successfully\./i)).toBeInTheDocument();
expect(window.location.href).toBe("/app");
});
// Verify API was called with correct credentials
@@ -111,6 +114,9 @@ describe("Email/Password Signup Workflow", () => {
beforeEach(() => {
jest.clearAllMocks();
fetchSpy = jest.spyOn(global, "fetch");
// Mock window.location.href
delete (window as any).location;
window.location = { href: "" } as any;
});
afterEach(() => {

View File

@@ -39,7 +39,7 @@ export function ToggleWarningModal({
{/* Message */}
<div className="flex justify-center">
<Text mainUiBody text04 className="text-center">
We recommend using <strong>Claude Opus 4.6</strong> for Crafting.
We recommend using <strong>Claude Opus 4.5</strong> for Crafting.
<br />
Other models may have reduced capabilities for code creation,
<br />

View File

@@ -34,8 +34,8 @@ export const PROVIDERS: ProviderConfig[] = [
providerName: LLMProviderName.ANTHROPIC,
recommended: true,
models: [
{ name: "claude-opus-4-6", label: "Claude Opus 4.6", recommended: true },
{ name: "claude-sonnet-4-6", label: "Claude Sonnet 4.6" },
{ name: "claude-opus-4-5", label: "Claude Opus 4.5", recommended: true },
{ name: "claude-sonnet-4-5", label: "Claude Sonnet 4.5" },
],
apiKeyPlaceholder: "sk-ant-...",
apiKeyUrl: "https://console.anthropic.com/dashboard",

View File

@@ -5,12 +5,12 @@
export interface BuildLlmSelection {
providerName: string; // e.g., "build-mode-anthropic" (LLMProviderDescriptor.name)
provider: string; // e.g., "anthropic"
modelName: string; // e.g., "claude-opus-4-6"
modelName: string; // e.g., "claude-opus-4-5"
}
// Priority order for smart default LLM selection
const LLM_SELECTION_PRIORITY = [
{ provider: "anthropic", modelName: "claude-opus-4-6" },
{ provider: "anthropic", modelName: "claude-opus-4-5" },
{ provider: "openai", modelName: "gpt-5.2" },
{ provider: "openrouter", modelName: "minimax/minimax-m2.1" },
] as const;
@@ -63,11 +63,11 @@ export function getDefaultLlmSelection(
export const RECOMMENDED_BUILD_MODELS = {
preferred: {
provider: "anthropic",
modelName: "claude-opus-4-6",
displayName: "Claude Opus 4.6",
modelName: "claude-opus-4-5",
displayName: "Claude Opus 4.5",
},
alternatives: [
{ provider: "anthropic", modelName: "claude-sonnet-4-6" },
{ provider: "anthropic", modelName: "claude-sonnet-4-5" },
{ provider: "openai", modelName: "gpt-5.2" },
{ provider: "openai", modelName: "gpt-5.1-codex" },
{ provider: "openrouter", modelName: "minimax/minimax-m2.1" },
@@ -148,8 +148,8 @@ export const BUILD_MODE_PROVIDERS: BuildModeProvider[] = [
providerName: "anthropic",
recommended: true,
models: [
{ name: "claude-opus-4-6", label: "Claude Opus 4.6", recommended: true },
{ name: "claude-sonnet-4-6", label: "Claude Sonnet 4.6" },
{ name: "claude-opus-4-5", label: "Claude Opus 4.5", recommended: true },
{ name: "claude-sonnet-4-5", label: "Claude Sonnet 4.5" },
],
apiKeyPlaceholder: "sk-ant-...",
apiKeyUrl: "https://console.anthropic.com/dashboard",

View File

@@ -1,21 +0,0 @@
import { ScopedMutator } from "swr";
import {
LLM_CHAT_PROVIDERS_URL,
LLM_PROVIDERS_ADMIN_URL,
} from "@/lib/llmConfig/constants";
const PERSONA_PROVIDER_ENDPOINT_PATTERN =
/^\/api\/llm\/persona\/\d+\/providers$/;
export async function refreshLlmProviderCaches(
mutate: ScopedMutator
): Promise<void> {
await Promise.all([
mutate(LLM_PROVIDERS_ADMIN_URL),
mutate(LLM_CHAT_PROVIDERS_URL),
mutate(
(key) =>
typeof key === "string" && PERSONA_PROVIDER_ENDPOINT_PATTERN.test(key)
),
]);
}

View File

@@ -1,6 +1,5 @@
export const LLM_ADMIN_URL = "/api/admin/llm";
export const LLM_PROVIDERS_ADMIN_URL = `${LLM_ADMIN_URL}/provider`;
export const LLM_CHAT_PROVIDERS_URL = "/api/llm/provider";
export const LLM_CONTEXTUAL_COST_ADMIN_URL =
"/api/admin/llm/provider-contextual-cost";

View File

@@ -57,12 +57,6 @@ export interface LineItemProps
WithoutStyles<React.HTMLAttributes<HTMLDivElement>>,
"children"
> {
/**
* Whether the row should behave like a standalone interactive button.
* Set to false when nested inside another interactive primitive
* (e.g. Radix Select.Item) to avoid nested focus targets.
*/
interactive?: boolean;
// line-item variants
strikethrough?: boolean;
danger?: boolean;
@@ -137,7 +131,6 @@ export interface LineItemProps
* - The component automatically adds a `data-selected="true"` attribute for custom styling
*/
export default function LineItem({
interactive = true,
selected,
strikethrough,
danger,
@@ -171,11 +164,6 @@ export default function LineItem({
const emphasisKey = emphasized ? "emphasized" : "normal";
const handleKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (!interactive) {
props.onKeyDown?.(e);
return;
}
if (e.key === "Enter") {
e.preventDefault();
(e.currentTarget as HTMLDivElement).click();
@@ -186,11 +174,6 @@ export default function LineItem({
};
const handleKeyUp = (e: React.KeyboardEvent<HTMLDivElement>) => {
if (!interactive) {
props.onKeyUp?.(e);
return;
}
if (e.key === " ") {
e.preventDefault();
(e.currentTarget as HTMLDivElement).click();
@@ -201,8 +184,8 @@ export default function LineItem({
const content = (
<div
ref={ref}
role={interactive ? "button" : undefined}
tabIndex={interactive ? 0 : undefined}
role="button"
tabIndex={0}
className={cn(
"flex flex-row w-full items-start p-2 rounded-08 group/LineItem gap-2",
!!(children && description) ? "items-start" : "items-center",

View File

@@ -369,7 +369,7 @@ function InputSelectItem({
<SelectPrimitive.Item
ref={ref}
value={value}
className="outline-none focus:outline-none rounded-08 data-[highlighted]:bg-background-tint-02"
className="outline-none focus:outline-none"
onSelect={onClick}
>
{/* Hidden ItemText for Radix to track selection */}
@@ -383,7 +383,7 @@ function InputSelectItem({
selected={isSelected}
emphasized
description={description}
interactive={false}
onClick={noProp((event) => event.preventDefault())}
>
{children}
</LineItem>

View File

@@ -193,7 +193,7 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
onSubmit({
message,
currentMessageFiles,
deepResearch: deepResearchEnabledForCurrentWorkflow,
deepResearch: deepResearchEnabled,
});
}
}
@@ -218,8 +218,6 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
chatSessionId: currentChatSessionId,
agentId: selectedAgent?.id,
});
const deepResearchEnabledForCurrentWorkflow =
currentProjectId === null && deepResearchEnabled;
const [presentingDocument, setPresentingDocument] =
useState<MinimalOnyxDocument | null>(null);
@@ -437,15 +435,10 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
onSubmit({
message: lastUserMsg.message,
currentMessageFiles: currentMessageFiles,
deepResearch: deepResearchEnabledForCurrentWorkflow,
deepResearch: deepResearchEnabled,
messageIdToResend: lastUserMsg.messageId,
});
}, [
messageHistory,
onSubmit,
currentMessageFiles,
deepResearchEnabledForCurrentWorkflow,
]);
}, [messageHistory, onSubmit, currentMessageFiles, deepResearchEnabled]);
const toggleDocumentSidebar = useCallback(() => {
if (!documentSidebarVisible) {
@@ -465,7 +458,7 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
onSubmit({
message,
currentMessageFiles,
deepResearch: deepResearchEnabledForCurrentWorkflow,
deepResearch: deepResearchEnabled,
});
if (showOnboarding || !onboardingDismissed) {
finishOnboarding();
@@ -475,7 +468,7 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
resetInputBar,
onSubmit,
currentMessageFiles,
deepResearchEnabledForCurrentWorkflow,
deepResearchEnabled,
showOnboarding,
onboardingDismissed,
finishOnboarding,
@@ -510,7 +503,7 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
onSubmit({
message,
currentMessageFiles,
deepResearch: deepResearchEnabledForCurrentWorkflow,
deepResearch: deepResearchEnabled,
});
if (showOnboarding || !onboardingDismissed) {
finishOnboarding();
@@ -531,7 +524,7 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
resetInputBar,
onSubmit,
currentMessageFiles,
deepResearchEnabledForCurrentWorkflow,
deepResearchEnabled,
showOnboarding,
onboardingDismissed,
finishOnboarding,
@@ -716,7 +709,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 ── */}
@@ -739,9 +732,7 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
<ChatUI
liveAgent={liveAgent!}
llmManager={llmManager}
deepResearchEnabled={
deepResearchEnabledForCurrentWorkflow
}
deepResearchEnabled={deepResearchEnabled}
currentMessageFiles={currentMessageFiles}
setPresentingDocument={setPresentingDocument}
onSubmit={onSubmit}
@@ -837,9 +828,7 @@ export default function AppPage({ firstMessage }: ChatPageProps) {
/>
<AppInputBar
ref={chatInputBarRef}
deepResearchEnabled={
deepResearchEnabledForCurrentWorkflow
}
deepResearchEnabled={deepResearchEnabled}
toggleDeepResearch={toggleDeepResearch}
filterManager={filterManager}
llmManager={llmManager}

View File

@@ -20,7 +20,6 @@ import {
getProviderIcon,
getProviderProductName,
} from "@/lib/llmConfig/providers";
import { refreshLlmProviderCaches } from "@/lib/llmConfig/cache";
import { deleteLlmProvider, setDefaultLlmModel } from "@/lib/llmConfig/svc";
import Text from "@/refresh-components/texts/Text";
import { Horizontal as HorizontalInput } from "@/layouts/input-layouts";
@@ -34,6 +33,7 @@ import {
LLMProviderView,
WellKnownLLMProviderDescriptor,
} from "@/interfaces/llm";
import { LLM_PROVIDERS_ADMIN_URL } from "@/lib/llmConfig/constants";
import { getModalForExistingProvider } from "@/sections/modals/llmConfig/getModal";
import { OpenAIModal } from "@/sections/modals/llmConfig/OpenAIModal";
import { AnthropicModal } from "@/sections/modals/llmConfig/AnthropicModal";
@@ -140,7 +140,7 @@ function ExistingProviderCard({
const handleDelete = async () => {
try {
await deleteLlmProvider(provider.id);
await refreshLlmProviderCaches(mutate);
mutate(LLM_PROVIDERS_ADMIN_URL);
deleteModal.toggle(false);
toast.success("Provider deleted successfully!");
} catch (e) {
@@ -345,7 +345,7 @@ export default function LLMConfigurationPage() {
try {
await setDefaultLlmModel(providerId, modelName);
await refreshLlmProviderCaches(mutate);
mutate(LLM_PROVIDERS_ADMIN_URL);
toast.success("Default model updated successfully!");
} catch (e) {
const message = e instanceof Error ? e.message : "Unknown error";

View File

@@ -148,7 +148,7 @@ const AppInputBar = React.memo(
classification === "search";
const { forcedToolIds, setForcedToolIds } = useForcedTools();
const { currentMessageFiles, setCurrentMessageFiles, currentProjectId } =
const { currentMessageFiles, setCurrentMessageFiles } =
useProjectsContext();
const currentIndexingFiles = useMemo(() => {
@@ -200,17 +200,9 @@ const AppInputBar = React.memo(
const textarea = textAreaRef.current;
if (!wrapper || !textarea) return;
// Reset so scrollHeight reflects actual content size
wrapper.style.height = `${MIN_INPUT_HEIGHT}px`;
// scrollHeight doesn't include the wrapper's padding, so add it back
const wrapperStyle = getComputedStyle(wrapper);
const paddingTop = parseFloat(wrapperStyle.paddingTop);
const paddingBottom = parseFloat(wrapperStyle.paddingBottom);
const contentHeight = textarea.scrollHeight + paddingTop + paddingBottom;
wrapper.style.height = `${Math.min(
Math.max(contentHeight, MIN_INPUT_HEIGHT),
Math.max(textarea.scrollHeight, MIN_INPUT_HEIGHT),
MAX_INPUT_HEIGHT
)}px`;
}, [message, isSearchMode]);
@@ -366,19 +358,13 @@ const AppInputBar = React.memo(
const showDeepResearch = useMemo(() => {
const deepResearchGloballyEnabled =
combinedSettings?.settings?.deep_research_enabled ?? true;
const isProjectWorkflow = currentProjectId !== null;
// TODO(@yuhong): Re-enable Deep Research in Projects workflow once it is fully supported.
// https://linear.app/onyx-app/issue/ENG-3818/re-enable-deep-research-in-projects
return (
!isProjectWorkflow &&
deepResearchGloballyEnabled &&
hasSearchToolsAvailable(selectedAgent?.tools || [])
);
}, [
selectedAgent?.tools,
combinedSettings?.settings?.deep_research_enabled,
currentProjectId,
]);
function handleKeyDownForPromptShortcuts(

View File

@@ -32,7 +32,6 @@ import Separator from "@/refresh-components/Separator";
import Text from "@/refresh-components/texts/Text";
import Tabs from "@/refresh-components/Tabs";
import { cn } from "@/lib/utils";
import { ScopedMutator } from "swr";
export const BEDROCK_PROVIDER_NAME = "bedrock";
const BEDROCK_DISPLAY_NAME = "AWS Bedrock";
@@ -84,7 +83,7 @@ interface BedrockModalInternalsProps {
modelConfigurations: ModelConfiguration[];
isTesting: boolean;
testError: string;
mutate: ScopedMutator;
mutate: (key: string) => void;
onClose: () => void;
}

View File

@@ -164,18 +164,6 @@ describe("Custom LLM Provider Configuration Workflow", () => {
// Verify SWR cache was invalidated
expect(mockMutate).toHaveBeenCalledWith("/api/admin/llm/provider");
expect(mockMutate).toHaveBeenCalledWith("/api/llm/provider");
const personaProvidersMutateCall = mockMutate.mock.calls.find(
([key]) => typeof key === "function"
);
expect(personaProvidersMutateCall).toBeDefined();
const personaProviderFilter = personaProvidersMutateCall?.[0] as (
key: unknown
) => boolean;
expect(personaProviderFilter("/api/llm/persona/42/providers")).toBe(true);
expect(personaProviderFilter("/api/llm/provider")).toBe(false);
});
test("shows error when test configuration fails", async () => {

View File

@@ -27,7 +27,6 @@ import { DisplayModels } from "./components/DisplayModels";
import { useCallback, useEffect, useMemo, useState } from "react";
import { fetchModels } from "@/app/admin/configuration/llm/utils";
import debounce from "lodash/debounce";
import { ScopedMutator } from "swr";
const DEFAULT_API_BASE = "http://localhost:1234";
@@ -47,7 +46,7 @@ interface LMStudioFormContentProps {
setHasFetched: (value: boolean) => void;
isTesting: boolean;
testError: string;
mutate: ScopedMutator;
mutate: () => void;
onClose: () => void;
isFormValid: boolean;
}

View File

@@ -26,7 +26,6 @@ import { DisplayModels } from "./components/DisplayModels";
import { useCallback, useEffect, useMemo, useState } from "react";
import { fetchOllamaModels } from "@/app/admin/configuration/llm/utils";
import debounce from "lodash/debounce";
import { ScopedMutator } from "swr";
export const OLLAMA_PROVIDER_NAME = "ollama_chat";
const DEFAULT_API_BASE = "http://127.0.0.1:11434";
@@ -45,7 +44,7 @@ interface OllamaModalContentProps {
setFetchedModels: (models: ModelConfiguration[]) => void;
isTesting: boolean;
testError: string;
mutate: ScopedMutator;
mutate: () => void;
onClose: () => void;
isFormValid: boolean;
}

View File

@@ -4,15 +4,14 @@ import Button from "@/refresh-components/buttons/Button";
import { Button as OpalButton } from "@opal/components";
import { SvgTrash } from "@opal/icons";
import { LLMProviderView } from "@/interfaces/llm";
import { refreshLlmProviderCaches } from "@/lib/llmConfig/cache";
import { LLM_PROVIDERS_ADMIN_URL } from "@/lib/llmConfig/constants";
import { deleteLlmProvider } from "@/lib/llmConfig/svc";
import { ScopedMutator } from "swr";
interface FormActionButtonsProps {
isTesting: boolean;
testError: string;
existingLlmProvider?: LLMProviderView;
mutate: ScopedMutator;
mutate: (key: string) => void;
onClose: () => void;
isFormValid: boolean;
}
@@ -30,7 +29,7 @@ export function FormActionButtons({
try {
await deleteLlmProvider(existingLlmProvider.id);
await refreshLlmProviderCaches(mutate);
mutate(LLM_PROVIDERS_ADMIN_URL);
onClose();
} catch (e) {
const message = e instanceof Error ? e.message : "Unknown error";

View File

@@ -1,7 +1,7 @@
"use client";
import { useState, ReactNode } from "react";
import useSWR, { useSWRConfig, ScopedMutator } from "swr";
import useSWR, { useSWRConfig, KeyedMutator } from "swr";
import { toast } from "@/hooks/useToast";
import {
LLMProviderView,
@@ -14,12 +14,12 @@ import { Button } from "@opal/components";
import { SvgSettings } from "@opal/icons";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
import { refreshLlmProviderCaches } from "@/lib/llmConfig/cache";
import { LLM_PROVIDERS_ADMIN_URL } from "@/lib/llmConfig/constants";
import { setDefaultLlmModel } from "@/lib/llmConfig/svc";
export interface ProviderFormContext {
onClose: () => void;
mutate: ScopedMutator;
mutate: KeyedMutator<any>;
isTesting: boolean;
setIsTesting: (testing: boolean) => void;
testError: string;
@@ -95,7 +95,7 @@ export function ProviderFormEntrypointWrapper({
try {
await setDefaultLlmModel(existingLlmProvider.id, firstVisibleModel.name);
await refreshLlmProviderCaches(mutate);
await mutate(LLM_PROVIDERS_ADMIN_URL);
toast.success("Provider set as default successfully!");
} catch (e) {
const message = e instanceof Error ? e.message : "Unknown error";

View File

@@ -7,11 +7,9 @@ import {
LLM_ADMIN_URL,
LLM_PROVIDERS_ADMIN_URL,
} from "@/lib/llmConfig/constants";
import { refreshLlmProviderCaches } from "@/lib/llmConfig/cache";
import { toast } from "@/hooks/useToast";
import * as Yup from "yup";
import isEqual from "lodash/isEqual";
import { ScopedMutator } from "swr";
// Common class names for the Form component across all LLM provider forms
export const LLM_FORM_CLASS_NAME = "flex flex-col gap-y-4 items-stretch mt-6";
@@ -107,7 +105,7 @@ export interface SubmitLLMProviderParams<
hideSuccess?: boolean;
setIsTesting: (testing: boolean) => void;
setTestError: (error: string) => void;
mutate: ScopedMutator;
mutate: (key: string) => void;
onClose: () => void;
setSubmitting: (submitting: boolean) => void;
}
@@ -289,7 +287,7 @@ export const submitLLMProvider = async <T extends BaseLLMFormValues>({
}
}
await refreshLlmProviderCaches(mutate);
mutate(LLM_PROVIDERS_ADMIN_URL);
onClose();
if (!hideSuccess) {

View File

@@ -10,6 +10,7 @@ import {
createMockOnboardingActions,
createMockFetchResponses,
MOCK_PROVIDERS,
ANTHROPIC_DEFAULT_VISIBLE_MODELS,
} from "./testHelpers";
// Mock fetch
@@ -50,6 +51,8 @@ jest.mock("@/components/modals/ProviderModal", () => ({
},
}));
// Mock fetchModels utility - returns the curated Anthropic visible models
// that match ANTHROPIC_VISIBLE_MODEL_NAMES from backend
const mockFetchModels = jest.fn().mockResolvedValue({
models: [
{
@@ -149,6 +152,71 @@ describe("AnthropicOnboardingForm", () => {
});
});
describe("Default Available Models", () => {
/**
* This test verifies that the exact curated list of Anthropic visible models
* matches what's returned from /api/admin/llm/built-in/options.
* The expected models are defined in ANTHROPIC_VISIBLE_MODEL_NAMES in
* backend/onyx/llm/llm_provider_options.py
*/
test("llmDescriptor contains the correct default visible models from built-in options", () => {
const expectedModelNames = [
"claude-opus-4-5",
"claude-sonnet-4-5",
"claude-haiku-4-5",
];
// Verify MOCK_PROVIDERS.anthropic has the correct model configurations
const actualModelNames = MOCK_PROVIDERS.anthropic.known_models.map(
(config) => config.name
);
// Check that all expected models are present
expect(actualModelNames).toEqual(
expect.arrayContaining(expectedModelNames)
);
// Check that only the expected models are present (no extras)
expect(actualModelNames).toHaveLength(expectedModelNames.length);
// Verify each model has is_visible set to true
MOCK_PROVIDERS.anthropic.known_models.forEach((config) => {
expect(config.is_visible).toBe(true);
});
});
test("ANTHROPIC_DEFAULT_VISIBLE_MODELS matches backend ANTHROPIC_VISIBLE_MODEL_NAMES", () => {
// These are the exact model names from backend/onyx/llm/llm_provider_options.py
// ANTHROPIC_VISIBLE_MODEL_NAMES = {"claude-opus-4-5", "claude-sonnet-4-5", "claude-haiku-4-5"}
const backendVisibleModelNames = new Set([
"claude-opus-4-5",
"claude-sonnet-4-5",
"claude-haiku-4-5",
]);
const testHelperModelNames = new Set(
ANTHROPIC_DEFAULT_VISIBLE_MODELS.map((m) => m.name)
);
expect(testHelperModelNames).toEqual(backendVisibleModelNames);
});
test("all default models are marked as visible", () => {
ANTHROPIC_DEFAULT_VISIBLE_MODELS.forEach((model) => {
expect(model.is_visible).toBe(true);
});
});
test("default model claude-sonnet-4-5 is set correctly in component", () => {
// The AnthropicOnboardingForm sets DEFAULT_DEFAULT_MODEL_NAME = "claude-sonnet-4-5"
// Verify this model exists in the default visible models
const defaultModelExists = ANTHROPIC_DEFAULT_VISIBLE_MODELS.some(
(m) => m.name === "claude-sonnet-4-5"
);
expect(defaultModelExists).toBe(true);
});
});
describe("Form Validation", () => {
test("submit button is disabled when form is empty", () => {
render(<AnthropicOnboardingForm {...defaultProps} />);

View File

@@ -10,6 +10,7 @@ import {
createMockOnboardingActions,
createMockFetchResponses,
MOCK_PROVIDERS,
OPENAI_DEFAULT_VISIBLE_MODELS,
} from "./testHelpers";
// Mock fetch
@@ -53,6 +54,8 @@ jest.mock("@/components/modals/ProviderModal", () => ({
},
}));
// Mock fetchModels utility - returns the curated OpenAI visible models
// that match OPENAI_VISIBLE_MODEL_NAMES from backend
const mockFetchModels = jest.fn().mockResolvedValue({
models: [
{
@@ -170,6 +173,77 @@ describe("OpenAIOnboardingForm", () => {
});
});
describe("Default Available Models", () => {
/**
* This test verifies that the exact curated list of OpenAI visible models
* matches what's returned from /api/admin/llm/built-in/options.
* The expected models are defined in OPENAI_VISIBLE_MODEL_NAMES in
* backend/onyx/llm/llm_provider_options.py
*/
test("llmDescriptor contains the correct default visible models from built-in options", () => {
const expectedModelNames = [
"gpt-5.2",
"gpt-5-mini",
"o1",
"o3-mini",
"gpt-4o",
"gpt-4o-mini",
];
// Verify MOCK_PROVIDERS.openai has the correct model configurations
const actualModelNames = MOCK_PROVIDERS.openai.known_models.map(
(config) => config.name
);
// Check that all expected models are present
expect(actualModelNames).toEqual(
expect.arrayContaining(expectedModelNames)
);
// Check that only the expected models are present (no extras)
expect(actualModelNames).toHaveLength(expectedModelNames.length);
// Verify each model has is_visible set to true
MOCK_PROVIDERS.openai.known_models.forEach((config) => {
expect(config.is_visible).toBe(true);
});
});
test("OPENAI_DEFAULT_VISIBLE_MODELS matches backend OPENAI_VISIBLE_MODEL_NAMES", () => {
// These are the exact model names from backend/onyx/llm/llm_provider_options.py
// OPENAI_VISIBLE_MODEL_NAMES = {"gpt-5.2", "gpt-5-mini", "o1", "o3-mini", "gpt-4o", "gpt-4o-mini"}
const backendVisibleModelNames = new Set([
"gpt-5.2",
"gpt-5-mini",
"o1",
"o3-mini",
"gpt-4o",
"gpt-4o-mini",
]);
const testHelperModelNames = new Set(
OPENAI_DEFAULT_VISIBLE_MODELS.map((m) => m.name)
);
expect(testHelperModelNames).toEqual(backendVisibleModelNames);
});
test("all default models are marked as visible", () => {
OPENAI_DEFAULT_VISIBLE_MODELS.forEach((model) => {
expect(model.is_visible).toBe(true);
});
});
test("default model gpt-5.2 is set correctly in component", () => {
// The OpenAIOnboardingForm sets DEFAULT_DEFAULT_MODEL_NAME = "gpt-5.2"
// Verify this model exists in the default visible models
const defaultModelExists = OPENAI_DEFAULT_VISIBLE_MODELS.some(
(m) => m.name === "gpt-5.2"
);
expect(defaultModelExists).toBe(true);
});
});
describe("Form Validation", () => {
test("submit button is disabled when form is empty", () => {
render(<OpenAIOnboardingForm {...defaultProps} />);

View File

@@ -10,6 +10,7 @@ import {
createMockOnboardingActions,
createMockFetchResponses,
MOCK_PROVIDERS,
VERTEXAI_DEFAULT_VISIBLE_MODELS,
} from "./testHelpers";
// Mock fetch
@@ -50,6 +51,8 @@ jest.mock("@/components/modals/ProviderModal", () => ({
},
}));
// Mock fetchModels utility - returns the curated Vertex AI visible models
// that match VERTEXAI_VISIBLE_MODEL_NAMES from backend
jest.mock("@/app/admin/configuration/llm/utils", () => ({
canProviderFetchModels: jest.fn().mockReturnValue(true),
fetchModels: jest.fn().mockResolvedValue({
@@ -151,6 +154,71 @@ describe("VertexAIOnboardingForm", () => {
});
});
describe("Default Available Models", () => {
/**
* This test verifies that the exact curated list of Vertex AI visible models
* matches what's returned from /api/admin/llm/built-in/options.
* The expected models are defined in VERTEXAI_VISIBLE_MODEL_NAMES in
* backend/onyx/llm/llm_provider_options.py
*/
test("llmDescriptor contains the correct default visible models from built-in options", () => {
const expectedModelNames = [
"gemini-2.5-flash",
"gemini-2.5-flash-lite",
"gemini-2.5-pro",
];
// Verify MOCK_PROVIDERS.vertexAi has the correct model configurations
const actualModelNames = MOCK_PROVIDERS.vertexAi.known_models.map(
(config) => config.name
);
// Check that all expected models are present
expect(actualModelNames).toEqual(
expect.arrayContaining(expectedModelNames)
);
// Check that only the expected models are present (no extras)
expect(actualModelNames).toHaveLength(expectedModelNames.length);
// Verify each model has is_visible set to true
MOCK_PROVIDERS.vertexAi.known_models.forEach((config) => {
expect(config.is_visible).toBe(true);
});
});
test("VERTEXAI_DEFAULT_VISIBLE_MODELS matches backend VERTEXAI_VISIBLE_MODEL_NAMES", () => {
// These are the exact model names from backend/onyx/llm/llm_provider_options.py
// VERTEXAI_VISIBLE_MODEL_NAMES = {"gemini-2.5-flash", "gemini-2.5-flash-lite", "gemini-2.5-pro"}
const backendVisibleModelNames = new Set([
"gemini-2.5-flash",
"gemini-2.5-flash-lite",
"gemini-2.5-pro",
]);
const testHelperModelNames = new Set(
VERTEXAI_DEFAULT_VISIBLE_MODELS.map((m) => m.name)
);
expect(testHelperModelNames).toEqual(backendVisibleModelNames);
});
test("all default models are marked as visible", () => {
VERTEXAI_DEFAULT_VISIBLE_MODELS.forEach((model) => {
expect(model.is_visible).toBe(true);
});
});
test("default model gemini-2.5-pro is set correctly in component", () => {
// The VertexAIOnboardingForm sets DEFAULT_DEFAULT_MODEL_NAME = "gemini-2.5-pro"
// Verify this model exists in the default visible models
const defaultModelExists = VERTEXAI_DEFAULT_VISIBLE_MODELS.some(
(m) => m.name === "gemini-2.5-pro"
);
expect(defaultModelExists).toBe(true);
});
});
describe("Form Validation", () => {
test("submit button is disabled when form is empty", () => {
render(<VertexAIOnboardingForm {...defaultProps} />);

View File

@@ -1,6 +1,7 @@
/**
* Shared test helpers and mocks for onboarding form tests
*/
import React from "react";
// Mock Element.prototype.scrollIntoView for JSDOM (not implemented in jsdom)
Element.prototype.scrollIntoView = jest.fn();
@@ -160,6 +161,11 @@ export async function waitForModalOpen(screen: any, waitFor: any) {
/**
* Common provider descriptors for testing
*/
/**
* The curated list of OpenAI visible models that are returned by
* /api/admin/llm/built-in/options. This must match OPENAI_VISIBLE_MODEL_NAMES
* in backend/onyx/llm/llm_provider_options.py
*/
export const OPENAI_DEFAULT_VISIBLE_MODELS = [
{
name: "gpt-5.2",
@@ -205,6 +211,11 @@ export const OPENAI_DEFAULT_VISIBLE_MODELS = [
},
];
/**
* The curated list of Anthropic visible models that are returned by
* /api/admin/llm/built-in/options. This must match ANTHROPIC_VISIBLE_MODEL_NAMES
* in backend/onyx/llm/llm_provider_options.py
*/
export const ANTHROPIC_DEFAULT_VISIBLE_MODELS = [
{
name: "claude-opus-4-5",
@@ -229,6 +240,11 @@ export const ANTHROPIC_DEFAULT_VISIBLE_MODELS = [
},
];
/**
* The curated list of Vertex AI visible models that are returned by
* /api/admin/llm/built-in/options. This must match VERTEXAI_VISIBLE_MODEL_NAMES
* in backend/onyx/llm/llm_provider_options.py
*/
export const VERTEXAI_DEFAULT_VISIBLE_MODELS = [
{
name: "gemini-2.5-flash",

View File

@@ -292,10 +292,7 @@ test.describe("Assistant Creation and Edit Verification", () => {
expect(agentIdMatch).toBeTruthy();
const agentId = agentIdMatch ? agentIdMatch[1] : null;
expect(agentId).not.toBeNull();
await expectScreenshot(page, {
name: "welcome-page-with-assistant",
hide: ["[data-testid='AppInputBar/llm-popover-trigger']"],
});
await expectScreenshot(page, { name: "welcome-page-with-assistant" });
// Store assistant ID for cleanup
knowledgeAssistantId = Number(agentId);