Compare commits

..

5 Commits

Author SHA1 Message Date
Dane Urban
2d9ccd8bc9 Refacttor get tokenizer to not rely on llm 2026-03-02 20:42:54 -08:00
Raunak Bhagat
24ac8b37d3 refactor(fe): define settings layout width presets as CSS variables (#8936) 2026-03-03 03:11:18 +00:00
Jessica Singh
be8b108ae4 chore(auth): ecs fargate deployment cleanup (#8589) 2026-03-03 02:34:04 +00:00
Danelegend
f380a75df3 fix: Non-intuitive llm auth exceptions (#8960) 2026-03-03 01:58:45 +00:00
Wenxi
21ec93663b chore: proxy cloud ph (#8961) 2026-03-03 01:43:15 +00:00
14 changed files with 104 additions and 294 deletions

View File

@@ -52,7 +52,7 @@ def create_user_files(
) -> CategorizedFilesResult:
# Categorize the files
categorized_files = categorize_uploaded_files(files)
categorized_files = categorize_uploaded_files(files, db_session)
# NOTE: At the moment, zip metadata is not used for user files.
# Should revisit to decide whether this should be a feature.
upload_response = upload_files(categorized_files.acceptable, FileOrigin.USER_FILE)

View File

@@ -67,6 +67,18 @@ Status checked against LiteLLM v1.81.6-nightly (2026-02-02):
STATUS: STILL NEEDED - litellm_core_utils/litellm_logging.py lines 3185-3199 set
usage as a dict with chat completion format instead of keeping it as
ResponseAPIUsage. Our patch creates a deep copy before modification.
7. Responses API metadata=None TypeError (_patch_responses_metadata_none):
- LiteLLM's @client decorator wrapper in utils.py uses kwargs.get("metadata", {})
to check for router calls, but when metadata is explicitly None (key exists with
value None), the default {} is not used
- This causes "argument of type 'NoneType' is not iterable" TypeError which swallows
the real exception (e.g. AuthenticationError for wrong API key)
- Surfaces as: APIConnectionError: OpenAIException - argument of type 'NoneType' is
not iterable
STATUS: STILL NEEDED - litellm/utils.py wrapper function (line 1721) does not guard
against metadata being explicitly None. Triggered when Responses API bridge
passes **litellm_params containing metadata=None.
"""
import time
@@ -725,6 +737,44 @@ def _patch_logging_assembled_streaming_response() -> None:
LiteLLMLoggingObj._get_assembled_streaming_response = _patched_get_assembled_streaming_response # type: ignore[method-assign]
def _patch_responses_metadata_none() -> None:
"""
Patches litellm.responses to normalize metadata=None to metadata={} in kwargs.
LiteLLM's @client decorator wrapper in utils.py (line 1721) does:
_is_litellm_router_call = "model_group" in kwargs.get("metadata", {})
When metadata is explicitly None in kwargs, kwargs.get("metadata", {}) returns
None (the key exists, so the default is not used), causing:
TypeError: argument of type 'NoneType' is not iterable
This swallows the real exception (e.g. AuthenticationError) and surfaces as:
APIConnectionError: OpenAIException - argument of type 'NoneType' is not iterable
This happens when the Responses API bridge calls litellm.responses() with
**litellm_params which may contain metadata=None.
STATUS: STILL NEEDED - litellm/utils.py wrapper function uses kwargs.get("metadata", {})
which does not guard against metadata being explicitly None. Same pattern exists
on line 1407 for async path.
"""
import litellm as _litellm
from functools import wraps
original_responses = _litellm.responses
if getattr(original_responses, "_metadata_patched", False):
return
@wraps(original_responses)
def _patched_responses(*args: Any, **kwargs: Any) -> Any:
if kwargs.get("metadata") is None:
kwargs["metadata"] = {}
return original_responses(*args, **kwargs)
_patched_responses._metadata_patched = True # type: ignore[attr-defined]
_litellm.responses = _patched_responses
def apply_monkey_patches() -> None:
"""
Apply all necessary monkey patches to LiteLLM for compatibility.
@@ -736,6 +786,7 @@ def apply_monkey_patches() -> None:
- Patching AzureOpenAIResponsesAPIConfig.should_fake_stream to enable native streaming
- Patching ResponsesAPIResponse.model_construct to fix usage format in all code paths
- Patching LiteLLMLoggingObj._get_assembled_streaming_response to avoid mutating original response
- Patching litellm.responses to fix metadata=None causing TypeError in error handling
"""
_patch_ollama_chunk_parser()
_patch_openai_responses_parallel_tool_calls()
@@ -743,3 +794,4 @@ def apply_monkey_patches() -> None:
_patch_azure_responses_should_fake_stream()
_patch_responses_api_usage_format()
_patch_logging_assembled_streaming_response()
_patch_responses_metadata_none()

View File

@@ -7,13 +7,14 @@ from PIL import UnidentifiedImageError
from pydantic import BaseModel
from pydantic import ConfigDict
from pydantic import Field
from sqlalchemy.orm import Session
from onyx.configs.app_configs import FILE_TOKEN_COUNT_THRESHOLD
from onyx.db.llm import fetch_default_llm_model
from onyx.file_processing.extract_file_text import extract_file_text
from onyx.file_processing.extract_file_text import get_file_ext
from onyx.file_processing.file_types import OnyxFileExtensions
from onyx.file_processing.password_validation import is_file_password_protected
from onyx.llm.factory import get_default_llm
from onyx.natural_language_processing.utils import get_tokenizer
from onyx.utils.logger import setup_logger
from shared_configs.configs import MULTI_TENANT
@@ -116,7 +117,9 @@ def estimate_image_tokens_for_upload(
pass
def categorize_uploaded_files(files: list[UploadFile]) -> CategorizedFiles:
def categorize_uploaded_files(
files: list[UploadFile], db_session: Session
) -> CategorizedFiles:
"""
Categorize uploaded files based on text extractability and tokenized length.
@@ -128,11 +131,11 @@ def categorize_uploaded_files(files: list[UploadFile]) -> CategorizedFiles:
"""
results = CategorizedFiles()
llm = get_default_llm()
default_model = fetch_default_llm_model(db_session)
tokenizer = get_tokenizer(
model_name=llm.config.model_name, provider_type=llm.config.model_provider
)
model_name = default_model.name if default_model else None
provider_type = default_model.llm_provider.provider if default_model else None
tokenizer = get_tokenizer(model_name=model_name, provider_type=provider_type)
# Check if threshold checks should be skipped
skip_threshold = False

View File

@@ -479,20 +479,10 @@ def put_llm_provider(
@admin_router.delete("/provider/{provider_id}")
def delete_llm_provider(
provider_id: int,
force: bool = Query(False),
_: User = Depends(current_admin_user),
db_session: Session = Depends(get_session),
) -> None:
try:
if not force:
model = fetch_default_llm_model(db_session)
if model and model.llm_provider_id == provider_id:
raise HTTPException(
status_code=400,
detail="Cannot delete the default LLM provider",
)
remove_llm_provider(db_session, provider_id)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))

View File

@@ -386,261 +386,6 @@ def test_delete_llm_provider(
assert provider_data is None
def test_delete_default_llm_provider_rejected(reset: None) -> None: # noqa: ARG001
"""Deleting the default LLM provider should return 400."""
admin_user = UserManager.create(name="admin_user")
# Create a provider
response = requests.put(
f"{API_SERVER_URL}/admin/llm/provider?is_creation=true",
headers=admin_user.headers,
json={
"name": "test-provider-default-delete",
"provider": LlmProviderNames.OPENAI,
"api_key": "sk-000000000000000000000000000000000000000000000000",
"model_configurations": [
ModelConfigurationUpsertRequest(
name="gpt-4o-mini", is_visible=True
).model_dump()
],
"is_public": True,
"groups": [],
},
)
assert response.status_code == 200
created_provider = response.json()
# Set this provider as the default
set_default_response = requests.post(
f"{API_SERVER_URL}/admin/llm/default",
headers=admin_user.headers,
json={
"provider_id": created_provider["id"],
"model_name": "gpt-4o-mini",
},
)
assert set_default_response.status_code == 200
# Attempt to delete the default provider — should be rejected
delete_response = requests.delete(
f"{API_SERVER_URL}/admin/llm/provider/{created_provider['id']}",
headers=admin_user.headers,
)
assert delete_response.status_code == 400
assert "Cannot delete the default LLM provider" in delete_response.json()["detail"]
# Verify provider still exists
provider_data = _get_provider_by_id(admin_user, created_provider["id"])
assert provider_data is not None
def test_delete_non_default_llm_provider_with_default_set(
reset: None, # noqa: ARG001
) -> None:
"""Deleting a non-default provider should succeed even when a default is set."""
admin_user = UserManager.create(name="admin_user")
# Create two providers
response_default = requests.put(
f"{API_SERVER_URL}/admin/llm/provider?is_creation=true",
headers=admin_user.headers,
json={
"name": "default-provider",
"provider": LlmProviderNames.OPENAI,
"api_key": "sk-000000000000000000000000000000000000000000000000",
"model_configurations": [
ModelConfigurationUpsertRequest(
name="gpt-4o-mini", is_visible=True
).model_dump()
],
"is_public": True,
"groups": [],
},
)
assert response_default.status_code == 200
default_provider = response_default.json()
response_other = requests.put(
f"{API_SERVER_URL}/admin/llm/provider?is_creation=true",
headers=admin_user.headers,
json={
"name": "other-provider",
"provider": LlmProviderNames.OPENAI,
"api_key": "sk-000000000000000000000000000000000000000000000000",
"model_configurations": [
ModelConfigurationUpsertRequest(
name="gpt-4o", is_visible=True
).model_dump()
],
"is_public": True,
"groups": [],
},
)
assert response_other.status_code == 200
other_provider = response_other.json()
# Set the first provider as default
set_default_response = requests.post(
f"{API_SERVER_URL}/admin/llm/default",
headers=admin_user.headers,
json={
"provider_id": default_provider["id"],
"model_name": "gpt-4o-mini",
},
)
assert set_default_response.status_code == 200
# Delete the non-default provider — should succeed
delete_response = requests.delete(
f"{API_SERVER_URL}/admin/llm/provider/{other_provider['id']}",
headers=admin_user.headers,
)
assert delete_response.status_code == 200
# Verify the non-default provider is gone
provider_data = _get_provider_by_id(admin_user, other_provider["id"])
assert provider_data is None
# Verify the default provider still exists
default_data = _get_provider_by_id(admin_user, default_provider["id"])
assert default_data is not None
def test_force_delete_default_llm_provider(
reset: None, # noqa: ARG001
) -> None:
"""Force-deleting the default LLM provider should succeed."""
admin_user = UserManager.create(name="admin_user")
# Create a provider
response = requests.put(
f"{API_SERVER_URL}/admin/llm/provider?is_creation=true",
headers=admin_user.headers,
json={
"name": "test-provider-force-delete",
"provider": LlmProviderNames.OPENAI,
"api_key": "sk-000000000000000000000000000000000000000000000000",
"model_configurations": [
ModelConfigurationUpsertRequest(
name="gpt-4o-mini", is_visible=True
).model_dump()
],
"is_public": True,
"groups": [],
},
)
assert response.status_code == 200
created_provider = response.json()
# Set this provider as the default
set_default_response = requests.post(
f"{API_SERVER_URL}/admin/llm/default",
headers=admin_user.headers,
json={
"provider_id": created_provider["id"],
"model_name": "gpt-4o-mini",
},
)
assert set_default_response.status_code == 200
# Attempt to delete without force — should be rejected
delete_response = requests.delete(
f"{API_SERVER_URL}/admin/llm/provider/{created_provider['id']}",
headers=admin_user.headers,
)
assert delete_response.status_code == 400
# Force delete — should succeed
force_delete_response = requests.delete(
f"{API_SERVER_URL}/admin/llm/provider/{created_provider['id']}?force=true",
headers=admin_user.headers,
)
assert force_delete_response.status_code == 200
# Verify provider is gone
provider_data = _get_provider_by_id(admin_user, created_provider["id"])
assert provider_data is None
def test_delete_default_vision_provider_clears_vision_default(
reset: None, # noqa: ARG001
) -> None:
"""Deleting the default vision provider should succeed and clear the vision default."""
admin_user = UserManager.create(name="admin_user")
# Create a text provider and set it as default (so we have a default text provider)
text_response = requests.put(
f"{API_SERVER_URL}/admin/llm/provider?is_creation=true",
headers=admin_user.headers,
json={
"name": "text-provider",
"provider": LlmProviderNames.OPENAI,
"api_key": "sk-000000000000000000000000000000000000000000000001",
"model_configurations": [
ModelConfigurationUpsertRequest(
name="gpt-4o-mini", is_visible=True
).model_dump()
],
"is_public": True,
"groups": [],
},
)
assert text_response.status_code == 200
text_provider = text_response.json()
_set_default_provider(admin_user, text_provider["id"], "gpt-4o-mini")
# Create a vision provider and set it as default vision
vision_response = requests.put(
f"{API_SERVER_URL}/admin/llm/provider?is_creation=true",
headers=admin_user.headers,
json={
"name": "vision-provider",
"provider": LlmProviderNames.OPENAI,
"api_key": "sk-000000000000000000000000000000000000000000000002",
"model_configurations": [
ModelConfigurationUpsertRequest(
name="gpt-4o",
is_visible=True,
supports_image_input=True,
).model_dump()
],
"is_public": True,
"groups": [],
},
)
assert vision_response.status_code == 200
vision_provider = vision_response.json()
_set_default_vision_provider(admin_user, vision_provider["id"], "gpt-4o")
# Verify vision default is set
data = _get_providers_admin(admin_user)
assert data is not None
_, _, vision_default = _unpack_data(data)
assert vision_default is not None
assert vision_default["provider_id"] == vision_provider["id"]
# Delete the vision provider — should succeed (only text default is protected)
delete_response = requests.delete(
f"{API_SERVER_URL}/admin/llm/provider/{vision_provider['id']}",
headers=admin_user.headers,
)
assert delete_response.status_code == 200
# Verify the vision provider is gone
provider_data = _get_provider_by_id(admin_user, vision_provider["id"])
assert provider_data is None
# Verify there is no default vision provider
data = _get_providers_admin(admin_user)
assert data is not None
_, text_default, vision_default = _unpack_data(data)
assert vision_default is None
# Verify the text default is still intact
assert text_default is not None
assert text_default["provider_id"] == text_provider["id"]
def test_duplicate_provider_name_rejected(reset: None) -> None: # noqa: ARG001
"""Creating a provider with a name that already exists should return 400."""
admin_user = UserManager.create(name="admin_user")

View File

@@ -126,7 +126,9 @@ Resources:
- Effect: Allow
Action:
- secretsmanager:GetSecretValue
Resource: !Sub arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:${Environment}/postgres/user/password-*
Resource:
- !Sub arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:${Environment}/postgres/user/password-*
- !Sub arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:${Environment}/onyx/user-auth-secret-*
Outputs:
OutputEcsCluster:

View File

@@ -167,10 +167,12 @@ Resources:
- ImportedNamespace: !ImportValue
Fn::Sub: "${Environment}-onyx-cluster-OnyxNamespaceName"
- Name: AUTH_TYPE
Value: disabled
Value: basic
Secrets:
- Name: POSTGRES_PASSWORD
ValueFrom: !Sub arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:${Environment}/postgres/user/password
- Name: USER_AUTH_SECRET
ValueFrom: !Sub arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:${Environment}/onyx/user-auth-secret
VolumesFrom: []
SystemControls: []

View File

@@ -166,9 +166,11 @@ Resources:
- ImportedNamespace: !ImportValue
Fn::Sub: "${Environment}-onyx-cluster-OnyxNamespaceName"
- Name: AUTH_TYPE
Value: disabled
Value: basic
Secrets:
- Name: POSTGRES_PASSWORD
ValueFrom: !Sub arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:${Environment}/postgres/user/password
- Name: USER_AUTH_SECRET
ValueFrom: !Sub arn:aws:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:${Environment}/onyx/user-auth-secret
VolumesFrom: []
SystemControls: []

View File

@@ -78,6 +78,16 @@ const nextConfig = {
},
async rewrites() {
return [
{
source: "/ph_ingest/static/:path*",
destination: "https://us-assets.i.posthog.com/static/:path*",
},
{
source: "/ph_ingest/:path*",
destination: `${
process.env.NEXT_PUBLIC_POSTHOG_HOST || "https://us.i.posthog.com"
}/:path*`,
},
{
source: "/api/docs/:path*", // catch /api/docs and /api/docs/...
destination: `${

View File

@@ -1,4 +1,10 @@
:root {
--app-page-main-content-width: 52.5rem;
--block-width-form-input-min: 10rem;
--container-sm: 42rem;
--container-sm-md: 47rem;
--container-md: 54.5rem;
--container-lg: 62rem;
--container-full: 100%;
}

View File

@@ -3,9 +3,7 @@ import posthog from "posthog-js";
import { PostHogProvider } from "posthog-js/react";
import { useEffect } from "react";
const isPostHogEnabled = !!(
process.env.NEXT_PUBLIC_POSTHOG_KEY && process.env.NEXT_PUBLIC_POSTHOG_HOST
);
const isPostHogEnabled = !!process.env.NEXT_PUBLIC_POSTHOG_KEY;
type PHProviderProps = { children: React.ReactNode };
@@ -13,7 +11,9 @@ export function PHProvider({ children }: PHProviderProps) {
useEffect(() => {
if (isPostHogEnabled) {
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY!, {
api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST!,
api_host: "/ph_ingest",
ui_host:
process.env.NEXT_PUBLIC_POSTHOG_HOST || "https://us.posthog.com",
person_profiles: "identified_only",
capture_pageview: false,
session_recording: {

View File

@@ -43,8 +43,11 @@ import { Content } from "@opal/layouts";
import Spacer from "@/refresh-components/Spacer";
const widthClasses = {
md: "w-[min(50rem,100%)]",
lg: "w-[min(60rem,100%)]",
sm: "w-[min(var(--container-sm),100%)]",
"sm-md": "w-[min(var(--container-sm-md),100%)]",
md: "w-[min(var(--container-md),100%)]",
lg: "w-[min(var(--container-lg),100%)]",
full: "w-[var(--container-full)]",
};
/**
@@ -57,18 +60,19 @@ const widthClasses = {
* - Full height container with centered content
* - Automatic overflow-y scrolling
* - Contains the scroll container ID that Settings.Header uses for shadow detection
* - Configurable width: "md" (50rem max) or "full" (full width with 4rem padding)
* - Configurable width via CSS variables defined in sizes.css:
* "sm" (672px), "sm-md" (752px), "md" (872px, default), "lg" (992px), "full" (100%)
*
* @example
* ```tsx
* // Default medium width (50rem max)
* // Default medium width (872px max)
* <SettingsLayouts.Root>
* <SettingsLayouts.Header {...} />
* <SettingsLayouts.Body>...</SettingsLayouts.Body>
* </SettingsLayouts.Root>
*
* // Full width with padding
* <SettingsLayouts.Root width="full">
* // Large width (992px max)
* <SettingsLayouts.Root width="lg">
* <SettingsLayouts.Header {...} />
* <SettingsLayouts.Body>...</SettingsLayouts.Body>
* </SettingsLayouts.Root>

View File

@@ -20,7 +20,7 @@ async function deleteAllProviders(client: OnyxApiClient): Promise<void> {
const providers = await client.listLlmProviders();
for (const provider of providers) {
try {
await client.deleteProvider(provider.id, { force: true });
await client.deleteProvider(provider.id);
} catch (error) {
console.warn(
`Failed to delete provider ${provider.id}: ${String(error)}`

View File

@@ -526,14 +526,8 @@ export class OnyxApiClient {
*
* @param providerId - The provider ID to delete
*/
async deleteProvider(
providerId: number,
{ force = false }: { force?: boolean } = {}
): Promise<void> {
const query = force ? "?force=true" : "";
const response = await this.delete(
`/admin/llm/provider/${providerId}${query}`
);
async deleteProvider(providerId: number): Promise<void> {
const response = await this.delete(`/admin/llm/provider/${providerId}`);
await this.handleResponseSoft(
response,