Compare commits

..

4 Commits

Author SHA1 Message Date
Dane Urban
d7fdbaf772 Make better 2026-03-06 11:22:38 -08:00
Dane Urban
ddb2e069ed . 2026-03-06 11:22:38 -08:00
Dane Urban
4b092cf252 . 2026-03-06 11:22:38 -08:00
Dane Urban
783c8a6481 Prevent the removal and hiding of default model 2026-03-06 11:22:38 -08:00
55 changed files with 1863 additions and 2049 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

@@ -270,10 +270,34 @@ def upsert_llm_provider(
mc.name for mc in llm_provider_upsert_request.model_configurations
}
default_model = fetch_default_llm_model(db_session)
# Build a lookup of requested visibility by model name
requested_visibility = {
mc.name: mc.is_visible
for mc in llm_provider_upsert_request.model_configurations
}
# Delete removed models
removed_ids = [
mc.id for name, mc in existing_by_name.items() if name not in models_to_exist
]
# Prevent removing and hiding the default model
if default_model:
for name, mc in existing_by_name.items():
if mc.id == default_model.id:
if name not in models_to_exist:
raise ValueError(
f"Cannot remove the default model '{name}'. "
"Please change the default model before removing."
)
if not requested_visibility.get(name, True):
raise ValueError(
f"Cannot hide the default model '{name}'. "
"Please change the default model before hiding."
)
if removed_ids:
db_session.query(ModelConfiguration).filter(
ModelConfiguration.id.in_(removed_ids)
@@ -538,7 +562,6 @@ def fetch_default_model(
.options(selectinload(ModelConfiguration.llm_provider))
.join(LLMModelFlow)
.where(
ModelConfiguration.is_visible == True, # noqa: E712
LLMModelFlow.llm_model_flow_type == flow_type,
LLMModelFlow.is_default == True, # noqa: E712
)

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

@@ -65,6 +65,7 @@ from onyx.server.manage.llm.models import LLMProviderUpsertRequest
from onyx.server.manage.llm.models import LLMProviderView
from onyx.server.manage.llm.models import LMStudioFinalModelResponse
from onyx.server.manage.llm.models import LMStudioModelsRequest
from onyx.server.manage.llm.models import ModelConfigurationUpsertRequest
from onyx.server.manage.llm.models import OllamaFinalModelResponse
from onyx.server.manage.llm.models import OllamaModelDetails
from onyx.server.manage.llm.models import OllamaModelsRequest
@@ -445,16 +446,17 @@ def put_llm_provider(
not existing_provider or not existing_provider.is_auto_mode
)
# Before the upsert, check if this provider currently owns the global
# CHAT default. The upsert may cascade-delete model_configurations
# (and their flow mappings), so we need to remember this beforehand.
was_default_provider = False
if existing_provider and transitioning_to_auto_mode:
current_default = fetch_default_llm_model(db_session)
was_default_provider = (
current_default is not None
and current_default.llm_provider_id == existing_provider.id
)
# When transitioning to auto mode, preserve existing model configurations
# so the upsert doesn't try to delete them (which would trip the default
# model protection guard). sync_auto_mode_models will handle the model
# lifecycle afterward — adding new models, hiding removed ones, and
# updating the default. This is safe even if sync fails: the provider
# keeps its old models and default rather than losing them.
if transitioning_to_auto_mode and existing_provider:
llm_provider_upsert_request.model_configurations = [
ModelConfigurationUpsertRequest.from_model(mc)
for mc in existing_provider.model_configurations
]
try:
result = upsert_llm_provider(
@@ -468,7 +470,6 @@ def put_llm_provider(
config = fetch_llm_recommendations_from_github()
if config and llm_provider_upsert_request.provider in config.providers:
# Refetch the provider to get the updated model
updated_provider = fetch_existing_llm_provider_by_id(
id=result.id, db_session=db_session
)
@@ -478,20 +479,6 @@ def put_llm_provider(
updated_provider,
config,
)
# If this provider was the default before the transition,
# restore the default using the recommended model.
if was_default_provider:
recommended = config.get_default_model(
llm_provider_upsert_request.provider
)
if recommended:
update_default_provider(
provider_id=updated_provider.id,
model_name=recommended.name,
db_session=db_session,
)
# Refresh result with synced models
result = LLMProviderView.from_model(updated_provider)

View File

@@ -1152,3 +1152,179 @@ class TestAutoModeTransitionsAndResync:
finally:
db_session.rollback()
_cleanup_provider(db_session, provider_name)
def test_sync_updates_default_when_recommended_default_changes(
self,
db_session: Session,
provider_name: str,
) -> None:
"""When the provider owns the CHAT default and a sync arrives with a
different recommended default model (both models still in config),
the global default should be updated to the new recommendation.
Steps:
1. Create auto-mode provider with config v1: default=gpt-4o.
2. Set gpt-4o as the global CHAT default.
3. Re-sync with config v2: default=gpt-4o-mini (gpt-4o still present).
4. Verify the CHAT default switched to gpt-4o-mini and both models
remain visible.
"""
config_v1 = _create_mock_llm_recommendations(
provider=LlmProviderNames.OPENAI,
default_model_name="gpt-4o",
additional_models=["gpt-4o-mini"],
)
config_v2 = _create_mock_llm_recommendations(
provider=LlmProviderNames.OPENAI,
default_model_name="gpt-4o-mini",
additional_models=["gpt-4o"],
)
try:
with patch(
"onyx.server.manage.llm.api.fetch_llm_recommendations_from_github",
return_value=config_v1,
):
put_llm_provider(
llm_provider_upsert_request=LLMProviderUpsertRequest(
name=provider_name,
provider=LlmProviderNames.OPENAI,
api_key="sk-test-key-00000000000000000000000000000000000",
api_key_changed=True,
is_auto_mode=True,
model_configurations=[],
),
is_creation=True,
_=_create_mock_admin(),
db_session=db_session,
)
# Set gpt-4o as the global CHAT default
db_session.expire_all()
provider = fetch_existing_llm_provider(
name=provider_name, db_session=db_session
)
assert provider is not None
update_default_provider(provider.id, "gpt-4o", db_session)
default_before = fetch_default_llm_model(db_session)
assert default_before is not None
assert default_before.name == "gpt-4o"
# Re-sync with config v2 (recommended default changed)
db_session.expire_all()
provider = fetch_existing_llm_provider(
name=provider_name, db_session=db_session
)
assert provider is not None
changes = sync_auto_mode_models(
db_session=db_session,
provider=provider,
llm_recommendations=config_v2,
)
assert changes > 0, "Sync should report changes when default switches"
# Both models should remain visible
db_session.expire_all()
provider = fetch_existing_llm_provider(
name=provider_name, db_session=db_session
)
assert provider is not None
visibility = {
mc.name: mc.is_visible for mc in provider.model_configurations
}
assert visibility["gpt-4o"] is True
assert visibility["gpt-4o-mini"] is True
# The CHAT default should now be gpt-4o-mini
default_after = fetch_default_llm_model(db_session)
assert default_after is not None
assert (
default_after.name == "gpt-4o-mini"
), f"Default should be updated to 'gpt-4o-mini', got '{default_after.name}'"
finally:
db_session.rollback()
_cleanup_provider(db_session, provider_name)
def test_sync_idempotent_when_default_already_matches(
self,
db_session: Session,
provider_name: str,
) -> None:
"""When the provider owns the CHAT default and it already matches the
recommended default, re-syncing should report zero changes.
This is a regression test for the bug where changes was unconditionally
incremented even when the default was already correct.
"""
config = _create_mock_llm_recommendations(
provider=LlmProviderNames.OPENAI,
default_model_name="gpt-4o",
additional_models=["gpt-4o-mini"],
)
try:
with patch(
"onyx.server.manage.llm.api.fetch_llm_recommendations_from_github",
return_value=config,
):
put_llm_provider(
llm_provider_upsert_request=LLMProviderUpsertRequest(
name=provider_name,
provider=LlmProviderNames.OPENAI,
api_key="sk-test-key-00000000000000000000000000000000000",
api_key_changed=True,
is_auto_mode=True,
model_configurations=[],
),
is_creation=True,
_=_create_mock_admin(),
db_session=db_session,
)
# Set gpt-4o (the recommended default) as global CHAT default
db_session.expire_all()
provider = fetch_existing_llm_provider(
name=provider_name, db_session=db_session
)
assert provider is not None
update_default_provider(provider.id, "gpt-4o", db_session)
# First sync to stabilize state
db_session.expire_all()
provider = fetch_existing_llm_provider(
name=provider_name, db_session=db_session
)
assert provider is not None
sync_auto_mode_models(
db_session=db_session,
provider=provider,
llm_recommendations=config,
)
# Second sync — default already matches, should be a no-op
db_session.expire_all()
provider = fetch_existing_llm_provider(
name=provider_name, db_session=db_session
)
assert provider is not None
changes = sync_auto_mode_models(
db_session=db_session,
provider=provider,
llm_recommendations=config,
)
assert changes == 0, (
f"Expected 0 changes when default already matches recommended, "
f"got {changes}"
)
# Default should still be gpt-4o
default_model = fetch_default_llm_model(db_session)
assert default_model is not None
assert default_model.name == "gpt-4o"
finally:
db_session.rollback()
_cleanup_provider(db_session, provider_name)

View File

@@ -0,0 +1,220 @@
"""
This should act as the main point of reference for testing that default model
logic is consisten.
-
"""
from collections.abc import Generator
from uuid import uuid4
import pytest
from sqlalchemy.orm import Session
from onyx.db.llm import fetch_existing_llm_provider
from onyx.db.llm import remove_llm_provider
from onyx.db.llm import update_default_provider
from onyx.db.llm import update_default_vision_provider
from onyx.db.llm import upsert_llm_provider
from onyx.llm.constants import LlmProviderNames
from onyx.server.manage.llm.models import LLMProviderUpsertRequest
from onyx.server.manage.llm.models import LLMProviderView
from onyx.server.manage.llm.models import ModelConfigurationUpsertRequest
def _create_test_provider(
db_session: Session,
name: str,
models: list[ModelConfigurationUpsertRequest] | None = None,
) -> LLMProviderView:
"""Helper to create a test LLM provider with multiple models."""
if models is None:
models = [
ModelConfigurationUpsertRequest(
name="gpt-4o", is_visible=True, supports_image_input=True
),
ModelConfigurationUpsertRequest(
name="gpt-4o-mini", is_visible=True, supports_image_input=False
),
]
return upsert_llm_provider(
LLMProviderUpsertRequest(
name=name,
provider=LlmProviderNames.OPENAI,
api_key="sk-test-key-00000000000000000000000000000000000",
api_key_changed=True,
model_configurations=models,
),
db_session=db_session,
)
def _cleanup_provider(db_session: Session, name: str) -> None:
"""Helper to clean up a test provider by name."""
provider = fetch_existing_llm_provider(name=name, db_session=db_session)
if provider:
remove_llm_provider(db_session, provider.id)
@pytest.fixture
def provider_name(db_session: Session) -> Generator[str, None, None]:
"""Generate a unique provider name for each test, with automatic cleanup."""
name = f"test-provider-{uuid4().hex[:8]}"
yield name
db_session.rollback()
_cleanup_provider(db_session, name)
class TestDefaultModelProtection:
"""Tests that the default model cannot be removed or hidden."""
def test_cannot_remove_default_text_model(
self,
db_session: Session,
provider_name: str,
) -> None:
"""Removing the default text model from a provider should raise ValueError."""
provider = _create_test_provider(db_session, provider_name)
update_default_provider(provider.id, "gpt-4o", db_session)
# Try to update the provider without the default model
with pytest.raises(ValueError, match="Cannot remove the default model"):
upsert_llm_provider(
LLMProviderUpsertRequest(
id=provider.id,
name=provider_name,
provider=LlmProviderNames.OPENAI,
api_key="sk-test-key-00000000000000000000000000000000000",
api_key_changed=True,
model_configurations=[
ModelConfigurationUpsertRequest(
name="gpt-4o-mini", is_visible=True
),
],
),
db_session=db_session,
)
def test_cannot_hide_default_text_model(
self,
db_session: Session,
provider_name: str,
) -> None:
"""Setting is_visible=False on the default text model should raise ValueError."""
provider = _create_test_provider(db_session, provider_name)
update_default_provider(provider.id, "gpt-4o", db_session)
# Try to hide the default model
with pytest.raises(ValueError, match="Cannot hide the default model"):
upsert_llm_provider(
LLMProviderUpsertRequest(
id=provider.id,
name=provider_name,
provider=LlmProviderNames.OPENAI,
api_key="sk-test-key-00000000000000000000000000000000000",
api_key_changed=True,
model_configurations=[
ModelConfigurationUpsertRequest(
name="gpt-4o", is_visible=False
),
ModelConfigurationUpsertRequest(
name="gpt-4o-mini", is_visible=True
),
],
),
db_session=db_session,
)
def test_cannot_remove_default_vision_model(
self,
db_session: Session,
provider_name: str,
) -> None:
"""Removing the default vision model from a provider should raise ValueError."""
provider = _create_test_provider(db_session, provider_name)
# Set gpt-4o as both the text and vision default
update_default_provider(provider.id, "gpt-4o", db_session)
update_default_vision_provider(provider.id, "gpt-4o", db_session)
# Try to remove the default vision model
with pytest.raises(ValueError, match="Cannot remove the default model"):
upsert_llm_provider(
LLMProviderUpsertRequest(
id=provider.id,
name=provider_name,
provider=LlmProviderNames.OPENAI,
api_key="sk-test-key-00000000000000000000000000000000000",
api_key_changed=True,
model_configurations=[
ModelConfigurationUpsertRequest(
name="gpt-4o-mini", is_visible=True
),
],
),
db_session=db_session,
)
def test_can_remove_non_default_model(
self,
db_session: Session,
provider_name: str,
) -> None:
"""Removing a non-default model should succeed."""
provider = _create_test_provider(db_session, provider_name)
update_default_provider(provider.id, "gpt-4o", db_session)
# Remove gpt-4o-mini (not default) — should succeed
updated = upsert_llm_provider(
LLMProviderUpsertRequest(
id=provider.id,
name=provider_name,
provider=LlmProviderNames.OPENAI,
api_key="sk-test-key-00000000000000000000000000000000000",
api_key_changed=True,
model_configurations=[
ModelConfigurationUpsertRequest(
name="gpt-4o", is_visible=True, supports_image_input=True
),
],
),
db_session=db_session,
)
model_names = {mc.name for mc in updated.model_configurations}
assert "gpt-4o" in model_names
assert "gpt-4o-mini" not in model_names
def test_can_hide_non_default_model(
self,
db_session: Session,
provider_name: str,
) -> None:
"""Hiding a non-default model should succeed."""
provider = _create_test_provider(db_session, provider_name)
update_default_provider(provider.id, "gpt-4o", db_session)
# Hide gpt-4o-mini (not default) — should succeed
updated = upsert_llm_provider(
LLMProviderUpsertRequest(
id=provider.id,
name=provider_name,
provider=LlmProviderNames.OPENAI,
api_key="sk-test-key-00000000000000000000000000000000000",
api_key_changed=True,
model_configurations=[
ModelConfigurationUpsertRequest(
name="gpt-4o", is_visible=True, supports_image_input=True
),
ModelConfigurationUpsertRequest(
name="gpt-4o-mini", is_visible=False
),
],
),
db_session=db_session,
)
model_visibility = {
mc.name: mc.is_visible for mc in updated.model_configurations
}
assert model_visibility["gpt-4o"] is True
assert model_visibility["gpt-4o-mini"] is False

View File

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

@@ -8,16 +8,16 @@
"name": "onyx-desktop",
"version": "0.0.0-dev",
"dependencies": {
"@tauri-apps/api": "^2.10.1"
"@tauri-apps/api": "^2.0.0"
},
"devDependencies": {
"@tauri-apps/cli": "^2.10.1"
"@tauri-apps/cli": "^2.0.0"
}
},
"node_modules/@tauri-apps/api": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz",
"integrity": "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==",
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.9.1.tgz",
"integrity": "sha512-IGlhP6EivjXHepbBic618GOmiWe4URJiIeZFlB7x3czM0yDHHYviH1Xvoiv4FefdkQtn6v7TuwWCRfOGdnVUGw==",
"license": "Apache-2.0 OR MIT",
"funding": {
"type": "opencollective",
@@ -25,9 +25,9 @@
}
},
"node_modules/@tauri-apps/cli": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.10.1.tgz",
"integrity": "sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g==",
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.9.6.tgz",
"integrity": "sha512-3xDdXL5omQ3sPfBfdC8fCtDKcnyV7OqyzQgfyT5P3+zY6lcPqIYKQBvUasNvppi21RSdfhy44ttvJmftb0PCDw==",
"dev": true,
"license": "Apache-2.0 OR MIT",
"bin": {
@@ -41,23 +41,23 @@
"url": "https://opencollective.com/tauri"
},
"optionalDependencies": {
"@tauri-apps/cli-darwin-arm64": "2.10.1",
"@tauri-apps/cli-darwin-x64": "2.10.1",
"@tauri-apps/cli-linux-arm-gnueabihf": "2.10.1",
"@tauri-apps/cli-linux-arm64-gnu": "2.10.1",
"@tauri-apps/cli-linux-arm64-musl": "2.10.1",
"@tauri-apps/cli-linux-riscv64-gnu": "2.10.1",
"@tauri-apps/cli-linux-x64-gnu": "2.10.1",
"@tauri-apps/cli-linux-x64-musl": "2.10.1",
"@tauri-apps/cli-win32-arm64-msvc": "2.10.1",
"@tauri-apps/cli-win32-ia32-msvc": "2.10.1",
"@tauri-apps/cli-win32-x64-msvc": "2.10.1"
"@tauri-apps/cli-darwin-arm64": "2.9.6",
"@tauri-apps/cli-darwin-x64": "2.9.6",
"@tauri-apps/cli-linux-arm-gnueabihf": "2.9.6",
"@tauri-apps/cli-linux-arm64-gnu": "2.9.6",
"@tauri-apps/cli-linux-arm64-musl": "2.9.6",
"@tauri-apps/cli-linux-riscv64-gnu": "2.9.6",
"@tauri-apps/cli-linux-x64-gnu": "2.9.6",
"@tauri-apps/cli-linux-x64-musl": "2.9.6",
"@tauri-apps/cli-win32-arm64-msvc": "2.9.6",
"@tauri-apps/cli-win32-ia32-msvc": "2.9.6",
"@tauri-apps/cli-win32-x64-msvc": "2.9.6"
}
},
"node_modules/@tauri-apps/cli-darwin-arm64": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.10.1.tgz",
"integrity": "sha512-Z2OjCXiZ+fbYZy7PmP3WRnOpM9+Fy+oonKDEmUE6MwN4IGaYqgceTjwHucc/kEEYZos5GICve35f7ZiizgqEnQ==",
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.9.6.tgz",
"integrity": "sha512-gf5no6N9FCk1qMrti4lfwP77JHP5haASZgVbBgpZG7BUepB3fhiLCXGUK8LvuOjP36HivXewjg72LTnPDScnQQ==",
"cpu": [
"arm64"
],
@@ -72,9 +72,9 @@
}
},
"node_modules/@tauri-apps/cli-darwin-x64": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.10.1.tgz",
"integrity": "sha512-V/irQVvjPMGOTQqNj55PnQPVuH4VJP8vZCN7ajnj+ZS8Kom1tEM2hR3qbbIRoS3dBKs5mbG8yg1WC+97dq17Pw==",
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.9.6.tgz",
"integrity": "sha512-oWh74WmqbERwwrwcueJyY6HYhgCksUc6NT7WKeXyrlY/FPmNgdyQAgcLuTSkhRFuQ6zh4Np1HZpOqCTpeZBDcw==",
"cpu": [
"x64"
],
@@ -89,9 +89,9 @@
}
},
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.10.1.tgz",
"integrity": "sha512-Hyzwsb4VnCWKGfTw+wSt15Z2pLw2f0JdFBfq2vHBOBhvg7oi6uhKiF87hmbXOBXUZaGkyRDkCHsdzJcIfoJC2w==",
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.9.6.tgz",
"integrity": "sha512-/zde3bFroFsNXOHN204DC2qUxAcAanUjVXXSdEGmhwMUZeAQalNj5cz2Qli2elsRjKN/hVbZOJj0gQ5zaYUjSg==",
"cpu": [
"arm"
],
@@ -106,9 +106,9 @@
}
},
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.10.1.tgz",
"integrity": "sha512-OyOYs2t5GkBIvyWjA1+h4CZxTcdz1OZPCWAPz5DYEfB0cnWHERTnQ/SLayQzncrT0kwRoSfSz9KxenkyJoTelA==",
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.9.6.tgz",
"integrity": "sha512-pvbljdhp9VOo4RnID5ywSxgBs7qiylTPlK56cTk7InR3kYSTJKYMqv/4Q/4rGo/mG8cVppesKIeBMH42fw6wjg==",
"cpu": [
"arm64"
],
@@ -123,9 +123,9 @@
}
},
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.10.1.tgz",
"integrity": "sha512-MIj78PDDGjkg3NqGptDOGgfXks7SYJwhiMh8SBoZS+vfdz7yP5jN18bNaLnDhsVIPARcAhE1TlsZe/8Yxo2zqg==",
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.9.6.tgz",
"integrity": "sha512-02TKUndpodXBCR0oP//6dZWGYcc22Upf2eP27NvC6z0DIqvkBBFziQUcvi2n6SrwTRL0yGgQjkm9K5NIn8s6jw==",
"cpu": [
"arm64"
],
@@ -140,9 +140,9 @@
}
},
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.10.1.tgz",
"integrity": "sha512-X0lvOVUg8PCVaoEtEAnpxmnkwlE1gcMDTqfhbefICKDnOTJ5Est3qL0SrWxizDackIOKBcvtpejrSiVpuJI1kw==",
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.9.6.tgz",
"integrity": "sha512-fmp1hnulbqzl1GkXl4aTX9fV+ubHw2LqlLH1PE3BxZ11EQk+l/TmiEongjnxF0ie4kV8DQfDNJ1KGiIdWe1GvQ==",
"cpu": [
"riscv64"
],
@@ -157,9 +157,9 @@
}
},
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.10.1.tgz",
"integrity": "sha512-2/12bEzsJS9fAKybxgicCDFxYD1WEI9kO+tlDwX5znWG2GwMBaiWcmhGlZ8fi+DMe9CXlcVarMTYc0L3REIRxw==",
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.9.6.tgz",
"integrity": "sha512-vY0le8ad2KaV1PJr+jCd8fUF9VOjwwQP/uBuTJvhvKTloEwxYA/kAjKK9OpIslGA9m/zcnSo74czI6bBrm2sYA==",
"cpu": [
"x64"
],
@@ -174,9 +174,9 @@
}
},
"node_modules/@tauri-apps/cli-linux-x64-musl": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.10.1.tgz",
"integrity": "sha512-Y8J0ZzswPz50UcGOFuXGEMrxbjwKSPgXftx5qnkuMs2rmwQB5ssvLb6tn54wDSYxe7S6vlLob9vt0VKuNOaCIQ==",
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.9.6.tgz",
"integrity": "sha512-TOEuB8YCFZTWVDzsO2yW0+zGcoMiPPwcUgdnW1ODnmgfwccpnihDRoks+ABT1e3fHb1ol8QQWsHSCovb3o2ENQ==",
"cpu": [
"x64"
],
@@ -191,9 +191,9 @@
}
},
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.10.1.tgz",
"integrity": "sha512-iSt5B86jHYAPJa/IlYw++SXtFPGnWtFJriHn7X0NFBVunF6zu9+/zOn8OgqIWSl8RgzhLGXQEEtGBdR4wzpVgg==",
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.9.6.tgz",
"integrity": "sha512-ujmDGMRc4qRLAnj8nNG26Rlz9klJ0I0jmZs2BPpmNNf0gM/rcVHhqbEkAaHPTBVIrtUdf7bGvQAD2pyIiUrBHQ==",
"cpu": [
"arm64"
],
@@ -208,9 +208,9 @@
}
},
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.10.1.tgz",
"integrity": "sha512-gXyxgEzsFegmnWywYU5pEBURkcFN/Oo45EAwvZrHMh+zUSEAvO5E8TXsgPADYm31d1u7OQU3O3HsYfVBf2moHw==",
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.9.6.tgz",
"integrity": "sha512-S4pT0yAJgFX8QRCyKA1iKjZ9Q/oPjCZf66A/VlG5Yw54Nnr88J1uBpmenINbXxzyhduWrIXBaUbEY1K80ZbpMg==",
"cpu": [
"ia32"
],
@@ -225,9 +225,9 @@
}
},
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.10.1.tgz",
"integrity": "sha512-6Cn7YpPFwzChy0ERz6djKEmUehWrYlM+xTaNzGPgZocw3BD7OfwfWHKVWxXzdjEW2KfKkHddfdxK1XXTYqBRLg==",
"version": "2.9.6",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.9.6.tgz",
"integrity": "sha512-ldWuWSSkWbKOPjQMJoYVj9wLHcOniv7diyI5UAJ4XsBdtaFB0pKHQsqw/ItUma0VXGC7vB4E9fZjivmxur60aw==",
"cpu": [
"x64"
],

View File

@@ -9,10 +9,10 @@
"build:dmg": "tauri build --target universal-apple-darwin",
"build:linux": "tauri build --bundles deb,rpm"
},
"dependencies": {
"@tauri-apps/api": "^2.10.1"
},
"devDependencies": {
"@tauri-apps/cli": "^2.10.1"
"@tauri-apps/cli": "^2.0.0"
},
"dependencies": {
"@tauri-apps/api": "^2.0.0"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -6,18 +6,18 @@ authors = ["you"]
edition = "2021"
[build-dependencies]
tauri-build = { version = "2.5", features = [] }
tauri-build = { version = "2.0", features = [] }
[dependencies]
tauri = { version = "2.10", features = ["macos-private-api", "tray-icon", "image-png"] }
tauri-plugin-shell = "2.3.5"
tauri-plugin-window-state = "2.4.1"
tauri = { version = "2.0", features = ["macos-private-api", "tray-icon", "image-png"] }
tauri-plugin-shell = "2.0"
tauri-plugin-window-state = "2.0"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
uuid = { version = "1.0", features = ["v4"] }
directories = "5.0"
tokio = { version = "1", features = ["time"] }
window-vibrancy = "0.7.1"
window-vibrancy = "0.5"
url = "2.5"
[features]

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

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

@@ -7,13 +7,15 @@ import { InMessageImage } from "@/app/app/components/files/images/InMessageImage
import CsvContent from "@/components/tools/CSVContent";
import TextViewModal from "@/sections/modals/TextViewModal";
import { MinimalOnyxDocument } from "@/lib/search/interfaces";
import { cn } from "@/lib/utils";
import ExpandableContentWrapper from "@/components/tools/ExpandableContentWrapper";
interface FileDisplayProps {
files: FileDescriptor[];
alignBubble?: boolean;
}
export default function FileDisplay({ files }: FileDisplayProps) {
export default function FileDisplay({ files, alignBubble }: FileDisplayProps) {
const [close, setClose] = useState(true);
const [previewingFile, setPreviewingFile] = useState<FileDescriptor | null>(
null
@@ -41,47 +43,59 @@ export default function FileDisplay({ files }: FileDisplayProps) {
)}
{textFiles.length > 0 && (
<div id="onyx-file" className="flex flex-col items-end gap-2 py-2">
{textFiles.map((file) => (
<Attachment
key={file.id}
fileName={file.name || file.id}
open={() => setPreviewingFile(file)}
/>
))}
<div
id="onyx-file"
className={cn("m-2 auto", alignBubble && "ml-auto")}
>
<div className="flex flex-col items-end gap-2">
{textFiles.map((file) => (
<Attachment
key={file.id}
fileName={file.name || file.id}
open={() => setPreviewingFile(file)}
/>
))}
</div>
</div>
)}
{imageFiles.length > 0 && (
<div id="onyx-image" className="flex flex-col items-end gap-2 py-2">
{imageFiles.map((file) => (
<InMessageImage key={file.id} fileId={file.id} />
))}
<div
id="onyx-image"
className={cn("m-2 auto", alignBubble && "ml-auto")}
>
<div className="flex flex-col items-end gap-2">
{imageFiles.map((file) => (
<InMessageImage key={file.id} fileId={file.id} />
))}
</div>
</div>
)}
{csvFiles.length > 0 && (
<div className="flex flex-col items-end gap-2 py-2">
{csvFiles.map((file) => {
return (
<div key={file.id} className="w-fit">
{close ? (
<>
<ExpandableContentWrapper
fileDescriptor={file}
close={() => setClose(false)}
ContentComponent={CsvContent}
<div className={cn("m-2 auto", alignBubble && "ml-auto")}>
<div className="flex flex-col items-end gap-2">
{csvFiles.map((file) => {
return (
<div key={file.id} className="w-fit">
{close ? (
<>
<ExpandableContentWrapper
fileDescriptor={file}
close={() => setClose(false)}
ContentComponent={CsvContent}
/>
</>
) : (
<Attachment
open={() => setClose(true)}
fileName={file.name || file.id}
/>
</>
) : (
<Attachment
open={() => setClose(true)}
fileName={file.name || file.id}
/>
)}
</div>
);
})}
)}
</div>
);
})}
</div>
</div>
)}
</>

View File

@@ -195,7 +195,7 @@ const HumanMessage = React.memo(function HumanMessage({
id="onyx-human-message"
className="group flex flex-col justify-end w-full relative"
>
<FileDisplay files={files || []} />
<FileDisplay alignBubble files={files || []} />
{isEditing ? (
<MessageEditing
content={content}

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