Compare commits

..

23 Commits

Author SHA1 Message Date
Wenxi Onyx
6d304e35a3 . 2026-04-21 16:08:18 -07:00
Wenxi Onyx
4c78da6eba accessbility fixes 2026-04-21 16:07:45 -07:00
Wenxi Onyx
f10abbfa12 feat: add accessbility testing groundwork 2026-04-21 15:46:08 -07:00
Jamison Lahman
aac98cc217 chore(tests): run test_run_multitenant_migrations.py w/ database tests (#10438) 2026-04-21 22:13:57 +00:00
Jessica Singh
0c140b137d chore(indexing): remove multilingual_expansion from search settings (#10282) 2026-04-21 22:10:51 +00:00
Bo-Onyx
b6e31f46b2 fix(pruning): Set pruning frequency default to 25 days and establish backend as source of truth (#10431) 2026-04-21 21:14:58 +00:00
dependabot[bot]
725495da36 chore(deps): bump rustls-webpki from 0.103.10 to 0.103.12 in /desktop/src-tauri (#10394)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-21 21:10:21 +00:00
Raunak Bhagat
15fc815d04 fix: add missing headerPadding="sm" to all CardLayout.Header consumers (#10433) 2026-04-21 14:11:02 -07:00
dependabot[bot]
f387561bb3 chore(deps): rm ddtrace (#10382)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Jamison Lahman <jamison@lahman.dev>
2026-04-21 20:58:00 +00:00
Raunak Bhagat
aa3ca737f5 refactor(refresh-components): underline Tab variant, Text color inherit, LineItem wrapDescription (#10430) 2026-04-21 20:46:23 +00:00
dependabot[bot]
b22d67c4cf chore(deps): bump brace-expansion in /web (#10426)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Jamison Lahman <jamison@lahman.dev>
2026-04-21 20:09:11 +00:00
Raunak Bhagat
a0ba989388 refactor(opal): add root.css with library-owned size tokens (#10427) 2026-04-21 12:58:54 -07:00
dependabot[bot]
95b353d666 chore(deps): bump yarl from 1.22.0 to 1.23.0 in /backend (#10387)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Jamison Lahman <jamison@lahman.dev>
2026-04-21 19:47:49 +00:00
dependabot[bot]
757bb0d045 chore(deps): bump mcp from 1.26.0 to 1.27.0 in /backend (#10384)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Jamison Lahman <jamison@lahman.dev>
2026-04-21 19:39:36 +00:00
Raunak Bhagat
c35f99a811 refactor(opal): add headerPadding prop to Card.Header layout (#10425) 2026-04-21 12:37:43 -07:00
Raunak Bhagat
609cc83c41 feat(opal): add headerPadding prop + wire status border on MessageCard (#10424) 2026-04-21 12:32:10 -07:00
Raunak Bhagat
5a472e95be fix(opal): keep expandable card separator line default border color (#10423) 2026-04-21 19:16:26 +00:00
Nikolas Garza
bfed70d078 feat(invites): add tenant_invite_event table to cap trial tenant lifetime invites (#10373) 2026-04-21 19:16:25 +00:00
Bo-Onyx
15e1189393 feat(llm-context): Add ods install-skill to wire up llm context skills (#10418) 2026-04-21 19:08:15 +00:00
Evan Lohn
f9f5a9b766 fix: unchanged client id not set in backend (#10419) 2026-04-21 18:51:01 +00:00
Raunak Bhagat
07aa92dabf feat(opal): add SvgNoImage and SvgSlowTime icons (#10420) 2026-04-21 11:53:04 -07:00
dependabot[bot]
b22d393da4 chore(deps): bump actions/upload-artifact from 7.0.0 to 7.0.1 (#10386)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-21 18:47:16 +00:00
Nikolas Garza
2256357cf9 feat(auth): per-IP rate limit on /auth/register (#10405) 2026-04-21 18:43:52 +00:00
90 changed files with 2461 additions and 637 deletions

View File

@@ -19,6 +19,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
iptables \
ipset \
iproute2 \
dnsmasq \
dnsutils \
unzip \
wget \

View File

@@ -1,6 +1,6 @@
{
"name": "Onyx Dev Sandbox",
"image": "onyxdotapp/onyx-devcontainer@sha256:4986c9252289b660ce772b45f0488b938fe425d8114245e96ef64b273b3fcee4",
"image": "onyxdotapp/onyx-devcontainer@sha256:9aedd9b6e127c7e23c57eb05bbe14f466dec3093d26bfdb82c3299b275211419",
"runArgs": [
"--cap-add=NET_ADMIN",
"--cap-add=NET_RAW",

View File

@@ -0,0 +1,32 @@
# Local resolver for the devcontainer. init-firewall.sh launches dnsmasq with
# this config and points /etc/resolv.conf at 127.0.0.1.
#
# The `ipset=` directives are the reason this exists: every A record dnsmasq
# returns for a listed domain is added to the `allowed-domains` ipset that
# iptables matches on. This keeps the allowlist correct as CDN IPs rotate
# (Fastly, Cloudflare, etc.) — the one-shot getent-at-boot approach in
# init-firewall.sh cannot do that.
no-resolv
no-hosts
# Forward upstream to Docker's embedded resolver.
server=127.0.0.11
# Only answer on loopback.
listen-address=127.0.0.1
bind-interfaces
cache-size=1000
# Domains whose resolved IPs should be added to the firewall allowlist.
# Keep in sync with ALLOWED_DOMAINS in init-firewall.sh.
ipset=/github.com/api.github.com/allowed-domains
ipset=/registry.npmjs.org/deb.nodesource.com/allowed-domains
ipset=/api.anthropic.com/api-staging.anthropic.com/files.anthropic.com/allowed-domains
ipset=/sentry.io/update.code.visualstudio.com/allowed-domains
ipset=/pypi.org/files.pythonhosted.org/allowed-domains
ipset=/go.dev/proxy.golang.org/sum.golang.org/allowed-domains
ipset=/storage.googleapis.com/dl.google.com/allowed-domains
ipset=/static.rust-lang.org/index.crates.io/static.crates.io/allowed-domains
ipset=/archive.ubuntu.com/security.ubuntu.com/allowed-domains

View File

@@ -121,4 +121,16 @@ if ! timeout 5 curl -s https://api.github.com/meta > /dev/null; then
echo "Warning: GitHub API is not accessible"
fi
# Start dnsmasq and point the container resolver at it. dnsmasq's ipset=
# directives add every resolved A record for allowlisted domains into the
# `allowed-domains` ipset at resolve time, keeping the firewall in step with
# CDN IP rotation.
pkill -x dnsmasq 2>/dev/null || true
dnsmasq -C /workspace/.devcontainer/dnsmasq.conf
cat > /etc/resolv.conf <<EOF
nameserver 127.0.0.1
options edns0 trust-ad
EOF
echo "Firewall setup complete"

View File

@@ -105,7 +105,7 @@ jobs:
- name: Upload build artifacts
if: always()
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
with:
name: desktop-build-${{ matrix.platform }}-${{ github.run_id }}
path: |

View File

@@ -184,7 +184,7 @@ jobs:
- name: Upload Docker logs
if: failure()
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
with:
name: docker-logs-${{ matrix.test-dir }}
path: docker-logs/

View File

@@ -468,7 +468,7 @@ jobs:
- name: Upload logs
if: always()
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
with:
name: docker-all-logs-${{ matrix.edition }}-${{ matrix.test-dir.name }}
path: ${{ github.workspace }}/docker-compose.log
@@ -589,7 +589,7 @@ jobs:
- name: Upload logs (onyx-lite)
if: always()
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
with:
name: docker-all-logs-onyx-lite
path: ${{ github.workspace }}/docker-compose-onyx-lite.log
@@ -727,7 +727,7 @@ jobs:
- name: Upload logs (multi-tenant)
if: always()
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
with:
name: docker-all-logs-multitenant
path: ${{ github.workspace }}/docker-compose-multitenant.log

View File

@@ -44,7 +44,7 @@ jobs:
- name: Upload coverage reports
if: always()
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
with:
name: jest-coverage-${{ github.run_id }}
path: ./web/coverage

View File

@@ -445,7 +445,7 @@ jobs:
run: |
npx playwright test --project ${PROJECT}
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
if: always()
with:
# Includes test results and trace.zip files
@@ -454,7 +454,7 @@ jobs:
retention-days: 30
- name: Upload screenshots
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
if: always()
with:
name: playwright-screenshots-${{ matrix.project }}-${{ github.run_id }}
@@ -534,7 +534,7 @@ jobs:
"s3://${PLAYWRIGHT_S3_BUCKET}/reports/pr-${PR_NUMBER}/${RUN_ID}/${PROJECT}/"
- name: Upload visual diff summary
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
if: always()
with:
name: screenshot-diff-summary-${{ matrix.project }}
@@ -543,7 +543,7 @@ jobs:
retention-days: 5
- name: Upload visual diff report artifact
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
if: always()
with:
name: screenshot-diff-report-${{ matrix.project }}-${{ github.run_id }}
@@ -590,7 +590,7 @@ jobs:
- name: Upload logs
if: success() || failure()
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
with:
name: docker-logs-${{ matrix.project }}-${{ github.run_id }}
path: ${{ github.workspace }}/docker-compose.log
@@ -674,7 +674,7 @@ jobs:
working-directory: ./web
run: npx playwright test --project lite
- uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f
- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
if: always()
with:
name: playwright-test-results-lite-${{ github.run_id }}
@@ -692,7 +692,7 @@ jobs:
- name: Upload logs
if: success() || failure()
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
with:
name: docker-logs-lite-${{ github.run_id }}
path: ${{ github.workspace }}/docker-compose.log

View File

@@ -121,7 +121,7 @@ jobs:
- name: Upload logs
if: always()
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
with:
name: docker-all-logs
path: ${{ github.workspace }}/docker-compose.log

View File

@@ -319,7 +319,7 @@ jobs:
- name: Upload logs
if: always()
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a
with:
name: docker-all-logs-nightly-${{ matrix.provider }}-llm-provider
path: |

2
.gitignore vendored
View File

@@ -61,4 +61,4 @@ node_modules
plans/
# Added context for LLMs
onyx-llm-context/
.claude/CLAUDE.md

View File

@@ -0,0 +1,33 @@
"""remove multilingual_expansion from search_settings
Revision ID: a7c3e2b1d4f8
Revises: 856bcbe14d79
Create Date: 2026-04-16
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = "a7c3e2b1d4f8"
down_revision = "856bcbe14d79"
branch_labels: None = None
depends_on: None = None
def upgrade() -> None:
op.drop_column("search_settings", "multilingual_expansion")
def downgrade() -> None:
op.add_column(
"search_settings",
sa.Column(
"multilingual_expansion",
postgresql.ARRAY(sa.String()),
nullable=False,
server_default="{}",
),
)

View File

@@ -0,0 +1,79 @@
"""add_tenant_invite_counter_table
Revision ID: d4e7a92c1b38
Revises: 3b9f09038764
Create Date: 2026-04-20 18:00:00.000000
Adds `public.tenant_invite_counter`, the lifetime invite-quota counter used by
the trial-tenant cap in `bulk_invite_users`. One row per tenant; holds a
monotonically-incremented total of invites ever reserved by that tenant.
Why we need it:
Trial tenants are capped at NUM_FREE_TRIAL_USER_INVITES per lifetime.
A counter derived from the mutable KV-backed invited-users list can be
reset by the remove-invited-user endpoint (each removal pops a KV
entry, lowering the effective count), allowing the cap to be bypassed
by looping invite → remove → invite. This table stores a counter that
is only ever incremented; no endpoint decrements it, so removals do
not free up quota.
How it works:
Each call to `bulk_invite_users` for a trial tenant runs a single atomic
UPSERT:
INSERT INTO public.tenant_invite_counter (tenant_id, total_invites_sent)
VALUES (:tid, :n)
ON CONFLICT (tenant_id) DO UPDATE
SET total_invites_sent = tenant_invite_counter.total_invites_sent + EXCLUDED.total_invites_sent,
updated_at = NOW()
RETURNING total_invites_sent;
The UPDATE takes a row-level lock on `tenant_id`, so concurrent bulk-
invite flows for the same tenant are serialized without an advisory
lock. If the returned total exceeds the cap the caller ROLLBACKs so the
reservation does not stick. Paid tenants skip this path entirely.
Deploy-time behavior:
The table ships empty. Trial tenants with pre-existing KV invited-users
entries are not seeded, so each one's counter starts at 0 and can
issue one additional full batch (up to NUM_FREE_TRIAL_USER_INVITES)
before the monotonic guard engages. Scope of the gap is bounded to
one batch per trial tenant and does not recur; backfill was
intentionally skipped to keep this migration pure-DDL.
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "d4e7a92c1b38"
down_revision = "3b9f09038764"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"tenant_invite_counter",
sa.Column("tenant_id", sa.String(), nullable=False),
sa.Column(
"total_invites_sent",
sa.Integer(),
nullable=False,
server_default="0",
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.PrimaryKeyConstraint("tenant_id"),
schema="public",
)
def downgrade() -> None:
op.drop_table("tenant_invite_counter", schema="public")

View File

@@ -0,0 +1,77 @@
"""Per-IP rate limit on email/password signup."""
import ipaddress
import time
from fastapi import Request
from onyx.configs.app_configs import SIGNUP_RATE_LIMIT_ENABLED
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.redis.redis_pool import get_async_redis_connection
from onyx.utils.logger import setup_logger
from shared_configs.configs import MULTI_TENANT
logger = setup_logger()
_PER_IP_PER_HOUR = 5
_BUCKET_SECONDS = 3600
_REDIS_KEY_PREFIX = "signup_rate:"
def _is_usable_client_ip(ip_str: str) -> bool:
try:
ip = ipaddress.ip_address(ip_str)
except ValueError:
return False
return ip.is_global
def _client_ip(request: Request) -> str:
xff = request.headers.get("x-forwarded-for")
if xff:
parts = [p.strip() for p in xff.split(",") if p.strip()]
if parts and _is_usable_client_ip(parts[0]):
return parts[0]
return request.client.host if request.client else "unknown"
def _bucket_key(ip: str) -> str:
bucket = int(time.time() // _BUCKET_SECONDS)
return f"{_REDIS_KEY_PREFIX}{ip}:{bucket}"
async def enforce_signup_rate_limit(request: Request) -> None:
"""Raise OnyxError(RATE_LIMITED) when the client exceeds the signup cap."""
if not (MULTI_TENANT and SIGNUP_RATE_LIMIT_ENABLED):
return
ip = _client_ip(request)
key = _bucket_key(ip)
try:
redis = await get_async_redis_connection()
pipe = redis.pipeline()
pipe.incr(key)
pipe.expire(key, _BUCKET_SECONDS)
incr_result, _ = await pipe.execute()
count = int(incr_result)
except Exception as e:
logger.error(f"Signup rate-limit Redis error: {e}")
return
if count > _PER_IP_PER_HOUR:
logger.warning(f"Signup rate limit exceeded for ip={ip} count={count}")
raise OnyxError(
OnyxErrorCode.RATE_LIMITED,
"Too many signup attempts from this network. Please wait before trying again.",
)
__all__ = [
"enforce_signup_rate_limit",
"_PER_IP_PER_HOUR",
"_BUCKET_SECONDS",
"_client_ip",
"_bucket_key",
]

View File

@@ -82,6 +82,7 @@ from onyx.auth.pat import get_hashed_pat_from_request
from onyx.auth.schemas import AuthBackend
from onyx.auth.schemas import UserCreate
from onyx.auth.schemas import UserRole
from onyx.auth.signup_rate_limit import enforce_signup_rate_limit
from onyx.configs.app_configs import AUTH_BACKEND
from onyx.configs.app_configs import AUTH_COOKIE_EXPIRE_TIME_SECONDS
from onyx.configs.app_configs import AUTH_TYPE
@@ -398,6 +399,9 @@ class UserManager(UUIDIDMixin, BaseUserManager[User, uuid.UUID]):
)
raise
if request is not None:
await enforce_signup_rate_limit(request)
# Verify captcha if enabled (for cloud signup protection)
from onyx.auth.captcha import CaptchaVerificationError
from onyx.auth.captcha import is_captcha_enabled

View File

@@ -100,7 +100,6 @@ from onyx.llm.factory import get_llm_for_persona
from onyx.llm.factory import get_llm_token_counter
from onyx.llm.interfaces import LLM
from onyx.llm.interfaces import LLMUserIdentity
from onyx.llm.multi_llm import LLMTimeoutError
from onyx.llm.override_models import LLMOverride
from onyx.llm.request_context import reset_llm_mock_response
from onyx.llm.request_context import set_llm_mock_response
@@ -1278,32 +1277,6 @@ def _run_models(
else:
if item is _MODEL_DONE:
models_remaining -= 1
elif isinstance(item, LLMTimeoutError):
model_llm = setup.llms[model_idx]
error_msg = (
"The LLM took too long to respond. "
"If you're running a local model, try increasing the "
"LLM_SOCKET_READ_TIMEOUT environment variable "
"(current default: 120 seconds)."
)
stack_trace = "".join(
traceback.format_exception(type(item), item, item.__traceback__)
)
if model_llm.config.api_key and len(model_llm.config.api_key) > 2:
stack_trace = stack_trace.replace(
model_llm.config.api_key, "[REDACTED_API_KEY]"
)
yield StreamingError(
error=error_msg,
stack_trace=stack_trace,
error_code="CONNECTION_ERROR",
is_retryable=True,
details={
"model": model_llm.config.model_name,
"provider": model_llm.config.model_provider,
"model_index": model_idx,
},
)
elif isinstance(item, Exception):
# Yield a tagged error for this model but keep the other models running.
# Do NOT decrement models_remaining — _run_model's finally always posts

View File

@@ -775,7 +775,7 @@ LEAVE_CONNECTOR_ACTIVE_ON_INITIALIZATION_FAILURE = (
== "true"
)
DEFAULT_PRUNING_FREQ = 60 * 60 * 24 # Once a day
DEFAULT_PRUNING_FREQ = 60 * 60 * 24 * 25 # 25 days
ALLOW_SIMULTANEOUS_PRUNING = (
os.environ.get("ALLOW_SIMULTANEOUS_PRUNING", "").lower() == "true"
@@ -1162,6 +1162,11 @@ RECAPTCHA_SECRET_KEY = os.environ.get("RECAPTCHA_SECRET_KEY", "")
# 0.5 is the recommended default
RECAPTCHA_SCORE_THRESHOLD = float(os.environ.get("RECAPTCHA_SCORE_THRESHOLD", "0.5"))
# Opt-in per-IP rate limit on /auth/register.
SIGNUP_RATE_LIMIT_ENABLED = (
os.environ.get("SIGNUP_RATE_LIMIT_ENABLED", "").lower() == "true"
)
MOCK_CONNECTOR_FILE_PATH = os.environ.get("MOCK_CONNECTOR_FILE_PATH")
# Set to true to mock LLM responses for testing purposes

View File

@@ -8,14 +8,13 @@ from sqlalchemy.orm import selectinload
from sqlalchemy.orm import Session
from onyx.configs.constants import FederatedConnectorSource
from onyx.configs.constants import MASK_CREDENTIAL_CHAR
from onyx.configs.constants import MASK_CREDENTIAL_LONG_RE
from onyx.db.engine.sql_engine import get_session_with_current_tenant
from onyx.db.models import DocumentSet
from onyx.db.models import FederatedConnector
from onyx.db.models import FederatedConnector__DocumentSet
from onyx.db.models import FederatedConnectorOAuthToken
from onyx.federated_connectors.factory import get_federated_connector
from onyx.utils.encryption import reject_masked_credentials
from onyx.utils.logger import setup_logger
logger = setup_logger()
@@ -47,23 +46,6 @@ def fetch_all_federated_connectors_parallel() -> list[FederatedConnector]:
return fetch_all_federated_connectors(db_session)
def _reject_masked_credentials(credentials: dict[str, Any]) -> None:
"""Raise if any credential string value contains mask placeholder characters.
mask_string() has two output formats:
- Short strings (< 14 chars): "••••••••••••" (U+2022 BULLET)
- Long strings (>= 14 chars): "abcd...wxyz" (first4 + "..." + last4)
Both must be rejected.
"""
for key, val in credentials.items():
if isinstance(val, str) and (
MASK_CREDENTIAL_CHAR in val or MASK_CREDENTIAL_LONG_RE.match(val)
):
raise ValueError(
f"Credential field '{key}' contains masked placeholder characters. Please provide the actual credential value."
)
def validate_federated_connector_credentials(
source: FederatedConnectorSource,
credentials: dict[str, Any],
@@ -85,7 +67,7 @@ def create_federated_connector(
config: dict[str, Any] | None = None,
) -> FederatedConnector:
"""Create a new federated connector with credential and config validation."""
_reject_masked_credentials(credentials)
reject_masked_credentials(credentials)
# Validate credentials before creating
if not validate_federated_connector_credentials(source, credentials):
@@ -298,7 +280,7 @@ def update_federated_connector(
)
if credentials is not None:
_reject_masked_credentials(credentials)
reject_masked_credentials(credentials)
# Validate credentials before updating
if not validate_federated_connector_credentials(

View File

@@ -2100,10 +2100,6 @@ class SearchSettings(Base):
String, nullable=True
)
multilingual_expansion: Mapped[list[str]] = mapped_column(
postgresql.ARRAY(String), default=[]
)
cloud_provider: Mapped["CloudEmbeddingProvider"] = relationship(
"CloudEmbeddingProvider",
back_populates="search_settings",
@@ -4593,6 +4589,25 @@ class TenantAnonymousUserPath(Base):
)
# Lifetime invite counter per tenant. Incremented atomically on every
# invite reservation; never decremented — removals do not free quota, so
# loops of invite → remove → invite cannot bypass the trial cap.
class TenantInviteCounter(Base):
__tablename__ = "tenant_invite_counter"
__table_args__ = {"schema": "public"}
tenant_id: Mapped[str] = mapped_column(String, primary_key=True)
total_invites_sent: Mapped[int] = mapped_column(
Integer, nullable=False, default=0, server_default="0"
)
updated_at: Mapped[datetime.datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
class MCPServer(Base):
"""Model for storing MCP server configurations"""

View File

@@ -6,7 +6,6 @@ from sqlalchemy.orm import Session
from onyx.configs.model_configs import DEFAULT_DOCUMENT_ENCODER_MODEL
from onyx.configs.model_configs import DOCUMENT_ENCODER_MODEL
from onyx.context.search.models import SavedSearchSettings
from onyx.db.engine.sql_engine import get_session_with_current_tenant
from onyx.db.llm import fetch_embedding_provider
from onyx.db.models import CloudEmbeddingProvider
from onyx.db.models import IndexAttempt
@@ -177,17 +176,6 @@ def get_all_search_settings(db_session: Session) -> list[SearchSettings]:
return list(all_settings)
def get_multilingual_expansion(db_session: Session | None = None) -> list[str]:
if db_session is None:
with get_session_with_current_tenant() as db_session:
search_settings = get_current_search_settings(db_session)
else:
search_settings = get_current_search_settings(db_session)
if not search_settings:
return []
return search_settings.multilingual_expansion
def update_search_settings(
current_settings: SearchSettings,
updated_settings: SavedSearchSettings,

View File

@@ -0,0 +1,62 @@
from sqlalchemy import func
from sqlalchemy import update
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import Session
from onyx.db.models import TenantInviteCounter
def reserve_trial_invites(
shared_session: Session,
tenant_id: str,
num_invites: int,
) -> int:
"""Atomically increment the tenant's invite counter by `num_invites`.
Returns the post-increment total. The caller is expected to compare
against the trial cap and rollback the session if the total exceeds
it — the UPSERT's UPDATE leg holds a row-level lock on `tenant_id`
for the duration of the transaction, serializing concurrent reservers
for the same tenant.
"""
stmt = pg_insert(TenantInviteCounter).values(
tenant_id=tenant_id,
total_invites_sent=num_invites,
)
stmt = stmt.on_conflict_do_update(
index_elements=[TenantInviteCounter.tenant_id],
set_={
"total_invites_sent": TenantInviteCounter.total_invites_sent
+ stmt.excluded.total_invites_sent,
"updated_at": func.now(),
},
).returning(TenantInviteCounter.total_invites_sent)
return int(shared_session.execute(stmt).scalar_one())
def release_trial_invites(
shared_session: Session,
tenant_id: str,
num_invites: int,
) -> None:
"""Compensating decrement of the counter by `num_invites`, clamped at 0.
Only called when a downstream step (KV write, billing register, etc.)
fails after the counter has already been incremented, so the counter
tracks invites that actually reached the system rather than merely
reserved. The counter is monotonic with respect to user actions — no
user-facing endpoint decrements it — but it is reconciled downward by
this function when the system fails mid-flow. No-op if the tenant has
no counter row.
"""
stmt = (
update(TenantInviteCounter)
.where(TenantInviteCounter.tenant_id == tenant_id)
.values(
total_invites_sent=func.greatest(
TenantInviteCounter.total_invites_sent - num_invites, 0
),
updated_at=func.now(),
)
)
shared_session.execute(stmt)

View File

@@ -68,6 +68,7 @@ class OnyxErrorCode(Enum):
# ------------------------------------------------------------------
RATE_LIMITED = ("RATE_LIMITED", 429)
SEAT_LIMIT_EXCEEDED = ("SEAT_LIMIT_EXCEEDED", 402)
TRIAL_INVITE_LIMIT_EXCEEDED = ("TRIAL_INVITE_LIMIT_EXCEEDED", 403)
# ------------------------------------------------------------------
# Payload (413)

View File

@@ -290,11 +290,7 @@ def litellm_exception_to_error_msg(
error_code = "BUDGET_EXCEEDED"
is_retryable = False
elif isinstance(core_exception, Timeout):
error_msg = (
"The LLM took too long to respond. "
"If you're running a local model, try increasing the "
"LLM_SOCKET_READ_TIMEOUT environment variable (current default: 120 seconds)."
)
error_msg = "Request timed out: The operation took too long to complete. Please try again."
error_code = "CONNECTION_ERROR"
is_retryable = True
elif isinstance(core_exception, APIError):

View File

@@ -82,6 +82,7 @@ from onyx.tools.tool_implementations.mcp.mcp_client import discover_mcp_tools
from onyx.tools.tool_implementations.mcp.mcp_client import initialize_mcp_client
from onyx.tools.tool_implementations.mcp.mcp_client import log_exception_group
from onyx.utils.encryption import mask_string
from onyx.utils.encryption import reject_masked_credentials
from onyx.utils.logger import setup_logger
logger = setup_logger()
@@ -96,30 +97,65 @@ def _truncate_description(description: str | None, max_length: int = 500) -> str
return description[: max_length - 3] + "..."
# TODO: Replace mask-comparison approach with an explicit Unset sentinel from the
# frontend indicating whether each credential field was actually modified. The current
# approach is brittle (e.g. short credentials produce a fixed-length mask that could
# collide) and mutates request values, which is surprising. The frontend should signal
# "unchanged" vs "new value" directly rather than relying on masked-string equality.
def _restore_masked_oauth_credentials(
def _resolve_oauth_credentials(
*,
request_client_id: str | None,
request_client_id_changed: bool,
request_client_secret: str | None,
existing_client: OAuthClientInformationFull,
request_client_secret_changed: bool,
existing_client: OAuthClientInformationFull | None,
) -> tuple[str | None, str | None]:
"""If the frontend sent back masked credentials, restore the real stored values."""
if (
request_client_id
and existing_client.client_id
and request_client_id == mask_string(existing_client.client_id)
):
request_client_id = existing_client.client_id
if (
request_client_secret
and existing_client.client_secret
and request_client_secret == mask_string(existing_client.client_secret)
):
request_client_secret = existing_client.client_secret
return request_client_id, request_client_secret
"""Pick the effective client_id / client_secret for an upsert/connect.
Mirrors the LLM-provider `api_key_changed` pattern: when the frontend
flags a field as unchanged, ignore whatever value it sent (it is most
likely a masked placeholder) and reuse the stored value. When the
frontend flags a field as changed, take the request value as-is, but
defensively reject masked placeholders so a buggy client can't write
a mask to the database.
"""
resolved_id = request_client_id
if not request_client_id_changed:
resolved_id = existing_client.client_id if existing_client else None
elif resolved_id:
reject_masked_credentials({"oauth_client_id": resolved_id})
resolved_secret = request_client_secret
if not request_client_secret_changed:
resolved_secret = existing_client.client_secret if existing_client else None
elif resolved_secret:
reject_masked_credentials({"oauth_client_secret": resolved_secret})
return resolved_id, resolved_secret
def _build_oauth_admin_config_data(
*,
client_id: str | None,
client_secret: str | None,
) -> MCPConnectionData:
"""Construct the admin connection config payload for an OAuth client.
A public client legitimately has no `client_secret`, so we only require
a `client_id` to seed `client_info`. When no client_id is available we
fall through to an empty config (the OAuth provider will rely on
Dynamic Client Registration to obtain credentials).
"""
config_data = MCPConnectionData(headers={})
if not client_id:
return config_data
token_endpoint_auth_method = "client_secret_post" if client_secret else "none"
client_info = OAuthClientInformationFull(
client_id=client_id,
client_secret=client_secret,
redirect_uris=[AnyUrl(f"{WEB_DOMAIN}/mcp/oauth/callback")],
grant_types=["authorization_code", "refresh_token"],
response_types=["code"],
scope=REQUESTED_SCOPE, # TODO(evan): allow specifying scopes?
token_endpoint_auth_method=token_endpoint_auth_method,
)
config_data[MCPOAuthKeys.CLIENT_INFO.value] = client_info.model_dump(mode="json")
return config_data
router = APIRouter(prefix="/mcp")
@@ -420,8 +456,10 @@ async def _connect_oauth(
detail=f"Server was configured with authentication type {auth_type_str}",
)
# If the frontend sent back masked credentials (unchanged by the user),
# restore the real stored values so we don't overwrite them with masks.
# Resolve the effective OAuth credentials, falling back to the stored
# values for any field the frontend marked as unchanged. This protects
# against the resubmit case where the form replays masked placeholders.
existing_client: OAuthClientInformationFull | None = None
if mcp_server.admin_connection_config:
existing_data = extract_connection_data(
mcp_server.admin_connection_config, apply_mask=False
@@ -431,31 +469,19 @@ async def _connect_oauth(
existing_client = OAuthClientInformationFull.model_validate(
existing_client_raw
)
(
request.oauth_client_id,
request.oauth_client_secret,
) = _restore_masked_oauth_credentials(
request.oauth_client_id,
request.oauth_client_secret,
existing_client,
)
# Create admin config with client info if provided
config_data = MCPConnectionData(headers={})
if request.oauth_client_id and request.oauth_client_secret:
client_info = OAuthClientInformationFull(
client_id=request.oauth_client_id,
client_secret=request.oauth_client_secret,
redirect_uris=[AnyUrl(f"{WEB_DOMAIN}/mcp/oauth/callback")],
grant_types=["authorization_code", "refresh_token"],
response_types=["code"],
scope=REQUESTED_SCOPE, # TODO: allow specifying scopes?
# Must specify auth method so client_secret is actually sent during token exchange
token_endpoint_auth_method="client_secret_post",
)
config_data[MCPOAuthKeys.CLIENT_INFO.value] = client_info.model_dump(
mode="json"
)
request.oauth_client_id, request.oauth_client_secret = _resolve_oauth_credentials(
request_client_id=request.oauth_client_id,
request_client_id_changed=request.oauth_client_id_changed,
request_client_secret=request.oauth_client_secret,
request_client_secret_changed=request.oauth_client_secret_changed,
existing_client=existing_client,
)
config_data = _build_oauth_admin_config_data(
client_id=request.oauth_client_id,
client_secret=request.oauth_client_secret,
)
if mcp_server.admin_connection_config_id is None:
if not is_admin:
@@ -1404,17 +1430,20 @@ def _upsert_mcp_server(
if client_info_raw:
client_info = OAuthClientInformationFull.model_validate(client_info_raw)
# If the frontend sent back masked credentials (unchanged by the user),
# restore the real stored values so the comparison below sees no change
# and the credentials aren't overwritten with masked strings.
# Resolve the effective OAuth credentials, falling back to the stored
# values for any field the frontend marked as unchanged. This protects
# the change-detection comparison below from spurious diffs caused by
# masked placeholders being replayed.
if client_info and request.auth_type == MCPAuthenticationType.OAUTH:
(
request.oauth_client_id,
request.oauth_client_secret,
) = _restore_masked_oauth_credentials(
request.oauth_client_id,
request.oauth_client_secret,
client_info,
) = _resolve_oauth_credentials(
request_client_id=request.oauth_client_id,
request_client_id_changed=request.oauth_client_id_changed,
request_client_secret=request.oauth_client_secret,
request_client_secret_changed=request.oauth_client_secret_changed,
existing_client=client_info,
)
changing_connection_config = (

View File

@@ -75,6 +75,23 @@ class MCPToolCreateRequest(BaseModel):
)
oauth_client_id: Optional[str] = Field(None, description="OAuth client ID")
oauth_client_secret: Optional[str] = Field(None, description="OAuth client secret")
oauth_client_id_changed: bool = Field(
default=False,
description=(
"True if `oauth_client_id` was edited by the user. When False on an "
"update of an existing server, the stored value is reused and the "
"request value is ignored. Defaults to False for backward "
"compatibility with older clients that don't send the flag."
),
)
oauth_client_secret_changed: bool = Field(
default=False,
description=(
"True if `oauth_client_secret` was edited by the user. When False on "
"an update of an existing server, the stored value is reused and the "
"request value is ignored."
),
)
transport: MCPTransport | None = Field(
None, description="MCP transport type (STREAMABLE_HTTP or SSE)"
)
@@ -204,6 +221,21 @@ class MCPUserOAuthConnectRequest(BaseModel):
oauth_client_secret: str | None = Field(
None, description="OAuth client secret (optional for DCR)"
)
oauth_client_id_changed: bool = Field(
default=False,
description=(
"True if `oauth_client_id` was edited by the user. When False, "
"the stored value is reused and the request value is ignored. "
"Defaults to False for backward compatibility."
),
)
oauth_client_secret_changed: bool = Field(
default=False,
description=(
"True if `oauth_client_secret` was edited by the user. When False, "
"the stored value is reused and the request value is ignored."
),
)
@model_validator(mode="after")
def validate_return_path(self) -> "MCPUserOAuthConnectRequest":

View File

@@ -51,11 +51,14 @@ from onyx.configs.constants import PUBLIC_API_TAGS
from onyx.db.api_key import is_api_key_email_address
from onyx.db.auth import get_live_users_count
from onyx.db.engine.sql_engine import get_session
from onyx.db.engine.sql_engine import get_session_with_shared_schema
from onyx.db.enums import AccountType
from onyx.db.enums import Permission
from onyx.db.enums import UserFileStatus
from onyx.db.models import User
from onyx.db.models import UserFile
from onyx.db.tenant_invite_counter import release_trial_invites
from onyx.db.tenant_invite_counter import reserve_trial_invites
from onyx.db.user_preferences import activate_user
from onyx.db.user_preferences import deactivate_user
from onyx.db.user_preferences import get_all_user_assistant_specific_configs
@@ -466,15 +469,31 @@ def bulk_invite_users(
if e not in existing_users and e not in already_invited
]
# Limit bulk invites for trial tenants to prevent email spam
# Only count new invites, not re-invites of existing users
# Check seat availability for new users. Must run before the counter
# reservation below — a seat-limit failure must not burn trial quota.
if emails_needing_seats:
enforce_seat_limit(db_session, seats_needed=len(emails_needing_seats))
# Enforce the trial invite cap via the monotonic `tenant_invite_counter`.
# The UPSERT holds a row-level lock on `tenant_id` during the UPDATE, so
# concurrent bulk-invite flows for the same tenant are serialized without
# an advisory lock. On reject we ROLLBACK so the reservation does not stick.
trial_invite_reservation = 0
if MULTI_TENANT and is_tenant_on_trial_fn(tenant_id):
current_invited = len(already_invited)
if current_invited + len(emails_needing_seats) > NUM_FREE_TRIAL_USER_INVITES:
raise HTTPException(
status_code=403,
detail="You have hit your invite limit. Please upgrade for unlimited invites.",
)
num_new_invites = len(emails_needing_seats)
if num_new_invites > 0:
with get_session_with_shared_schema() as shared_session:
new_total = reserve_trial_invites(
shared_session, tenant_id, num_new_invites
)
if new_total > NUM_FREE_TRIAL_USER_INVITES:
shared_session.rollback()
raise OnyxError(
OnyxErrorCode.TRIAL_INVITE_LIMIT_EXCEEDED,
"You have hit your invite limit. Please upgrade for unlimited invites.",
)
shared_session.commit()
trial_invite_reservation = num_new_invites
enforce_invite_rate_limit(
redis_client=get_redis_client(tenant_id=tenant_id),
admin_user_id=current_user.id,
@@ -482,10 +501,6 @@ def bulk_invite_users(
tenant_id=tenant_id,
)
# Check seat availability for new users
if emails_needing_seats:
enforce_seat_limit(db_session, seats_needed=len(emails_needing_seats))
if MULTI_TENANT:
try:
fetch_ee_implementation_or_noop(
@@ -498,7 +513,29 @@ def bulk_invite_users(
initial_invited_users = get_invited_users()
all_emails = list(set(new_invited_emails) | set(initial_invited_users))
number_of_invited_users = write_invited_users(all_emails)
try:
number_of_invited_users = write_invited_users(all_emails)
except Exception:
# KV write failed after the counter already reserved slots. Release
# the reservation so the counter tracks invites that actually reached
# the store. Compensation failures are logged and never re-raised —
# the original KV error is what the caller needs to see.
if trial_invite_reservation > 0:
try:
with get_session_with_shared_schema() as comp_session:
release_trial_invites(
comp_session, tenant_id, trial_invite_reservation
)
comp_session.commit()
except Exception as comp_err:
logger.error(
"tenant_invite_counter release failed for tenant=%s, "
"slots burned=%d: %s",
tenant_id,
trial_invite_reservation,
comp_err,
)
raise
# send out email invitations only to new users (not already invited or existing)
if not ENABLE_EMAIL_INVITES:
@@ -526,10 +563,31 @@ def bulk_invite_users(
logger.info(
"Reverting changes: removing users from tenant and resetting invited users"
)
write_invited_users(initial_invited_users) # Reset to original state
fetch_ee_implementation_or_noop(
"onyx.server.tenants.user_mapping", "remove_users_from_tenant", None
)(new_invited_emails, tenant_id)
try:
write_invited_users(initial_invited_users) # Reset to original state
fetch_ee_implementation_or_noop(
"onyx.server.tenants.user_mapping", "remove_users_from_tenant", None
)(new_invited_emails, tenant_id)
finally:
# Release the counter reservation regardless of whether the KV /
# user-mapping reverts above succeeded — otherwise a double-fault
# (billing failure + revert failure) permanently inflates the
# counter for an invite batch the system considers rolled back.
if trial_invite_reservation > 0:
try:
with get_session_with_shared_schema() as comp_session:
release_trial_invites(
comp_session, tenant_id, trial_invite_reservation
)
comp_session.commit()
except Exception as comp_err:
logger.error(
"tenant_invite_counter release failed for tenant=%s, "
"slots burned=%d: %s",
tenant_id,
trial_invite_reservation,
comp_err,
)
raise e
return BulkInviteResponse(

View File

@@ -4,6 +4,7 @@ from enum import Enum
from pydantic import BaseModel
from pydantic import Field
from onyx.configs.app_configs import DEFAULT_PRUNING_FREQ
from onyx.configs.app_configs import DEFAULT_USER_FILE_MAX_UPLOAD_SIZE_MB
from onyx.configs.app_configs import DISABLE_VECTOR_DB
from onyx.configs.app_configs import MAX_ALLOWED_UPLOAD_SIZE_MB
@@ -125,6 +126,7 @@ class UserSettings(Settings):
# Hard ceiling for user_file_max_upload_size_mb, derived from env var.
max_allowed_upload_size_mb: int = MAX_ALLOWED_UPLOAD_SIZE_MB
# Factory defaults so the frontend can show a "restore default" button.
default_pruning_freq: int = DEFAULT_PRUNING_FREQ
default_user_file_max_upload_size_mb: int = DEFAULT_USER_FILE_MAX_UPLOAD_SIZE_MB
default_file_token_count_threshold_k: int = Field(
default_factory=lambda: (

View File

@@ -108,12 +108,6 @@ def setup_onyx(
logger.notice(f'Query embedding prefix: "{search_settings.query_prefix}"')
logger.notice(f'Passage embedding prefix: "{search_settings.passage_prefix}"')
if search_settings:
if search_settings.multilingual_expansion:
logger.notice(
f"Multilingual query expansion is enabled with {search_settings.multilingual_expansion}."
)
# setup Postgres with default credential, llm providers, etc.
setup_postgres(db_session)

View File

@@ -1,6 +1,8 @@
from typing import Any
from onyx.configs.app_configs import ENCRYPTION_KEY_SECRET
from onyx.configs.constants import MASK_CREDENTIAL_CHAR
from onyx.configs.constants import MASK_CREDENTIAL_LONG_RE
from onyx.connectors.google_utils.shared_constants import (
DB_CREDENTIALS_AUTHENTICATION_METHOD,
)
@@ -42,6 +44,52 @@ def mask_string(sensitive_str: str) -> str:
return f"{sensitive_str[:visible_start]}...{sensitive_str[-visible_end:]}"
def is_masked_credential(value: str) -> bool:
"""Return True if the string looks like a `mask_string` placeholder.
`mask_string` has two output formats:
- Short strings (< 14 chars): "••••••••••••" (U+2022 BULLET)
- Long strings (>= 14 chars): "abcd...wxyz" (first4 + "..." + last4)
"""
return MASK_CREDENTIAL_CHAR in value or bool(MASK_CREDENTIAL_LONG_RE.match(value))
def reject_masked_credentials(credentials: dict[str, Any]) -> None:
"""Raise if any credential string value contains mask placeholder characters.
Used as a defensive net at write boundaries so that masked values
round-tripped from `mask_string` are never persisted as real credentials.
Recurses into nested dicts and lists to stay symmetric with
`mask_credential_dict`, which masks nested string values. The error
message includes a dotted path like `oauth.client_secret` or
`keys[2]` so callers can pinpoint the offending field.
"""
_reject_masked_in_dict(credentials, path="")
def _reject_masked_in_dict(credentials: dict[str, Any], path: str) -> None:
for key, val in credentials.items():
field_path = f"{path}.{key}" if path else key
_reject_masked_in_value(val, field_path)
def _reject_masked_in_value(val: Any, path: str) -> None:
if isinstance(val, str):
if is_masked_credential(val):
raise ValueError(
f"Credential field '{path}' contains masked placeholder "
"characters. Please provide the actual credential value."
)
return
if isinstance(val, dict):
_reject_masked_in_dict(val, path=path)
return
if isinstance(val, list):
for index, item in enumerate(val):
_reject_masked_in_value(item, f"{path}[{index}]")
MASK_CREDENTIALS_WHITELIST = {
DB_CREDENTIALS_AUTHENTICATION_METHOD,
"wiki_base",

View File

@@ -1,5 +1,5 @@
[pytest]
pythonpath =
pythonpath =
.
generated/onyx_openapi_client
asyncio_default_fixture_loop_scope = function
@@ -9,7 +9,6 @@ markers =
filterwarnings =
ignore::DeprecationWarning
ignore::cryptography.utils.CryptographyDeprecationWarning
ignore::PendingDeprecationWarning:ddtrace.internal.module
# .test.env is gitignored.
# After installing pytest-dotenv,
# you can use it to test credentials locally.

View File

@@ -98,8 +98,6 @@ botocore-stubs==1.40.74
braintrust==0.3.9
brotli==1.2.0
# via onyx
bytecode==0.17.0
# via ddtrace
cachetools==6.2.2
# via py-key-value-aio
caio==0.9.25
@@ -193,7 +191,6 @@ dataclasses-json==0.6.7
# via unstructured
dateparser==1.2.2
# via htmldate
ddtrace==3.10.0
decorator==5.2.1
# via retry
defusedxml==0.7.1
@@ -228,8 +225,6 @@ email-validator==2.2.0
# pydantic
emoji==2.15.0
# via unstructured
envier==0.6.1
# via ddtrace
et-xmlfile==2.0.0
# via openpyxl
events==0.5
@@ -464,8 +459,6 @@ langfuse==3.10.0
langsmith==0.7.32
# via langchain-core
lazy-imports==1.0.1
legacy-cgi==2.6.4 ; python_full_version >= '3.13'
# via ddtrace
litellm==1.81.6
# via onyx
locket==1.0.0
@@ -509,7 +502,7 @@ marshmallow==3.26.2
# via dataclasses-json
matrix-client==0.3.2
# via zulip
mcp==1.26.0
mcp==1.27.0
# via
# claude-agent-sdk
# fastmcp
@@ -578,7 +571,6 @@ openpyxl==3.0.10
opensearch-py==3.0.0
opentelemetry-api==1.39.1
# via
# ddtrace
# fastmcp
# langfuse
# openinference-instrumentation
@@ -661,7 +653,6 @@ proto-plus==1.26.1
# google-cloud-resource-manager
protobuf==6.33.5
# via
# ddtrace
# google-api-core
# google-cloud-aiplatform
# google-cloud-resource-manager
@@ -1008,7 +999,6 @@ typing-extensions==4.15.0
# boto3-stubs
# braintrust
# cohere
# ddtrace
# exa-py
# exceptiongroup
# fastapi
@@ -1116,7 +1106,6 @@ wrapt==1.17.3
# via
# aiobotocore
# braintrust
# ddtrace
# deprecated
# langfuse
# openinference-instrumentation
@@ -1127,11 +1116,9 @@ xlsxwriter==3.2.9
# via python-pptx
xmlsec==1.3.14
# via python3-saml
xmltodict==1.0.2
# via ddtrace
xxhash==3.6.0
# via langsmith
yarl==1.22.0
yarl==1.23.0
# via aiohttp
zeep==4.3.2
# via simple-salesforce

View File

@@ -285,7 +285,7 @@ matplotlib-inline==0.2.1
# via
# ipykernel
# ipython
mcp==1.26.0
mcp==1.27.0
# via claude-agent-sdk
mdurl==0.1.2
# via markdown-it-py
@@ -618,7 +618,7 @@ websockets==15.0.1
# via google-genai
wrapt==1.17.3
# via aiobotocore
yarl==1.22.0
yarl==1.23.0
# via aiohttp
zipp==3.23.0
# via importlib-metadata

View File

@@ -215,7 +215,7 @@ markdown-it-py==4.0.0
# via rich
markupsafe==3.0.3
# via jinja2
mcp==1.26.0
mcp==1.27.0
# via claude-agent-sdk
mdurl==0.1.2
# via markdown-it-py
@@ -430,7 +430,7 @@ websockets==15.0.1
# via google-genai
wrapt==1.17.3
# via aiobotocore
yarl==1.22.0
yarl==1.23.0
# via aiohttp
zipp==3.23.0
# via importlib-metadata

View File

@@ -247,7 +247,7 @@ markdown-it-py==4.0.0
# via rich
markupsafe==3.0.3
# via jinja2
mcp==1.26.0
mcp==1.27.0
# via claude-agent-sdk
mdurl==0.1.2
# via markdown-it-py
@@ -559,7 +559,7 @@ websockets==15.0.1
# via google-genai
wrapt==1.17.3
# via aiobotocore
yarl==1.22.0
yarl==1.23.0
# via aiohttp
zipp==3.23.0
# via importlib-metadata

View File

@@ -6,7 +6,7 @@ The script is invoked as a subprocess — the same way it would be used in
production. Tests verify exit codes and stdout messages.
Usage:
pytest tests/integration/tests/migrations/test_run_multitenant_migrations.py -v
pytest -m alembic tests/integration/tests/migrations/test_run_multitenant_migrations.py -v
"""
from __future__ import annotations
@@ -24,6 +24,8 @@ from sqlalchemy.engine import Engine
from onyx.db.engine.sql_engine import SqlEngine
pytestmark = pytest.mark.alembic
# Resolve the backend/ directory once so every helper can use it as cwd.
_BACKEND_DIR = os.path.normpath(
os.path.join(os.path.dirname(__file__), "..", "..", "..", "..")
@@ -43,14 +45,13 @@ def _run_script(
env_override: dict[str, str] | None = None,
) -> subprocess.CompletedProcess[str]:
"""Run ``python alembic/run_multitenant_migrations.py`` from the backend/ directory."""
env = {**os.environ, **(env_override or {})}
return subprocess.run(
[sys.executable, "alembic/run_multitenant_migrations.py", *extra_args],
cwd=_BACKEND_DIR,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
env=env,
env={**os.environ, "PYTHONPATH": _BACKEND_DIR, **(env_override or {})},
)
@@ -110,6 +111,7 @@ def current_head_rev() -> str:
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
env={**os.environ, "PYTHONPATH": _BACKEND_DIR},
)
assert (
result.returncode == 0

View File

@@ -1,7 +1,7 @@
import pytest
from onyx.configs.constants import MASK_CREDENTIAL_CHAR
from onyx.db.federated import _reject_masked_credentials
from onyx.utils.encryption import reject_masked_credentials
class TestRejectMaskedCredentials:
@@ -10,24 +10,24 @@ class TestRejectMaskedCredentials:
mask_string() has two output formats:
- Short strings (< 14 chars): "••••••••••••" (U+2022 BULLET)
- Long strings (>= 14 chars): "abcd...wxyz" (first4 + "..." + last4)
_reject_masked_credentials must catch both.
reject_masked_credentials must catch both.
"""
def test_rejects_fully_masked_value(self) -> None:
masked = MASK_CREDENTIAL_CHAR * 12 # "••••••••••••"
with pytest.raises(ValueError, match="masked placeholder"):
_reject_masked_credentials({"client_id": masked})
reject_masked_credentials({"client_id": masked})
def test_rejects_long_string_masked_value(self) -> None:
"""mask_string returns 'first4...last4' for long strings — the real
format used for OAuth credentials like client_id and client_secret."""
with pytest.raises(ValueError, match="masked placeholder"):
_reject_masked_credentials({"client_id": "1234...7890"})
reject_masked_credentials({"client_id": "1234...7890"})
def test_rejects_when_any_field_is_masked(self) -> None:
"""Even if client_id is real, a masked client_secret must be caught."""
with pytest.raises(ValueError, match="client_secret"):
_reject_masked_credentials(
reject_masked_credentials(
{
"client_id": "1234567890.1234567890",
"client_secret": MASK_CREDENTIAL_CHAR * 12,
@@ -36,7 +36,7 @@ class TestRejectMaskedCredentials:
def test_accepts_real_credentials(self) -> None:
# Should not raise
_reject_masked_credentials(
reject_masked_credentials(
{
"client_id": "1234567890.1234567890",
"client_secret": "test_client_secret_value",
@@ -45,14 +45,63 @@ class TestRejectMaskedCredentials:
def test_accepts_empty_dict(self) -> None:
# Should not raise — empty credentials are handled elsewhere
_reject_masked_credentials({})
reject_masked_credentials({})
def test_ignores_non_string_values(self) -> None:
# Non-string values (None, bool, int) should pass through
_reject_masked_credentials(
reject_masked_credentials(
{
"client_id": "real_value",
"redirect_uri": None,
"some_flag": True,
}
)
def test_rejects_masked_value_inside_nested_dict(self) -> None:
"""`mask_credential_dict` recurses into nested dicts; the rejection
helper must do the same so a masked nested string can't slip
through on resubmit."""
with pytest.raises(ValueError, match=r"oauth\.client_secret"):
reject_masked_credentials(
{
"name": "fine",
"oauth": {
"client_id": "1234567890.1234567890",
"client_secret": "abcd...wxyz",
},
}
)
def test_rejects_masked_value_inside_list(self) -> None:
"""`_mask_list` masks string elements; the rejection helper must
catch them too."""
with pytest.raises(ValueError, match=r"keys\[1\]"):
reject_masked_credentials(
{
"keys": ["real-key-aaaa", "abcd...wxyz", "real-key-bbbb"],
}
)
def test_rejects_masked_value_inside_list_of_dicts(self) -> None:
with pytest.raises(ValueError, match=r"sessions\[0\]\.token"):
reject_masked_credentials(
{
"sessions": [
{"token": "abcd...wxyz"},
{"token": "real-token-value"},
],
}
)
def test_accepts_deeply_nested_real_values(self) -> None:
reject_masked_credentials(
{
"oauth": {
"client_id": "real-id-value-1234",
"extras": {
"scopes": ["read", "write"],
"metadata": {"region": "us-east-1"},
},
},
}
)

View File

@@ -0,0 +1,176 @@
"""Unit tests for the per-IP signup rate limiter."""
from unittest.mock import AsyncMock
from unittest.mock import MagicMock
from unittest.mock import patch
import pytest
from fastapi import Request
from onyx.auth import signup_rate_limit as rl
from onyx.auth.signup_rate_limit import _bucket_key
from onyx.auth.signup_rate_limit import _client_ip
from onyx.auth.signup_rate_limit import _PER_IP_PER_HOUR
from onyx.auth.signup_rate_limit import enforce_signup_rate_limit
from onyx.error_handling.exceptions import OnyxError
def _make_request(
xff: str | None = None, client_host: str | None = "1.2.3.4"
) -> Request:
scope: dict = {
"type": "http",
"method": "POST",
"path": "/auth/register",
"headers": [],
}
if xff is not None:
scope["headers"].append((b"x-forwarded-for", xff.encode()))
if client_host is not None:
scope["client"] = (client_host, 54321)
return Request(scope)
def _fake_pipeline_redis(incr_return: int) -> MagicMock:
"""Build a Redis mock whose pipeline().execute() yields [incr_return, ok]."""
pipeline = MagicMock()
pipeline.incr = MagicMock()
pipeline.expire = MagicMock()
pipeline.execute = AsyncMock(return_value=[incr_return, 1])
redis = MagicMock()
redis.pipeline = MagicMock(return_value=pipeline)
redis._pipeline = pipeline # type: ignore[attr-defined]
return redis
def test_client_ip_uses_leftmost_when_first_entry_is_public() -> None:
req = _make_request(xff="1.2.3.4, 10.0.0.42")
assert _client_ip(req) == "1.2.3.4"
def test_client_ip_falls_back_when_leftmost_is_private() -> None:
req = _make_request(xff="10.0.0.1, 1.2.3.4", client_host="5.6.7.8")
assert _client_ip(req) == "5.6.7.8"
def test_client_ip_falls_back_when_leftmost_is_loopback() -> None:
req = _make_request(xff="127.0.0.1", client_host="5.6.7.8")
assert _client_ip(req) == "5.6.7.8"
def test_client_ip_falls_back_when_xff_is_malformed() -> None:
req = _make_request(xff="not-an-ip, 1.2.3.4", client_host="10.0.0.1")
assert _client_ip(req) == "10.0.0.1"
def test_client_ip_falls_back_to_tcp_peer_when_xff_absent() -> None:
req = _make_request(xff=None, client_host="5.6.7.8")
assert _client_ip(req) == "5.6.7.8"
def test_client_ip_handles_no_client() -> None:
req = _make_request(xff=None, client_host=None)
assert _client_ip(req) == "unknown"
@pytest.mark.asyncio
async def test_disabled_when_not_multitenant() -> None:
req = _make_request(client_host="1.2.3.4")
fake_redis = MagicMock()
with (
patch.object(rl, "MULTI_TENANT", False),
patch.object(rl, "SIGNUP_RATE_LIMIT_ENABLED", True),
patch.object(
rl, "get_async_redis_connection", AsyncMock(return_value=fake_redis)
) as conn,
):
await enforce_signup_rate_limit(req)
conn.assert_not_awaited()
@pytest.mark.asyncio
async def test_disabled_when_enable_flag_off() -> None:
req = _make_request(client_host="1.2.3.4")
fake_redis = MagicMock()
with (
patch.object(rl, "MULTI_TENANT", True),
patch.object(rl, "SIGNUP_RATE_LIMIT_ENABLED", False),
patch.object(
rl, "get_async_redis_connection", AsyncMock(return_value=fake_redis)
) as conn,
):
await enforce_signup_rate_limit(req)
conn.assert_not_awaited()
@pytest.mark.asyncio
async def test_allows_when_under_limit() -> None:
"""Counts at or below the hourly cap do not raise."""
req = _make_request(xff="1.2.3.4, 10.0.0.1")
fake_redis = _fake_pipeline_redis(incr_return=_PER_IP_PER_HOUR)
with (
patch.object(rl, "MULTI_TENANT", True),
patch.object(rl, "SIGNUP_RATE_LIMIT_ENABLED", True),
patch.object(
rl, "get_async_redis_connection", AsyncMock(return_value=fake_redis)
),
):
await enforce_signup_rate_limit(req)
@pytest.mark.asyncio
async def test_rejects_when_over_limit() -> None:
"""Strictly above the cap → OnyxError.RATE_LIMITED (HTTP 429)."""
req = _make_request(xff="1.2.3.4, 10.0.0.1")
fake_redis = _fake_pipeline_redis(incr_return=_PER_IP_PER_HOUR + 1)
with (
patch.object(rl, "MULTI_TENANT", True),
patch.object(rl, "SIGNUP_RATE_LIMIT_ENABLED", True),
patch.object(
rl, "get_async_redis_connection", AsyncMock(return_value=fake_redis)
),
):
with pytest.raises(OnyxError) as exc_info:
await enforce_signup_rate_limit(req)
assert exc_info.value.error_code.status_code == 429
@pytest.mark.asyncio
async def test_pipeline_expire_runs_on_every_hit() -> None:
"""INCR and EXPIRE run in a single pipeline for atomicity."""
req = _make_request(xff="1.2.3.4, 10.0.0.1")
fake_redis = _fake_pipeline_redis(incr_return=3)
with (
patch.object(rl, "MULTI_TENANT", True),
patch.object(rl, "SIGNUP_RATE_LIMIT_ENABLED", True),
patch.object(
rl, "get_async_redis_connection", AsyncMock(return_value=fake_redis)
),
):
await enforce_signup_rate_limit(req)
fake_redis._pipeline.expire.assert_called_once()
@pytest.mark.asyncio
async def test_fails_open_on_redis_error() -> None:
"""Redis blip must NOT block legitimate signups."""
req = _make_request(xff="1.2.3.4, 10.0.0.1")
with (
patch.object(rl, "MULTI_TENANT", True),
patch.object(rl, "SIGNUP_RATE_LIMIT_ENABLED", True),
patch.object(
rl,
"get_async_redis_connection",
AsyncMock(side_effect=RuntimeError("redis down")),
),
):
await enforce_signup_rate_limit(req)
def test_bucket_keys_differ_across_ips() -> None:
"""Two different IPs in the same hour must not share a counter."""
a = _bucket_key("1.1.1.1")
b = _bucket_key("2.2.2.2")
assert a != b
assert a.startswith("signup_rate:1.1.1.1:")
assert b.startswith("signup_rate:2.2.2.2:")

View File

@@ -0,0 +1,207 @@
"""Unit tests for the MCP OAuth credentials resolver and config builder.
These tests cover the fix for the "resubmit unchanged wipes client_info" bug
described in `plans/mcp-oauth-resubmit-empty-secret-fix.md`. The resolver
mirrors the LLM-provider `api_key_changed` pattern: when the frontend marks a
credential field as unchanged, the backend reuses the stored value instead of
overwriting it with whatever (likely masked) string the form replayed.
"""
import pytest
from mcp.shared.auth import OAuthClientInformationFull
from pydantic import AnyUrl
from onyx.server.features.mcp.api import _build_oauth_admin_config_data
from onyx.server.features.mcp.api import _resolve_oauth_credentials
from onyx.server.features.mcp.models import MCPOAuthKeys
from onyx.utils.encryption import mask_string
def _make_existing_client(
*,
client_id: str = "stored-client-id",
client_secret: str | None = "stored-secret",
) -> OAuthClientInformationFull:
return OAuthClientInformationFull(
client_id=client_id,
client_secret=client_secret,
redirect_uris=[AnyUrl("https://example.com/callback")],
grant_types=["authorization_code", "refresh_token"],
response_types=["code"],
token_endpoint_auth_method=("client_secret_post" if client_secret else "none"),
)
class TestResolveOAuthCredentials:
def test_public_client_unchanged_resubmit_keeps_stored_values(self) -> None:
existing = _make_existing_client(client_id="abc", client_secret=None)
resolved_id, resolved_secret = _resolve_oauth_credentials(
request_client_id=mask_string("abc") if len("abc") >= 14 else "abc",
request_client_id_changed=False,
request_client_secret="",
request_client_secret_changed=False,
existing_client=existing,
)
assert resolved_id == "abc"
assert resolved_secret is None
def test_confidential_client_unchanged_resubmit_keeps_stored_values(self) -> None:
stored_id = "long-client-id-123456"
stored_secret = "long-client-secret-abcdef"
existing = _make_existing_client(
client_id=stored_id,
client_secret=stored_secret,
)
resolved_id, resolved_secret = _resolve_oauth_credentials(
request_client_id=mask_string(stored_id),
request_client_id_changed=False,
request_client_secret=mask_string(stored_secret),
request_client_secret_changed=False,
existing_client=existing,
)
assert resolved_id == stored_id
assert resolved_secret == stored_secret
def test_only_client_id_changed_keeps_stored_secret(self) -> None:
existing = _make_existing_client(
client_id="stored-id",
client_secret="stored-secret-value",
)
resolved_id, resolved_secret = _resolve_oauth_credentials(
request_client_id="brand-new-id",
request_client_id_changed=True,
request_client_secret=mask_string("stored-secret-value"),
request_client_secret_changed=False,
existing_client=existing,
)
assert resolved_id == "brand-new-id"
assert resolved_secret == "stored-secret-value"
def test_only_client_secret_changed_keeps_stored_id(self) -> None:
existing = _make_existing_client(
client_id="stored-client-id-1234",
client_secret="stored-secret",
)
resolved_id, resolved_secret = _resolve_oauth_credentials(
request_client_id=mask_string("stored-client-id-1234"),
request_client_id_changed=False,
request_client_secret="brand-new-secret",
request_client_secret_changed=True,
existing_client=existing,
)
assert resolved_id == "stored-client-id-1234"
assert resolved_secret == "brand-new-secret"
def test_changed_flag_with_long_masked_value_is_rejected(self) -> None:
existing = _make_existing_client(
client_id="real-stored-id-1234",
client_secret="real-stored-secret-1234",
)
with pytest.raises(ValueError, match="oauth_client_id"):
_resolve_oauth_credentials(
request_client_id=mask_string("some-other-long-string"),
request_client_id_changed=True,
request_client_secret="anything-else",
request_client_secret_changed=True,
existing_client=existing,
)
with pytest.raises(ValueError, match="oauth_client_secret"):
_resolve_oauth_credentials(
request_client_id="totally-fresh-id",
request_client_id_changed=True,
request_client_secret=mask_string("another-long-secret"),
request_client_secret_changed=True,
existing_client=existing,
)
def test_changed_flag_with_short_mask_placeholder_is_rejected(self) -> None:
# mask_string returns "••••••••••••" for short inputs; verify both
# mask formats trip the safety net, not just the long form.
short_mask = mask_string("short")
existing = _make_existing_client()
with pytest.raises(ValueError, match="oauth_client_secret"):
_resolve_oauth_credentials(
request_client_id="something",
request_client_id_changed=True,
request_client_secret=short_mask,
request_client_secret_changed=True,
existing_client=existing,
)
def test_no_existing_client_passes_request_values_through(self) -> None:
# Create flow: nothing is stored yet; both flags are False (the default)
# but there's nothing to fall back to. The resolver should resolve to
# None for both fields, leaving the caller to handle the create path
# explicitly (which `_upsert_mcp_server` does by only invoking the
# resolver when an `existing_client` is present).
resolved_id, resolved_secret = _resolve_oauth_credentials(
request_client_id="user-typed-id",
request_client_id_changed=False,
request_client_secret="user-typed-secret",
request_client_secret_changed=False,
existing_client=None,
)
assert resolved_id is None
assert resolved_secret is None
def test_no_existing_client_with_changed_flags_uses_request_values(self) -> None:
resolved_id, resolved_secret = _resolve_oauth_credentials(
request_client_id="user-typed-id",
request_client_id_changed=True,
request_client_secret="user-typed-secret",
request_client_secret_changed=True,
existing_client=None,
)
assert resolved_id == "user-typed-id"
assert resolved_secret == "user-typed-secret"
class TestBuildOAuthAdminConfigData:
def test_no_client_id_returns_empty_headers_only(self) -> None:
config_data = _build_oauth_admin_config_data(
client_id=None,
client_secret=None,
)
assert config_data == {"headers": {}}
assert MCPOAuthKeys.CLIENT_INFO.value not in config_data
def test_public_client_with_no_secret_still_seeds_client_info(self) -> None:
# Regression for the original bug: a public client (id present, secret
# absent) used to fall through the gate and silently wipe the stored
# client_info on resubmit.
config_data = _build_oauth_admin_config_data(
client_id="public-client-id",
client_secret=None,
)
client_info_dict = config_data.get(MCPOAuthKeys.CLIENT_INFO.value)
assert client_info_dict is not None
assert client_info_dict["client_id"] == "public-client-id"
assert client_info_dict.get("client_secret") is None
assert client_info_dict["token_endpoint_auth_method"] == "none"
def test_confidential_client_uses_client_secret_post(self) -> None:
config_data = _build_oauth_admin_config_data(
client_id="confidential-id",
client_secret="confidential-secret",
)
client_info_dict = config_data.get(MCPOAuthKeys.CLIENT_INFO.value)
assert client_info_dict is not None
assert client_info_dict["client_id"] == "confidential-id"
assert client_info_dict["client_secret"] == "confidential-secret"
assert client_info_dict["token_endpoint_auth_method"] == "client_secret_post"

View File

@@ -1,36 +1,70 @@
"""Test bulk invite limit for free trial tenants."""
from collections.abc import Iterator
from contextlib import contextmanager
from unittest.mock import MagicMock
from unittest.mock import patch
import pytest
from fastapi import HTTPException
from onyx.error_handling.error_codes import OnyxErrorCode
from onyx.error_handling.exceptions import OnyxError
from onyx.server.manage.models import EmailInviteStatus
from onyx.server.manage.models import UserByEmail
from onyx.server.manage.users import bulk_invite_users
from onyx.server.manage.users import remove_invited_user
def _make_shared_session_mock(next_total: int) -> MagicMock:
"""Build a MagicMock mirroring `get_session_with_shared_schema`.
The production code does one `INSERT ... ON CONFLICT DO UPDATE ...
RETURNING total_invites_sent` call against the shared-schema session.
The mock plays the role of that session: calling the patched factory
returns a context manager whose `__enter__` yields a session whose
`.execute(...).scalar_one()` answers with `next_total` — the
post-increment counter value the DB would have returned.
"""
session = MagicMock()
session.execute.return_value.scalar_one.return_value = next_total
@contextmanager
def _ctx() -> Iterator[MagicMock]:
yield session
mock = MagicMock(side_effect=_ctx)
return mock
@patch(
"onyx.server.manage.users.get_session_with_shared_schema",
new_callable=lambda: _make_shared_session_mock(next_total=6),
)
@patch("onyx.server.manage.users.enforce_invite_rate_limit")
@patch("onyx.server.manage.users.MULTI_TENANT", True)
@patch("onyx.server.manage.users.is_tenant_on_trial_fn", return_value=True)
@patch("onyx.server.manage.users.get_current_tenant_id", return_value="test_tenant")
@patch("onyx.server.manage.users.get_invited_users", return_value=[])
@patch("onyx.server.manage.users.get_all_users", return_value=[])
@patch("onyx.server.manage.users.enforce_seat_limit")
@patch("onyx.server.manage.users.NUM_FREE_TRIAL_USER_INVITES", 5)
def test_trial_tenant_cannot_exceed_invite_limit(*_mocks: None) -> None:
"""Trial tenants cannot invite more users than the configured limit."""
"""Post-upsert total of 6 exceeds cap=5 — must raise OnyxError."""
emails = [f"user{i}@example.com" for i in range(6)]
with pytest.raises(HTTPException) as exc_info:
with pytest.raises(OnyxError) as exc_info:
bulk_invite_users(emails=emails, current_user=MagicMock())
assert exc_info.value.status_code == 403
assert exc_info.value.error_code == OnyxErrorCode.TRIAL_INVITE_LIMIT_EXCEEDED
assert "invite limit" in exc_info.value.detail.lower()
@patch(
"onyx.server.manage.users.get_session_with_shared_schema",
new_callable=lambda: _make_shared_session_mock(next_total=3),
)
@patch("onyx.server.manage.users.enforce_invite_rate_limit")
@patch("onyx.server.manage.users.get_redis_client")
@patch("onyx.server.manage.users.MULTI_TENANT", True)
@patch("onyx.server.manage.users.DEV_MODE", True)
@patch("onyx.server.manage.users.ENABLE_EMAIL_INVITES", False)
@@ -46,7 +80,7 @@ def test_trial_tenant_cannot_exceed_invite_limit(*_mocks: None) -> None:
return_value=lambda *_args: None,
)
def test_trial_tenant_can_invite_within_limit(*_mocks: None) -> None:
"""Trial tenants can invite users when under the limit."""
"""Post-upsert total of 3 fits under cap=5 — must succeed."""
emails = ["user1@example.com", "user2@example.com", "user3@example.com"]
result = bulk_invite_users(emails=emails, current_user=MagicMock())
@@ -55,6 +89,41 @@ def test_trial_tenant_can_invite_within_limit(*_mocks: None) -> None:
assert result.email_invite_status == EmailInviteStatus.DISABLED
@patch("onyx.server.manage.users.get_session_with_shared_schema")
@patch("onyx.server.manage.users.enforce_invite_rate_limit")
@patch("onyx.server.manage.users.MULTI_TENANT", True)
@patch("onyx.server.manage.users.DEV_MODE", True)
@patch("onyx.server.manage.users.ENABLE_EMAIL_INVITES", False)
@patch("onyx.server.manage.users.is_tenant_on_trial_fn", return_value=False)
@patch("onyx.server.manage.users.get_current_tenant_id", return_value="test_tenant")
@patch("onyx.server.manage.users.get_invited_users", return_value=[])
@patch("onyx.server.manage.users.get_all_users", return_value=[])
@patch("onyx.server.manage.users.write_invited_users", return_value=3)
@patch("onyx.server.manage.users.enforce_seat_limit")
@patch(
"onyx.server.manage.users.fetch_ee_implementation_or_noop",
return_value=lambda *_args: None,
)
def test_paid_tenant_bypasses_invite_counter(
_ee_fetch: MagicMock,
_seat_limit: MagicMock,
_write_invited: MagicMock,
_get_all_users: MagicMock,
_get_invited_users: MagicMock,
_get_tenant_id: MagicMock,
_is_trial: MagicMock,
_rate_limit: MagicMock,
mock_get_session: MagicMock,
) -> None:
"""Paid tenants must not read or write the invite counter at all."""
emails = [f"user{i}@example.com" for i in range(3)]
result = bulk_invite_users(emails=emails, current_user=MagicMock())
mock_get_session.assert_not_called()
assert result.invited_count == 3
# --- email_invite_status tests ---
_COMMON_PATCHES = [
@@ -120,68 +189,7 @@ def test_email_invite_status_send_failed(*_mocks: None) -> None:
assert result.invited_count == 1
# --- trial-only rate limit gating tests ---
@patch("onyx.server.manage.users.enforce_invite_rate_limit")
@patch("onyx.server.manage.users.MULTI_TENANT", True)
@patch("onyx.server.manage.users.DEV_MODE", True)
@patch("onyx.server.manage.users.ENABLE_EMAIL_INVITES", False)
@patch("onyx.server.manage.users.is_tenant_on_trial_fn", return_value=False)
@patch("onyx.server.manage.users.get_current_tenant_id", return_value="test_tenant")
@patch("onyx.server.manage.users.get_invited_users", return_value=[])
@patch("onyx.server.manage.users.get_all_users", return_value=[])
@patch("onyx.server.manage.users.write_invited_users", return_value=3)
@patch("onyx.server.manage.users.enforce_seat_limit")
@patch(
"onyx.server.manage.users.fetch_ee_implementation_or_noop",
return_value=lambda *_args: None,
)
def test_paid_tenant_bypasses_invite_rate_limit(
_ee_fetch: MagicMock,
_seat_limit: MagicMock,
_write_invited: MagicMock,
_get_all_users: MagicMock,
_get_invited_users: MagicMock,
_get_tenant_id: MagicMock,
_is_trial: MagicMock,
mock_rate_limit: MagicMock,
) -> None:
"""Paid tenants must not hit the invite rate limiter at all."""
emails = [f"user{i}@example.com" for i in range(3)]
bulk_invite_users(emails=emails, current_user=MagicMock())
mock_rate_limit.assert_not_called()
@patch("onyx.server.manage.users.enforce_invite_rate_limit")
@patch("onyx.server.manage.users.MULTI_TENANT", True)
@patch("onyx.server.manage.users.DEV_MODE", True)
@patch("onyx.server.manage.users.ENABLE_EMAIL_INVITES", False)
@patch("onyx.server.manage.users.is_tenant_on_trial_fn", return_value=True)
@patch("onyx.server.manage.users.get_current_tenant_id", return_value="test_tenant")
@patch("onyx.server.manage.users.get_invited_users", return_value=[])
@patch("onyx.server.manage.users.get_all_users", return_value=[])
@patch("onyx.server.manage.users.write_invited_users", return_value=3)
@patch("onyx.server.manage.users.enforce_seat_limit")
@patch("onyx.server.manage.users.NUM_FREE_TRIAL_USER_INVITES", 50)
@patch(
"onyx.server.manage.users.fetch_ee_implementation_or_noop",
return_value=lambda *_args: None,
)
def test_trial_tenant_hits_invite_rate_limit(
_ee_fetch: MagicMock,
_seat_limit: MagicMock,
_write_invited: MagicMock,
_get_all_users: MagicMock,
_get_invited_users: MagicMock,
_get_tenant_id: MagicMock,
_is_trial: MagicMock,
mock_rate_limit: MagicMock,
) -> None:
"""Trial tenants must flow through the invite rate limiter."""
emails = [f"user{i}@example.com" for i in range(3)]
bulk_invite_users(emails=emails, current_user=MagicMock())
mock_rate_limit.assert_called_once()
# --- trial-only rate limit gating tests (remove-invited-user) ---
@patch("onyx.server.manage.users.enforce_remove_invited_rate_limit")
@@ -212,6 +220,7 @@ def test_paid_tenant_bypasses_remove_invited_rate_limit(
@patch("onyx.server.manage.users.enforce_remove_invited_rate_limit")
@patch("onyx.server.manage.users.remove_user_from_invited_users", return_value=0)
@patch("onyx.server.manage.users.get_redis_client")
@patch("onyx.server.manage.users.MULTI_TENANT", True)
@patch("onyx.server.manage.users.DEV_MODE", True)
@patch("onyx.server.manage.users.is_tenant_on_trial_fn", return_value=True)
@@ -224,6 +233,7 @@ def test_trial_tenant_hits_remove_invited_rate_limit(
_ee_fetch: MagicMock,
_get_tenant_id: MagicMock,
_is_trial: MagicMock,
_get_redis: MagicMock,
_remove_from_invited: MagicMock,
mock_rate_limit: MagicMock,
) -> None:

View File

@@ -175,7 +175,7 @@ LOG_ONYX_MODEL_INTERACTIONS=False
## Gen AI Settings
# GEN_AI_MAX_TOKENS=
LLM_SOCKET_READ_TIMEOUT=120
# LLM_SOCKET_READ_TIMEOUT=
# MAX_CHUNKS_FED_TO_CHAT=
# DISABLE_LITELLM_STREAMING=
# LITELLM_EXTRA_HEADERS=

View File

@@ -1262,7 +1262,7 @@ configMap:
S3_FILE_STORE_BUCKET_NAME: ""
# Gen AI Settings
GEN_AI_MAX_TOKENS: ""
LLM_SOCKET_READ_TIMEOUT: "120"
LLM_SOCKET_READ_TIMEOUT: "60"
MAX_CHUNKS_FED_TO_CHAT: ""
# Query Options
DOC_TIME_DECAY: ""

View File

@@ -2950,9 +2950,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
[[package]]
name = "rustls-webpki"
version = "0.103.10"
version = "0.103.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06"
dependencies = [
"aws-lc-rs",
"ring",

View File

@@ -43,7 +43,6 @@ backend = [
"chardet==5.2.0",
"chonkie==1.0.10",
"dask==2026.1.1",
"ddtrace==3.10.0",
"discord.py==2.4.0",
"distributed==2026.1.1",
"fastapi-users==15.0.4",
@@ -75,7 +74,7 @@ backend = [
# backend/onyx/file_processing/extract_file_text.py and what impacts
# updating might have on this behavior.
"markitdown[pdf, docx, pptx, xlsx, xls]==0.1.2",
"mcp[cli]==1.26.0",
"mcp[cli]==1.27.0",
"msal==1.34.0",
"msoffcrypto-tool==5.4.2",
"Office365-REST-Python-Client==2.6.2",

View File

@@ -0,0 +1,210 @@
package cmd
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/onyx-dot-app/onyx/tools/ods/internal/paths"
"github.com/spf13/cobra"
)
const (
defaultSkillSource = ".claude/skills/onyx-llm-context"
claudeSkillsDir = ".claude/skills"
claudeMDFile = ".claude/CLAUDE.md"
llmContextCloneURL = "https://github.com/onyx-dot-app/onyx-llm-context.git"
)
func NewInstallSkillCommand() *cobra.Command {
var (
source string
copyMode bool
cloneRepo bool
)
cmd := &cobra.Command{
Use: "install-skill",
Short: "Install onyx-llm-context skills for Claude Code",
Long: `Install skills from onyx-llm-context into Claude Code.
Enforced skills (enforced/) are added as @imports in .claude/CLAUDE.md (project-scoped, git-ignored).
Manual skills (skills/) are symlinked into ~/.claude/skills/ and invoked via /skill-name.
By default, looks for onyx-llm-context at ~/.claude/skills/onyx-llm-context.`,
Example: ` ods install-skill --clone
ods install-skill --source /path/to/onyx-llm-context
ods install-skill --copy`,
RunE: func(cmd *cobra.Command, args []string) error {
if source == "" {
home, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("could not determine home directory: %w", err)
}
source = filepath.Join(home, defaultSkillSource)
}
if _, err := os.Stat(source); os.IsNotExist(err) {
if !cloneRepo {
return fmt.Errorf("onyx-llm-context not found at %s\n Re-run with --clone to fetch it automatically", source)
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Cloning %s → %s\n", llmContextCloneURL, source)
gitCmd := exec.Command("git", "clone", llmContextCloneURL, source)
gitCmd.Stdout = cmd.OutOrStdout()
gitCmd.Stderr = cmd.ErrOrStderr()
if err := gitCmd.Run(); err != nil {
return fmt.Errorf("git clone failed: %w", err)
}
}
repoRoot, err := paths.GitRoot()
if err != nil {
return err
}
if err := installEnforcedSkills(cmd, source, repoRoot); err != nil {
return err
}
if err := installManualSkills(cmd, source, copyMode); err != nil {
return err
}
return nil
},
}
cmd.Flags().StringVar(&source, "source", "", "Path to onyx-llm-context (default: ~/.claude/skills/onyx-llm-context)")
cmd.Flags().BoolVar(&copyMode, "copy", false, "Copy files instead of symlinking")
cmd.Flags().BoolVar(&cloneRepo, "clone", false, fmt.Sprintf("Clone onyx-llm-context from %s if not already present", llmContextCloneURL))
return cmd
}
// installEnforcedSkills writes @imports for all enforced/ skills into .claude/CLAUDE.md at the repo root.
func installEnforcedSkills(cmd *cobra.Command, source, repoRoot string) error {
enforcedDir := filepath.Join(source, "enforced")
entries, err := os.ReadDir(enforcedDir)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("could not read %s: %w", enforcedDir, err)
}
var imports []string
for _, entry := range entries {
if !entry.IsDir() {
continue
}
skillFile := filepath.Join(enforcedDir, entry.Name(), "SKILL.md")
if _, err := os.Stat(skillFile); os.IsNotExist(err) {
continue
}
imports = append(imports, fmt.Sprintf("@%s", skillFile))
}
if len(imports) == 0 {
return nil
}
claudeDir := filepath.Join(repoRoot, ".claude")
destFile := filepath.Join(repoRoot, claudeMDFile)
if err := os.MkdirAll(claudeDir, 0o755); err != nil {
return fmt.Errorf("could not create .claude directory: %w", err)
}
content := strings.Join(imports, "\n") + "\n"
existing, err := os.ReadFile(destFile)
if err == nil && string(existing) == content {
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Up to date %s\n", destFile)
return nil
}
if err := os.WriteFile(destFile, []byte(content), 0o644); err != nil {
return fmt.Errorf("could not write %s: %w", destFile, err)
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Installed %s\n", destFile)
return nil
}
// installManualSkills symlinks each skills/ subdirectory into ~/.claude/skills/.
func installManualSkills(cmd *cobra.Command, source string, copyMode bool) error {
skillsDir := filepath.Join(source, "skills")
entries, err := os.ReadDir(skillsDir)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("could not read %s: %w", skillsDir, err)
}
home, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("could not determine home directory: %w", err)
}
claudeSkills := filepath.Join(home, claudeSkillsDir)
if err := os.MkdirAll(claudeSkills, 0o755); err != nil {
return fmt.Errorf("could not create %s: %w", claudeSkills, err)
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
srcDir := filepath.Join(skillsDir, entry.Name())
dstDir := filepath.Join(claudeSkills, entry.Name())
if copyMode {
if err := copySkill(srcDir, dstDir); err != nil {
return fmt.Errorf("could not copy %s: %w", entry.Name(), err)
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Copied %s\n", dstDir)
continue
}
if fi, err := os.Lstat(dstDir); err == nil {
if fi.Mode()&os.ModeSymlink != 0 {
_ = os.Remove(dstDir)
} else if err := os.RemoveAll(dstDir); err != nil {
return fmt.Errorf("could not remove existing %s: %w", dstDir, err)
}
}
rel, err := filepath.Rel(claudeSkills, srcDir)
if err != nil {
return fmt.Errorf("could not compute relative path for %s: %w", entry.Name(), err)
}
if err := os.Symlink(rel, dstDir); err != nil {
if copyErr := copySkill(srcDir, dstDir); copyErr != nil {
return fmt.Errorf("could not install %s: %w", entry.Name(), copyErr)
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Copied %s (symlink failed)\n", dstDir)
continue
}
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Linked %s -> %s\n", dstDir, rel)
}
return nil
}
func copySkill(srcDir, dstDir string) error {
return filepath.WalkDir(srcDir, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
rel, _ := filepath.Rel(srcDir, path)
dst := filepath.Join(dstDir, rel)
if d.IsDir() {
return os.MkdirAll(dst, 0o755)
}
content, err := os.ReadFile(path)
if err != nil {
return err
}
return os.WriteFile(dst, content, 0o644)
})
}

View File

@@ -58,6 +58,7 @@ func NewRootCommand() *cobra.Command {
cmd.AddCommand(NewLatestStableTagCommand())
cmd.AddCommand(NewWhoisCommand())
cmd.AddCommand(NewTraceCommand())
cmd.AddCommand(NewInstallSkillCommand())
return cmd
}

304
uv.lock generated
View File

@@ -746,15 +746,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f5/10/56978295c14794b2c12007b07f3e41ba26acda9257457d7085b0bb3bb90c/brotli-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:e7c0af964e0b4e3412a0ebf341ea26ec767fa0b4cf81abb5e897c9338b5ad6a3", size = 375639, upload-time = "2025-11-05T18:38:55.67Z" },
]
[[package]]
name = "bytecode"
version = "0.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/98/c4/4818b392104bd426171fc2ce9c79c8edb4019ba6505747626d0f7107766c/bytecode-0.17.0.tar.gz", hash = "sha256:0c37efa5bd158b1b873f530cceea2c645611d55bd2dc2a4758b09f185749b6fd", size = 105863, upload-time = "2025-09-03T19:55:45.703Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ce/80/379e685099841f8501a19fb58b496512ef432331fed38276c3938ab09d8e/bytecode-0.17.0-py3-none-any.whl", hash = "sha256:64fb10cde1db7ef5cc39bd414ecebd54ba3b40e1c4cf8121ca5e72f170916ff8", size = 43045, upload-time = "2025-09-03T19:55:43.879Z" },
]
[[package]]
name = "cachetools"
version = "6.2.2"
@@ -1386,54 +1377,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/87/22/f020c047ae1346613db9322638186468238bcfa8849b4668a22b97faad65/dateparser-1.2.2-py3-none-any.whl", hash = "sha256:5a5d7211a09013499867547023a2a0c91d5a27d15dd4dbcea676ea9fe66f2482", size = 315453, upload-time = "2025-06-26T09:29:21.412Z" },
]
[[package]]
name = "ddtrace"
version = "3.10.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "bytecode" },
{ name = "envier" },
{ name = "legacy-cgi", marker = "python_full_version >= '3.13'" },
{ name = "opentelemetry-api" },
{ name = "protobuf" },
{ name = "typing-extensions" },
{ name = "wrapt" },
{ name = "xmltodict" },
]
sdist = { url = "https://files.pythonhosted.org/packages/31/79/f0e5d00c401b9cbb771a6e6fdfc2ceaabcd384a6147fc55b6bebd4d26806/ddtrace-3.10.0.tar.gz", hash = "sha256:82a412a4320404f4d8dc1eea7a871cf9a55392685ac5e9d7fe178dc5c40e8b5c", size = 6731269, upload-time = "2025-07-03T19:56:26.419Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/89/d8/3330339982318ba7d313c40f76bda5db6adc9a046704823e8d5c7f98b06c/ddtrace-3.10.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:4704b7af76c93ae5616bc7ce225c8dc56b1b2cb78c78f64c952392f9ef920a81", size = 6885135, upload-time = "2025-07-03T19:54:29.312Z" },
{ url = "https://files.pythonhosted.org/packages/40/b5/03b5ead62875d19cb0da4bd6dd5d97b74f8320687f8e4dec59058592531c/ddtrace-3.10.0-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:9ab54d3d5b84d1ac2e570efdeef0dfa15add46a674bb034f8797ae9224280afa", size = 7211906, upload-time = "2025-07-03T19:54:30.826Z" },
{ url = "https://files.pythonhosted.org/packages/fb/22/cb74c4f4b56b8fc3178c62abe21b3c01aacd8caf0d3889124c6cfdc19f4f/ddtrace-3.10.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c843bef47601cfca1f123d57c26a4e287906ae2fda23c55c42ed0fffdd96e9e7", size = 6239310, upload-time = "2025-07-03T19:54:32.472Z" },
{ url = "https://files.pythonhosted.org/packages/0d/35/1370fd43244aff881d9a8709ffc8d82e665d1a2fc8a052926e140fc2d7c7/ddtrace-3.10.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:425062d369b709083836b37c64fa4a982da5365b7604cd714d4eca1fa5eb008d", size = 2966051, upload-time = "2025-07-03T19:54:34.459Z" },
{ url = "https://files.pythonhosted.org/packages/07/4e/3a28b2acc5d145a98666303abc654b6fb89816ee7c880f8205742108389f/ddtrace-3.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e07bccea09006e1202f9e6398ac5641888308bbecc5e09b80190d01f48853086", size = 6573290, upload-time = "2025-07-03T19:54:36.077Z" },
{ url = "https://files.pythonhosted.org/packages/cf/ef/b9b29ffb3dbd6a0123b4c8648479a28b77ac8242b3f7a070016db9c94623/ddtrace-3.10.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0cfa3861dc0c7e80230b8f26dcf2656e2e613eb179dc947516657de437318026", size = 7188225, upload-time = "2025-07-03T19:54:38.178Z" },
{ url = "https://files.pythonhosted.org/packages/1c/ed/ae1b36a3fcf71afcde867496c4ed34053bcc18ae4eaa94bec4cd5db27a34/ddtrace-3.10.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1fad7e92a43ea8abcafd3a332610d157ed24d614aba2deab1af026c13b0e4b84", size = 4118801, upload-time = "2025-07-03T19:54:40.472Z" },
{ url = "https://files.pythonhosted.org/packages/f7/d7/67073f3d8b01fe359d77779583c0db8c6a57564aa2118fe37f8d381372f2/ddtrace-3.10.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9d3a49c5247397fbd37bffb674aefa934b238a4aa36e5cd200917a435a5a606d", size = 7611449, upload-time = "2025-07-03T19:54:42.093Z" },
{ url = "https://files.pythonhosted.org/packages/3c/d3/74f7b204a8f19dcee0a94aebaeafc28e0e0cf25b9093be8c17b20d9e3226/ddtrace-3.10.0-cp311-cp311-win32.whl", hash = "sha256:4be49a04407977e5be7d2323d7a8762ad65bb0a0c242841d292ec447fe9d3c20", size = 5733173, upload-time = "2025-07-03T19:54:43.961Z" },
{ url = "https://files.pythonhosted.org/packages/f5/25/2f48e7395dce16d2f8960150811ad78e4e8e8acdce921253474e8d4ede2d/ddtrace-3.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:8b2d8a10e494a4cdb6bb4a41a384f561413f72a9979744ebe2f89dd2af82fd48", size = 6551512, upload-time = "2025-07-03T19:54:45.739Z" },
{ url = "https://files.pythonhosted.org/packages/d4/d7/157f61f354069f8d96223961ddd5c08f6e190efab5e6d22e01b03cce330d/ddtrace-3.10.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:acc084e43e42a7c780e37bca50c57c212f0bc42d9667647d9b871a0c63916c31", size = 6877273, upload-time = "2025-07-03T19:54:47.698Z" },
{ url = "https://files.pythonhosted.org/packages/6b/44/0949b7bc23656772953788623c4ad93793f4e14535c2cd3ae4a253e6bfec/ddtrace-3.10.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:f4a3d18d3a44594cb8a40e9a369e142fc52f544bc01df7a0a627d41238238cbb", size = 7205992, upload-time = "2025-07-03T19:54:49.538Z" },
{ url = "https://files.pythonhosted.org/packages/0a/34/b1ae07406176586c20c0cb8740de32449685830fa16ffec9b960405bb618/ddtrace-3.10.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e1e2cb1c8f18bbc58880148f9250c828bd5fd36f996b51ee05d06c29c76ac67", size = 6223804, upload-time = "2025-07-03T19:54:51.693Z" },
{ url = "https://files.pythonhosted.org/packages/dd/c6/425f551f3d751075010afe6aaa253a7f42a8f599950c74a60d61584e4fb0/ddtrace-3.10.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:601bb4a45abc6515335e2e73db0a7361158d8801cc31db78f791b7c536ae8b97", size = 2950106, upload-time = "2025-07-03T19:54:53.519Z" },
{ url = "https://files.pythonhosted.org/packages/1c/68/9c4010e4dc870dfb3c692a03557ff73bca3b9110044878945802a053769c/ddtrace-3.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c400f67a2831f7a52f30046f16a1898e7236798115617bbdf8662cd1ae08bea1", size = 6559763, upload-time = "2025-07-03T19:54:55.353Z" },
{ url = "https://files.pythonhosted.org/packages/38/62/9deb677e3ff92757d0fa8163baec37d5a4bfb3489a66625259ea6e877930/ddtrace-3.10.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8b10f392a332fecbaf3f5058fb1a932eb6199aee5aa49e2d41a5b35cf4f28c88", size = 7168092, upload-time = "2025-07-03T19:54:57.336Z" },
{ url = "https://files.pythonhosted.org/packages/ba/20/057849336b4af39d903ca89d908d02a24305e1aecea45db28b91ed93f45c/ddtrace-3.10.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:af2544451bd2fc59a5d5caf2a032366fcd6034da1ff3f20fccca5ade5be254de", size = 4096999, upload-time = "2025-07-03T19:54:59.515Z" },
{ url = "https://files.pythonhosted.org/packages/ff/06/38d3c22c2fe3b1b45792a7e64f96bb38ec459e8f1c8ee1e0b196eff352e5/ddtrace-3.10.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:22be32fe541212ab912ad21971241b979b96174c225c4950299dd3889822d791", size = 7598395, upload-time = "2025-07-03T19:55:01.549Z" },
{ url = "https://files.pythonhosted.org/packages/f7/2d/33f5523c794d4762938ee4ccf9f876b480632da67b5f6daa80f89a66d7ed/ddtrace-3.10.0-cp312-cp312-win32.whl", hash = "sha256:2fe703848a68c4314200dd4bbc7a6861c7b664700c319b39566516d3eca71688", size = 5728447, upload-time = "2025-07-03T19:55:03.564Z" },
{ url = "https://files.pythonhosted.org/packages/84/a5/f68d322f43b18baafd70892aed2f1e0ece63cfd048d53b4edb865b2467b5/ddtrace-3.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:dc4b2bb321fe1589338e8676f4971bd276b8a2eae62774c03efe7c1e61534f92", size = 6546168, upload-time = "2025-07-03T19:55:05.564Z" },
{ url = "https://files.pythonhosted.org/packages/a4/df/7a2528558e55a1a119d4c19021454f70022349fb6c145eeea65b1dc992eb/ddtrace-3.10.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:2c3db0eb18e476115087cedbbc787d2c8fae9a1353b600ef8d7ec2cf44c9b62f", size = 6868784, upload-time = "2025-07-03T19:55:07.879Z" },
{ url = "https://files.pythonhosted.org/packages/5a/21/88a9ab6924431cc1657fd3e363131c5e9dd6e13f4d669a50ab8c4c31cc66/ddtrace-3.10.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:1a45e0d226dd78066868e71ab4f1b4688aeec4b8a482fb495ccfaafbfa11de87", size = 7198483, upload-time = "2025-07-03T19:55:10.468Z" },
{ url = "https://files.pythonhosted.org/packages/37/4e/6fec0110bb37a306052797512e9a080baef5043d08657402e2ac5331d0b8/ddtrace-3.10.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:244157a6db87efcf81dfaf319a83dfaf9afd41a5cbcdd3388a86b8537fe75cda", size = 6217912, upload-time = "2025-07-03T19:55:13.143Z" },
{ url = "https://files.pythonhosted.org/packages/9c/e6/ba3afc112099ea4d7a78fbca124f401db569e7dd7a8967b646b4f761602e/ddtrace-3.10.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9b4a59a1a2ab35a0efa58be904d8a96b505ec2e67f0db7d2856715bff1189220", size = 2943999, upload-time = "2025-07-03T19:55:15.175Z" },
{ url = "https://files.pythonhosted.org/packages/06/c1/541160c7b89188acc941e2d26a086769b4ee4c437a422b20ec1646602059/ddtrace-3.10.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:167e519c55a12a0bce8964a6d58221432f29f039dbe537092af78b2c0640205f", size = 6552727, upload-time = "2025-07-03T19:55:17.303Z" },
{ url = "https://files.pythonhosted.org/packages/1e/06/b1a9b3eb6a1333dc3c405ac90252091ae2822e2979a175d836ce33e6ad58/ddtrace-3.10.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ffe9fff5364531ecbdae1f54992b8c05abac3a09cde4b0e4a7c6213ea6e1b89c", size = 7162545, upload-time = "2025-07-03T19:55:20.542Z" },
{ url = "https://files.pythonhosted.org/packages/ae/ec/41dbdd788e19325019af392286e94974e7c2f71848e26da755585bc9644e/ddtrace-3.10.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:fe6439351b94cca8d5422a73ffc5db60f198c1b45c7a485bfba157cb53032a6b", size = 4093055, upload-time = "2025-07-03T19:55:23.04Z" },
{ url = "https://files.pythonhosted.org/packages/57/f6/3489c28c1ea009a31ba5137aa1daa62da313b516c41d03adbf13c02c1943/ddtrace-3.10.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f9a5d25107ce5364d8747e999bf0ecc9a73b51d9f95a9d8a32d728bf1008ba8b", size = 7592729, upload-time = "2025-07-03T19:55:25.16Z" },
{ url = "https://files.pythonhosted.org/packages/6a/31/eee2515cdd52c9a933fe2e1f329ba9700a14a35adcd67417e25472104a97/ddtrace-3.10.0-cp313-cp313-win32.whl", hash = "sha256:8cb6cd3edd2ccacb79ce33b7588591ea138fab9f5299b6c7340f6512658056da", size = 5724955, upload-time = "2025-07-03T19:55:29.36Z" },
{ url = "https://files.pythonhosted.org/packages/73/8f/ef14c53296fdb91cef93dd18b7a9ec0c9965ea975711cd75893639b37e2b/ddtrace-3.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:288b61ad03eae5ac23bcea298158bfdf4547dce2ff24c98b5f24e91d99114d8a", size = 6542769, upload-time = "2025-07-03T19:55:31.556Z" },
]
[[package]]
name = "debugpy"
version = "1.8.17"
@@ -1626,15 +1569,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/5e/4b5aaaabddfacfe36ba7768817bd1f71a7a810a43705e531f3ae4c690767/emoji-2.15.0-py3-none-any.whl", hash = "sha256:205296793d66a89d88af4688fa57fd6496732eb48917a87175a023c8138995eb", size = 608433, upload-time = "2025-09-21T12:13:01.197Z" },
]
[[package]]
name = "envier"
version = "0.6.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/19/e7/4fe4d3f6e21213cea9bcddc36ba60e6ae4003035f9ce8055e6a9f0322ddb/envier-0.6.1.tar.gz", hash = "sha256:3309a01bb3d8850c9e7a31a5166d5a836846db2faecb79b9cb32654dd50ca9f9", size = 10063, upload-time = "2024-10-22T09:56:47.226Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/56/e9/30493b1cc967f7c07869de4b2ab3929151a58e6bb04495015554d24b61db/envier-0.6.1-py3-none-any.whl", hash = "sha256:73609040a76be48bbcb97074d9969666484aa0de706183a6e9ef773156a8a6a9", size = 10638, upload-time = "2024-10-22T09:56:45.968Z" },
]
[[package]]
name = "et-xmlfile"
version = "2.0.0"
@@ -3352,15 +3286,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/88/37/c84d7fec58dec38564574dec8f94bb1db788598fe397f116a9d6a86d3055/lazy_imports-1.0.1-py3-none-any.whl", hash = "sha256:eb5accc33bf9987e5197e79476bbeb960b74a2c16619bdf41281b3240f730846", size = 18896, upload-time = "2025-08-09T07:15:53.7Z" },
]
[[package]]
name = "legacy-cgi"
version = "2.6.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f4/9c/91c7d2c5ebbdf0a1a510bfa0ddeaa2fbb5b78677df5ac0a0aa51cf7125b0/legacy_cgi-2.6.4.tar.gz", hash = "sha256:abb9dfc7835772f7c9317977c63253fd22a7484b5c9bbcdca60a29dcce97c577", size = 24603, upload-time = "2025-10-27T05:20:05.395Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8c/7e/e7394eeb49a41cc514b3eb49020223666cbf40d86f5721c2f07871e6d84a/legacy_cgi-2.6.4-py3-none-any.whl", hash = "sha256:7e235ce58bf1e25d1fc9b2d299015e4e2cd37305eccafec1e6bac3fc04b878cd", size = 20035, upload-time = "2025-10-27T05:20:04.289Z" },
]
[[package]]
name = "litellm"
version = "1.81.6"
@@ -3772,7 +3697,7 @@ wheels = [
[[package]]
name = "mcp"
version = "1.26.0"
version = "1.27.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
@@ -3790,9 +3715,9 @@ dependencies = [
{ name = "typing-inspection" },
{ name = "uvicorn", marker = "sys_platform != 'emscripten'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/fc/6d/62e76bbb8144d6ed86e202b5edd8a4cb631e7c8130f3f4893c3f90262b10/mcp-1.26.0.tar.gz", hash = "sha256:db6e2ef491eecc1a0d93711a76f28dec2e05999f93afd48795da1c1137142c66", size = 608005, upload-time = "2026-01-24T19:40:32.468Z" }
sdist = { url = "https://files.pythonhosted.org/packages/8b/eb/c0cfc62075dc6e1ec1c64d352ae09ac051d9334311ed226f1f425312848a/mcp-1.27.0.tar.gz", hash = "sha256:d3dc35a7eec0d458c1da4976a48f982097ddaab87e278c5511d5a4a56e852b83", size = 607509, upload-time = "2026-04-02T14:48:08.88Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fd/d9/eaa1f80170d2b7c5ba23f3b59f766f3a0bb41155fbc32a69adfa1adaaef9/mcp-1.26.0-py3-none-any.whl", hash = "sha256:904a21c33c25aa98ddbeb47273033c435e595bbacfdb177f4bd87f6dceebe1ca", size = 233615, upload-time = "2026-01-24T19:40:30.652Z" },
{ url = "https://files.pythonhosted.org/packages/9c/46/f6b4ad632c67ef35209a66127e4bddc95759649dd595f71f13fba11bdf9a/mcp-1.27.0-py3-none-any.whl", hash = "sha256:5ce1fa81614958e267b21fb2aa34e0aea8e2c6ede60d52aba45fd47246b4d741", size = 215967, upload-time = "2026-04-02T14:48:07.24Z" },
]
[package.optional-dependencies]
@@ -4456,7 +4381,6 @@ backend = [
{ name = "chardet" },
{ name = "chonkie" },
{ name = "dask" },
{ name = "ddtrace" },
{ name = "discord-py" },
{ name = "distributed" },
{ name = "dropbox" },
@@ -4629,7 +4553,6 @@ backend = [
{ name = "chardet", specifier = "==5.2.0" },
{ name = "chonkie", specifier = "==1.0.10" },
{ name = "dask", specifier = "==2026.1.1" },
{ name = "ddtrace", specifier = "==3.10.0" },
{ name = "discord-py", specifier = "==2.4.0" },
{ name = "distributed", specifier = "==2026.1.1" },
{ name = "dropbox", specifier = "==12.0.2" },
@@ -4657,7 +4580,7 @@ backend = [
{ name = "lxml", specifier = "==5.3.0" },
{ name = "mako", specifier = "==1.3.11" },
{ name = "markitdown", extras = ["pdf", "docx", "pptx", "xlsx", "xls"], specifier = "==0.1.2" },
{ name = "mcp", extras = ["cli"], specifier = "==1.26.0" },
{ name = "mcp", extras = ["cli"], specifier = "==1.27.0" },
{ name = "mistune", specifier = "==3.2.0" },
{ name = "msal", specifier = "==1.34.0" },
{ name = "msoffcrypto-tool", specifier = "==5.4.2" },
@@ -8262,15 +8185,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e3/3f/75e69fa9d2084524ca4e796442d8058a78d78c64c1e8229d552c031a23b4/xmlsec-1.3.14-cp312-cp312-win_amd64.whl", hash = "sha256:d0762f4232bce2c7f6c0af329db8b821b4460bbe123a2528fb5677d03db7a4b5", size = 2441942, upload-time = "2024-04-17T19:34:10.416Z" },
]
[[package]]
name = "xmltodict"
version = "1.0.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6a/aa/917ceeed4dbb80d2f04dbd0c784b7ee7bba8ae5a54837ef0e5e062cd3cfb/xmltodict-1.0.2.tar.gz", hash = "sha256:54306780b7c2175a3967cad1db92f218207e5bc1aba697d887807c0fb68b7649", size = 25725, upload-time = "2025-09-17T21:59:26.459Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c0/20/69a0e6058bc5ea74892d089d64dfc3a62ba78917ec5e2cfa70f7c92ba3a5/xmltodict-1.0.2-py3-none-any.whl", hash = "sha256:62d0fddb0dcbc9f642745d8bbf4d81fd17d6dfaec5a15b5c1876300aad92af0d", size = 13893, upload-time = "2025-09-17T21:59:24.859Z" },
]
[[package]]
name = "xxhash"
version = "3.6.0"
@@ -8376,112 +8290,124 @@ wheels = [
[[package]]
name = "yarl"
version = "1.22.0"
version = "1.23.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "multidict" },
{ name = "propcache" },
]
sdist = { url = "https://files.pythonhosted.org/packages/57/63/0c6ebca57330cd313f6102b16dd57ffaf3ec4c83403dcb45dbd15c6f3ea1/yarl-1.22.0.tar.gz", hash = "sha256:bebf8557577d4401ba8bd9ff33906f1376c877aa78d1fe216ad01b4d6745af71", size = 187169, upload-time = "2025-10-06T14:12:55.963Z" }
sdist = { url = "https://files.pythonhosted.org/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", size = 194676, upload-time = "2026-03-01T22:07:53.373Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4d/27/5ab13fc84c76a0250afd3d26d5936349a35be56ce5785447d6c423b26d92/yarl-1.22.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ab72135b1f2db3fed3997d7e7dc1b80573c67138023852b6efb336a5eae6511", size = 141607, upload-time = "2025-10-06T14:09:16.298Z" },
{ url = "https://files.pythonhosted.org/packages/6a/a1/d065d51d02dc02ce81501d476b9ed2229d9a990818332242a882d5d60340/yarl-1.22.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:669930400e375570189492dc8d8341301578e8493aec04aebc20d4717f899dd6", size = 94027, upload-time = "2025-10-06T14:09:17.786Z" },
{ url = "https://files.pythonhosted.org/packages/c1/da/8da9f6a53f67b5106ffe902c6fa0164e10398d4e150d85838b82f424072a/yarl-1.22.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:792a2af6d58177ef7c19cbf0097aba92ca1b9cb3ffdd9c7470e156c8f9b5e028", size = 94963, upload-time = "2025-10-06T14:09:19.662Z" },
{ url = "https://files.pythonhosted.org/packages/68/fe/2c1f674960c376e29cb0bec1249b117d11738db92a6ccc4a530b972648db/yarl-1.22.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ea66b1c11c9150f1372f69afb6b8116f2dd7286f38e14ea71a44eee9ec51b9d", size = 368406, upload-time = "2025-10-06T14:09:21.402Z" },
{ url = "https://files.pythonhosted.org/packages/95/26/812a540e1c3c6418fec60e9bbd38e871eaba9545e94fa5eff8f4a8e28e1e/yarl-1.22.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3e2daa88dc91870215961e96a039ec73e4937da13cf77ce17f9cad0c18df3503", size = 336581, upload-time = "2025-10-06T14:09:22.98Z" },
{ url = "https://files.pythonhosted.org/packages/0b/f5/5777b19e26fdf98563985e481f8be3d8a39f8734147a6ebf459d0dab5a6b/yarl-1.22.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ba440ae430c00eee41509353628600212112cd5018d5def7e9b05ea7ac34eb65", size = 388924, upload-time = "2025-10-06T14:09:24.655Z" },
{ url = "https://files.pythonhosted.org/packages/86/08/24bd2477bd59c0bbd994fe1d93b126e0472e4e3df5a96a277b0a55309e89/yarl-1.22.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e6438cc8f23a9c1478633d216b16104a586b9761db62bfacb6425bac0a36679e", size = 392890, upload-time = "2025-10-06T14:09:26.617Z" },
{ url = "https://files.pythonhosted.org/packages/46/00/71b90ed48e895667ecfb1eaab27c1523ee2fa217433ed77a73b13205ca4b/yarl-1.22.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c52a6e78aef5cf47a98ef8e934755abf53953379b7d53e68b15ff4420e6683d", size = 365819, upload-time = "2025-10-06T14:09:28.544Z" },
{ url = "https://files.pythonhosted.org/packages/30/2d/f715501cae832651d3282387c6a9236cd26bd00d0ff1e404b3dc52447884/yarl-1.22.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3b06bcadaac49c70f4c88af4ffcfbe3dc155aab3163e75777818092478bcbbe7", size = 363601, upload-time = "2025-10-06T14:09:30.568Z" },
{ url = "https://files.pythonhosted.org/packages/f8/f9/a678c992d78e394e7126ee0b0e4e71bd2775e4334d00a9278c06a6cce96a/yarl-1.22.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:6944b2dc72c4d7f7052683487e3677456050ff77fcf5e6204e98caf785ad1967", size = 358072, upload-time = "2025-10-06T14:09:32.528Z" },
{ url = "https://files.pythonhosted.org/packages/2c/d1/b49454411a60edb6fefdcad4f8e6dbba7d8019e3a508a1c5836cba6d0781/yarl-1.22.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:d5372ca1df0f91a86b047d1277c2aaf1edb32d78bbcefffc81b40ffd18f027ed", size = 385311, upload-time = "2025-10-06T14:09:34.634Z" },
{ url = "https://files.pythonhosted.org/packages/87/e5/40d7a94debb8448c7771a916d1861d6609dddf7958dc381117e7ba36d9e8/yarl-1.22.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:51af598701f5299012b8416486b40fceef8c26fc87dc6d7d1f6fc30609ea0aa6", size = 381094, upload-time = "2025-10-06T14:09:36.268Z" },
{ url = "https://files.pythonhosted.org/packages/35/d8/611cc282502381ad855448643e1ad0538957fc82ae83dfe7762c14069e14/yarl-1.22.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b266bd01fedeffeeac01a79ae181719ff848a5a13ce10075adbefc8f1daee70e", size = 370944, upload-time = "2025-10-06T14:09:37.872Z" },
{ url = "https://files.pythonhosted.org/packages/2d/df/fadd00fb1c90e1a5a8bd731fa3d3de2e165e5a3666a095b04e31b04d9cb6/yarl-1.22.0-cp311-cp311-win32.whl", hash = "sha256:a9b1ba5610a4e20f655258d5a1fdc7ebe3d837bb0e45b581398b99eb98b1f5ca", size = 81804, upload-time = "2025-10-06T14:09:39.359Z" },
{ url = "https://files.pythonhosted.org/packages/b5/f7/149bb6f45f267cb5c074ac40c01c6b3ea6d8a620d34b337f6321928a1b4d/yarl-1.22.0-cp311-cp311-win_amd64.whl", hash = "sha256:078278b9b0b11568937d9509b589ee83ef98ed6d561dfe2020e24a9fd08eaa2b", size = 86858, upload-time = "2025-10-06T14:09:41.068Z" },
{ url = "https://files.pythonhosted.org/packages/2b/13/88b78b93ad3f2f0b78e13bfaaa24d11cbc746e93fe76d8c06bf139615646/yarl-1.22.0-cp311-cp311-win_arm64.whl", hash = "sha256:b6a6f620cfe13ccec221fa312139135166e47ae169f8253f72a0abc0dae94376", size = 81637, upload-time = "2025-10-06T14:09:42.712Z" },
{ url = "https://files.pythonhosted.org/packages/75/ff/46736024fee3429b80a165a732e38e5d5a238721e634ab41b040d49f8738/yarl-1.22.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:e340382d1afa5d32b892b3ff062436d592ec3d692aeea3bef3a5cfe11bbf8c6f", size = 142000, upload-time = "2025-10-06T14:09:44.631Z" },
{ url = "https://files.pythonhosted.org/packages/5a/9a/b312ed670df903145598914770eb12de1bac44599549b3360acc96878df8/yarl-1.22.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f1e09112a2c31ffe8d80be1b0988fa6a18c5d5cad92a9ffbb1c04c91bfe52ad2", size = 94338, upload-time = "2025-10-06T14:09:46.372Z" },
{ url = "https://files.pythonhosted.org/packages/ba/f5/0601483296f09c3c65e303d60c070a5c19fcdbc72daa061e96170785bc7d/yarl-1.22.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:939fe60db294c786f6b7c2d2e121576628468f65453d86b0fe36cb52f987bd74", size = 94909, upload-time = "2025-10-06T14:09:48.648Z" },
{ url = "https://files.pythonhosted.org/packages/60/41/9a1fe0b73dbcefce72e46cf149b0e0a67612d60bfc90fb59c2b2efdfbd86/yarl-1.22.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e1651bf8e0398574646744c1885a41198eba53dc8a9312b954073f845c90a8df", size = 372940, upload-time = "2025-10-06T14:09:50.089Z" },
{ url = "https://files.pythonhosted.org/packages/17/7a/795cb6dfee561961c30b800f0ed616b923a2ec6258b5def2a00bf8231334/yarl-1.22.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b8a0588521a26bf92a57a1705b77b8b59044cdceccac7151bd8d229e66b8dedb", size = 345825, upload-time = "2025-10-06T14:09:52.142Z" },
{ url = "https://files.pythonhosted.org/packages/d7/93/a58f4d596d2be2ae7bab1a5846c4d270b894958845753b2c606d666744d3/yarl-1.22.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:42188e6a615c1a75bcaa6e150c3fe8f3e8680471a6b10150c5f7e83f47cc34d2", size = 386705, upload-time = "2025-10-06T14:09:54.128Z" },
{ url = "https://files.pythonhosted.org/packages/61/92/682279d0e099d0e14d7fd2e176bd04f48de1484f56546a3e1313cd6c8e7c/yarl-1.22.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6d2cb59377d99718913ad9a151030d6f83ef420a2b8f521d94609ecc106ee82", size = 396518, upload-time = "2025-10-06T14:09:55.762Z" },
{ url = "https://files.pythonhosted.org/packages/db/0f/0d52c98b8a885aeda831224b78f3be7ec2e1aa4a62091f9f9188c3c65b56/yarl-1.22.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50678a3b71c751d58d7908edc96d332af328839eea883bb554a43f539101277a", size = 377267, upload-time = "2025-10-06T14:09:57.958Z" },
{ url = "https://files.pythonhosted.org/packages/22/42/d2685e35908cbeaa6532c1fc73e89e7f2efb5d8a7df3959ea8e37177c5a3/yarl-1.22.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1e8fbaa7cec507aa24ea27a01456e8dd4b6fab829059b69844bd348f2d467124", size = 365797, upload-time = "2025-10-06T14:09:59.527Z" },
{ url = "https://files.pythonhosted.org/packages/a2/83/cf8c7bcc6355631762f7d8bdab920ad09b82efa6b722999dfb05afa6cfac/yarl-1.22.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:433885ab5431bc3d3d4f2f9bd15bfa1614c522b0f1405d62c4f926ccd69d04fa", size = 365535, upload-time = "2025-10-06T14:10:01.139Z" },
{ url = "https://files.pythonhosted.org/packages/25/e1/5302ff9b28f0c59cac913b91fe3f16c59a033887e57ce9ca5d41a3a94737/yarl-1.22.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b790b39c7e9a4192dc2e201a282109ed2985a1ddbd5ac08dc56d0e121400a8f7", size = 382324, upload-time = "2025-10-06T14:10:02.756Z" },
{ url = "https://files.pythonhosted.org/packages/bf/cd/4617eb60f032f19ae3a688dc990d8f0d89ee0ea378b61cac81ede3e52fae/yarl-1.22.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:31f0b53913220599446872d757257be5898019c85e7971599065bc55065dc99d", size = 383803, upload-time = "2025-10-06T14:10:04.552Z" },
{ url = "https://files.pythonhosted.org/packages/59/65/afc6e62bb506a319ea67b694551dab4a7e6fb7bf604e9bd9f3e11d575fec/yarl-1.22.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a49370e8f711daec68d09b821a34e1167792ee2d24d405cbc2387be4f158b520", size = 374220, upload-time = "2025-10-06T14:10:06.489Z" },
{ url = "https://files.pythonhosted.org/packages/e7/3d/68bf18d50dc674b942daec86a9ba922d3113d8399b0e52b9897530442da2/yarl-1.22.0-cp312-cp312-win32.whl", hash = "sha256:70dfd4f241c04bd9239d53b17f11e6ab672b9f1420364af63e8531198e3f5fe8", size = 81589, upload-time = "2025-10-06T14:10:09.254Z" },
{ url = "https://files.pythonhosted.org/packages/c8/9a/6ad1a9b37c2f72874f93e691b2e7ecb6137fb2b899983125db4204e47575/yarl-1.22.0-cp312-cp312-win_amd64.whl", hash = "sha256:8884d8b332a5e9b88e23f60bb166890009429391864c685e17bd73a9eda9105c", size = 87213, upload-time = "2025-10-06T14:10:11.369Z" },
{ url = "https://files.pythonhosted.org/packages/44/c5/c21b562d1680a77634d748e30c653c3ca918beb35555cff24986fff54598/yarl-1.22.0-cp312-cp312-win_arm64.whl", hash = "sha256:ea70f61a47f3cc93bdf8b2f368ed359ef02a01ca6393916bc8ff877427181e74", size = 81330, upload-time = "2025-10-06T14:10:13.112Z" },
{ url = "https://files.pythonhosted.org/packages/ea/f3/d67de7260456ee105dc1d162d43a019ecad6b91e2f51809d6cddaa56690e/yarl-1.22.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8dee9c25c74997f6a750cd317b8ca63545169c098faee42c84aa5e506c819b53", size = 139980, upload-time = "2025-10-06T14:10:14.601Z" },
{ url = "https://files.pythonhosted.org/packages/01/88/04d98af0b47e0ef42597b9b28863b9060bb515524da0a65d5f4db160b2d5/yarl-1.22.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:01e73b85a5434f89fc4fe27dcda2aff08ddf35e4d47bbbea3bdcd25321af538a", size = 93424, upload-time = "2025-10-06T14:10:16.115Z" },
{ url = "https://files.pythonhosted.org/packages/18/91/3274b215fd8442a03975ce6bee5fe6aa57a8326b29b9d3d56234a1dca244/yarl-1.22.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:22965c2af250d20c873cdbee8ff958fb809940aeb2e74ba5f20aaf6b7ac8c70c", size = 93821, upload-time = "2025-10-06T14:10:17.993Z" },
{ url = "https://files.pythonhosted.org/packages/61/3a/caf4e25036db0f2da4ca22a353dfeb3c9d3c95d2761ebe9b14df8fc16eb0/yarl-1.22.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4f15793aa49793ec8d1c708ab7f9eded1aa72edc5174cae703651555ed1b601", size = 373243, upload-time = "2025-10-06T14:10:19.44Z" },
{ url = "https://files.pythonhosted.org/packages/6e/9e/51a77ac7516e8e7803b06e01f74e78649c24ee1021eca3d6a739cb6ea49c/yarl-1.22.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5542339dcf2747135c5c85f68680353d5cb9ffd741c0f2e8d832d054d41f35a", size = 342361, upload-time = "2025-10-06T14:10:21.124Z" },
{ url = "https://files.pythonhosted.org/packages/d4/f8/33b92454789dde8407f156c00303e9a891f1f51a0330b0fad7c909f87692/yarl-1.22.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c401e05ad47a75869c3ab3e35137f8468b846770587e70d71e11de797d113df", size = 387036, upload-time = "2025-10-06T14:10:22.902Z" },
{ url = "https://files.pythonhosted.org/packages/d9/9a/c5db84ea024f76838220280f732970aa4ee154015d7f5c1bfb60a267af6f/yarl-1.22.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:243dda95d901c733f5b59214d28b0120893d91777cb8aa043e6ef059d3cddfe2", size = 397671, upload-time = "2025-10-06T14:10:24.523Z" },
{ url = "https://files.pythonhosted.org/packages/11/c9/cd8538dc2e7727095e0c1d867bad1e40c98f37763e6d995c1939f5fdc7b1/yarl-1.22.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bec03d0d388060058f5d291a813f21c011041938a441c593374da6077fe21b1b", size = 377059, upload-time = "2025-10-06T14:10:26.406Z" },
{ url = "https://files.pythonhosted.org/packages/a1/b9/ab437b261702ced75122ed78a876a6dec0a1b0f5e17a4ac7a9a2482d8abe/yarl-1.22.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0748275abb8c1e1e09301ee3cf90c8a99678a4e92e4373705f2a2570d581273", size = 365356, upload-time = "2025-10-06T14:10:28.461Z" },
{ url = "https://files.pythonhosted.org/packages/b2/9d/8e1ae6d1d008a9567877b08f0ce4077a29974c04c062dabdb923ed98e6fe/yarl-1.22.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:47fdb18187e2a4e18fda2c25c05d8251a9e4a521edaed757fef033e7d8498d9a", size = 361331, upload-time = "2025-10-06T14:10:30.541Z" },
{ url = "https://files.pythonhosted.org/packages/ca/5a/09b7be3905962f145b73beb468cdd53db8aa171cf18c80400a54c5b82846/yarl-1.22.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c7044802eec4524fde550afc28edda0dd5784c4c45f0be151a2d3ba017daca7d", size = 382590, upload-time = "2025-10-06T14:10:33.352Z" },
{ url = "https://files.pythonhosted.org/packages/aa/7f/59ec509abf90eda5048b0bc3e2d7b5099dffdb3e6b127019895ab9d5ef44/yarl-1.22.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:139718f35149ff544caba20fce6e8a2f71f1e39b92c700d8438a0b1d2a631a02", size = 385316, upload-time = "2025-10-06T14:10:35.034Z" },
{ url = "https://files.pythonhosted.org/packages/e5/84/891158426bc8036bfdfd862fabd0e0fa25df4176ec793e447f4b85cf1be4/yarl-1.22.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1b51bebd221006d3d2f95fbe124b22b247136647ae5dcc8c7acafba66e5ee67", size = 374431, upload-time = "2025-10-06T14:10:37.76Z" },
{ url = "https://files.pythonhosted.org/packages/bb/49/03da1580665baa8bef5e8ed34c6df2c2aca0a2f28bf397ed238cc1bbc6f2/yarl-1.22.0-cp313-cp313-win32.whl", hash = "sha256:d3e32536234a95f513bd374e93d717cf6b2231a791758de6c509e3653f234c95", size = 81555, upload-time = "2025-10-06T14:10:39.649Z" },
{ url = "https://files.pythonhosted.org/packages/9a/ee/450914ae11b419eadd067c6183ae08381cfdfcb9798b90b2b713bbebddda/yarl-1.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:47743b82b76d89a1d20b83e60d5c20314cbd5ba2befc9cda8f28300c4a08ed4d", size = 86965, upload-time = "2025-10-06T14:10:41.313Z" },
{ url = "https://files.pythonhosted.org/packages/98/4d/264a01eae03b6cf629ad69bae94e3b0e5344741e929073678e84bf7a3e3b/yarl-1.22.0-cp313-cp313-win_arm64.whl", hash = "sha256:5d0fcda9608875f7d052eff120c7a5da474a6796fe4d83e152e0e4d42f6d1a9b", size = 81205, upload-time = "2025-10-06T14:10:43.167Z" },
{ url = "https://files.pythonhosted.org/packages/88/fc/6908f062a2f77b5f9f6d69cecb1747260831ff206adcbc5b510aff88df91/yarl-1.22.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:719ae08b6972befcba4310e49edb1161a88cdd331e3a694b84466bd938a6ab10", size = 146209, upload-time = "2025-10-06T14:10:44.643Z" },
{ url = "https://files.pythonhosted.org/packages/65/47/76594ae8eab26210b4867be6f49129861ad33da1f1ebdf7051e98492bf62/yarl-1.22.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:47d8a5c446df1c4db9d21b49619ffdba90e77c89ec6e283f453856c74b50b9e3", size = 95966, upload-time = "2025-10-06T14:10:46.554Z" },
{ url = "https://files.pythonhosted.org/packages/ab/ce/05e9828a49271ba6b5b038b15b3934e996980dd78abdfeb52a04cfb9467e/yarl-1.22.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cfebc0ac8333520d2d0423cbbe43ae43c8838862ddb898f5ca68565e395516e9", size = 97312, upload-time = "2025-10-06T14:10:48.007Z" },
{ url = "https://files.pythonhosted.org/packages/d1/c5/7dffad5e4f2265b29c9d7ec869c369e4223166e4f9206fc2243ee9eea727/yarl-1.22.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4398557cbf484207df000309235979c79c4356518fd5c99158c7d38203c4da4f", size = 361967, upload-time = "2025-10-06T14:10:49.997Z" },
{ url = "https://files.pythonhosted.org/packages/50/b2/375b933c93a54bff7fc041e1a6ad2c0f6f733ffb0c6e642ce56ee3b39970/yarl-1.22.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2ca6fd72a8cd803be290d42f2dec5cdcd5299eeb93c2d929bf060ad9efaf5de0", size = 323949, upload-time = "2025-10-06T14:10:52.004Z" },
{ url = "https://files.pythonhosted.org/packages/66/50/bfc2a29a1d78644c5a7220ce2f304f38248dc94124a326794e677634b6cf/yarl-1.22.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca1f59c4e1ab6e72f0a23c13fca5430f889634166be85dbf1013683e49e3278e", size = 361818, upload-time = "2025-10-06T14:10:54.078Z" },
{ url = "https://files.pythonhosted.org/packages/46/96/f3941a46af7d5d0f0498f86d71275696800ddcdd20426298e572b19b91ff/yarl-1.22.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c5010a52015e7c70f86eb967db0f37f3c8bd503a695a49f8d45700144667708", size = 372626, upload-time = "2025-10-06T14:10:55.767Z" },
{ url = "https://files.pythonhosted.org/packages/c1/42/8b27c83bb875cd89448e42cd627e0fb971fa1675c9ec546393d18826cb50/yarl-1.22.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d7672ecf7557476642c88497c2f8d8542f8e36596e928e9bcba0e42e1e7d71f", size = 341129, upload-time = "2025-10-06T14:10:57.985Z" },
{ url = "https://files.pythonhosted.org/packages/49/36/99ca3122201b382a3cf7cc937b95235b0ac944f7e9f2d5331d50821ed352/yarl-1.22.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3b7c88eeef021579d600e50363e0b6ee4f7f6f728cd3486b9d0f3ee7b946398d", size = 346776, upload-time = "2025-10-06T14:10:59.633Z" },
{ url = "https://files.pythonhosted.org/packages/85/b4/47328bf996acd01a4c16ef9dcd2f59c969f495073616586f78cd5f2efb99/yarl-1.22.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f4afb5c34f2c6fecdcc182dfcfc6af6cccf1aa923eed4d6a12e9d96904e1a0d8", size = 334879, upload-time = "2025-10-06T14:11:01.454Z" },
{ url = "https://files.pythonhosted.org/packages/c2/ad/b77d7b3f14a4283bffb8e92c6026496f6de49751c2f97d4352242bba3990/yarl-1.22.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:59c189e3e99a59cf8d83cbb31d4db02d66cda5a1a4374e8a012b51255341abf5", size = 350996, upload-time = "2025-10-06T14:11:03.452Z" },
{ url = "https://files.pythonhosted.org/packages/81/c8/06e1d69295792ba54d556f06686cbd6a7ce39c22307100e3fb4a2c0b0a1d/yarl-1.22.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:5a3bf7f62a289fa90f1990422dc8dff5a458469ea71d1624585ec3a4c8d6960f", size = 356047, upload-time = "2025-10-06T14:11:05.115Z" },
{ url = "https://files.pythonhosted.org/packages/4b/b8/4c0e9e9f597074b208d18cef227d83aac36184bfbc6eab204ea55783dbc5/yarl-1.22.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:de6b9a04c606978fdfe72666fa216ffcf2d1a9f6a381058d4378f8d7b1e5de62", size = 342947, upload-time = "2025-10-06T14:11:08.137Z" },
{ url = "https://files.pythonhosted.org/packages/e0/e5/11f140a58bf4c6ad7aca69a892bff0ee638c31bea4206748fc0df4ebcb3a/yarl-1.22.0-cp313-cp313t-win32.whl", hash = "sha256:1834bb90991cc2999f10f97f5f01317f99b143284766d197e43cd5b45eb18d03", size = 86943, upload-time = "2025-10-06T14:11:10.284Z" },
{ url = "https://files.pythonhosted.org/packages/31/74/8b74bae38ed7fe6793d0c15a0c8207bbb819cf287788459e5ed230996cdd/yarl-1.22.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff86011bd159a9d2dfc89c34cfd8aff12875980e3bd6a39ff097887520e60249", size = 93715, upload-time = "2025-10-06T14:11:11.739Z" },
{ url = "https://files.pythonhosted.org/packages/69/66/991858aa4b5892d57aef7ee1ba6b4d01ec3b7eb3060795d34090a3ca3278/yarl-1.22.0-cp313-cp313t-win_arm64.whl", hash = "sha256:7861058d0582b847bc4e3a4a4c46828a410bca738673f35a29ba3ca5db0b473b", size = 83857, upload-time = "2025-10-06T14:11:13.586Z" },
{ url = "https://files.pythonhosted.org/packages/46/b3/e20ef504049f1a1c54a814b4b9bed96d1ac0e0610c3b4da178f87209db05/yarl-1.22.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:34b36c2c57124530884d89d50ed2c1478697ad7473efd59cfd479945c95650e4", size = 140520, upload-time = "2025-10-06T14:11:15.465Z" },
{ url = "https://files.pythonhosted.org/packages/e4/04/3532d990fdbab02e5ede063676b5c4260e7f3abea2151099c2aa745acc4c/yarl-1.22.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:0dd9a702591ca2e543631c2a017e4a547e38a5c0f29eece37d9097e04a7ac683", size = 93504, upload-time = "2025-10-06T14:11:17.106Z" },
{ url = "https://files.pythonhosted.org/packages/11/63/ff458113c5c2dac9a9719ac68ee7c947cb621432bcf28c9972b1c0e83938/yarl-1.22.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:594fcab1032e2d2cc3321bb2e51271e7cd2b516c7d9aee780ece81b07ff8244b", size = 94282, upload-time = "2025-10-06T14:11:19.064Z" },
{ url = "https://files.pythonhosted.org/packages/a7/bc/315a56aca762d44a6aaaf7ad253f04d996cb6b27bad34410f82d76ea8038/yarl-1.22.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f3d7a87a78d46a2e3d5b72587ac14b4c16952dd0887dbb051451eceac774411e", size = 372080, upload-time = "2025-10-06T14:11:20.996Z" },
{ url = "https://files.pythonhosted.org/packages/3f/3f/08e9b826ec2e099ea6e7c69a61272f4f6da62cb5b1b63590bb80ca2e4a40/yarl-1.22.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:852863707010316c973162e703bddabec35e8757e67fcb8ad58829de1ebc8590", size = 338696, upload-time = "2025-10-06T14:11:22.847Z" },
{ url = "https://files.pythonhosted.org/packages/e3/9f/90360108e3b32bd76789088e99538febfea24a102380ae73827f62073543/yarl-1.22.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:131a085a53bfe839a477c0845acf21efc77457ba2bcf5899618136d64f3303a2", size = 387121, upload-time = "2025-10-06T14:11:24.889Z" },
{ url = "https://files.pythonhosted.org/packages/98/92/ab8d4657bd5b46a38094cfaea498f18bb70ce6b63508fd7e909bd1f93066/yarl-1.22.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:078a8aefd263f4d4f923a9677b942b445a2be970ca24548a8102689a3a8ab8da", size = 394080, upload-time = "2025-10-06T14:11:27.307Z" },
{ url = "https://files.pythonhosted.org/packages/f5/e7/d8c5a7752fef68205296201f8ec2bf718f5c805a7a7e9880576c67600658/yarl-1.22.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bca03b91c323036913993ff5c738d0842fc9c60c4648e5c8d98331526df89784", size = 372661, upload-time = "2025-10-06T14:11:29.387Z" },
{ url = "https://files.pythonhosted.org/packages/b6/2e/f4d26183c8db0bb82d491b072f3127fb8c381a6206a3a56332714b79b751/yarl-1.22.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:68986a61557d37bb90d3051a45b91fa3d5c516d177dfc6dd6f2f436a07ff2b6b", size = 364645, upload-time = "2025-10-06T14:11:31.423Z" },
{ url = "https://files.pythonhosted.org/packages/80/7c/428e5812e6b87cd00ee8e898328a62c95825bf37c7fa87f0b6bb2ad31304/yarl-1.22.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:4792b262d585ff0dff6bcb787f8492e40698443ec982a3568c2096433660c694", size = 355361, upload-time = "2025-10-06T14:11:33.055Z" },
{ url = "https://files.pythonhosted.org/packages/ec/2a/249405fd26776f8b13c067378ef4d7dd49c9098d1b6457cdd152a99e96a9/yarl-1.22.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:ebd4549b108d732dba1d4ace67614b9545b21ece30937a63a65dd34efa19732d", size = 381451, upload-time = "2025-10-06T14:11:35.136Z" },
{ url = "https://files.pythonhosted.org/packages/67/a8/fb6b1adbe98cf1e2dd9fad71003d3a63a1bc22459c6e15f5714eb9323b93/yarl-1.22.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f87ac53513d22240c7d59203f25cc3beac1e574c6cd681bbfd321987b69f95fd", size = 383814, upload-time = "2025-10-06T14:11:37.094Z" },
{ url = "https://files.pythonhosted.org/packages/d9/f9/3aa2c0e480fb73e872ae2814c43bc1e734740bb0d54e8cb2a95925f98131/yarl-1.22.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:22b029f2881599e2f1b06f8f1db2ee63bd309e2293ba2d566e008ba12778b8da", size = 370799, upload-time = "2025-10-06T14:11:38.83Z" },
{ url = "https://files.pythonhosted.org/packages/50/3c/af9dba3b8b5eeb302f36f16f92791f3ea62e3f47763406abf6d5a4a3333b/yarl-1.22.0-cp314-cp314-win32.whl", hash = "sha256:6a635ea45ba4ea8238463b4f7d0e721bad669f80878b7bfd1f89266e2ae63da2", size = 82990, upload-time = "2025-10-06T14:11:40.624Z" },
{ url = "https://files.pythonhosted.org/packages/ac/30/ac3a0c5bdc1d6efd1b41fa24d4897a4329b3b1e98de9449679dd327af4f0/yarl-1.22.0-cp314-cp314-win_amd64.whl", hash = "sha256:0d6e6885777af0f110b0e5d7e5dda8b704efed3894da26220b7f3d887b839a79", size = 88292, upload-time = "2025-10-06T14:11:42.578Z" },
{ url = "https://files.pythonhosted.org/packages/df/0a/227ab4ff5b998a1b7410abc7b46c9b7a26b0ca9e86c34ba4b8d8bc7c63d5/yarl-1.22.0-cp314-cp314-win_arm64.whl", hash = "sha256:8218f4e98d3c10d683584cb40f0424f4b9fd6e95610232dd75e13743b070ee33", size = 82888, upload-time = "2025-10-06T14:11:44.863Z" },
{ url = "https://files.pythonhosted.org/packages/06/5e/a15eb13db90abd87dfbefb9760c0f3f257ac42a5cac7e75dbc23bed97a9f/yarl-1.22.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:45c2842ff0e0d1b35a6bf1cd6c690939dacb617a70827f715232b2e0494d55d1", size = 146223, upload-time = "2025-10-06T14:11:46.796Z" },
{ url = "https://files.pythonhosted.org/packages/18/82/9665c61910d4d84f41a5bf6837597c89e665fa88aa4941080704645932a9/yarl-1.22.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d947071e6ebcf2e2bee8fce76e10faca8f7a14808ca36a910263acaacef08eca", size = 95981, upload-time = "2025-10-06T14:11:48.845Z" },
{ url = "https://files.pythonhosted.org/packages/5d/9a/2f65743589809af4d0a6d3aa749343c4b5f4c380cc24a8e94a3c6625a808/yarl-1.22.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:334b8721303e61b00019474cc103bdac3d7b1f65e91f0bfedeec2d56dfe74b53", size = 97303, upload-time = "2025-10-06T14:11:50.897Z" },
{ url = "https://files.pythonhosted.org/packages/b0/ab/5b13d3e157505c43c3b43b5a776cbf7b24a02bc4cccc40314771197e3508/yarl-1.22.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e7ce67c34138a058fd092f67d07a72b8e31ff0c9236e751957465a24b28910c", size = 361820, upload-time = "2025-10-06T14:11:52.549Z" },
{ url = "https://files.pythonhosted.org/packages/fb/76/242a5ef4677615cf95330cfc1b4610e78184400699bdda0acb897ef5e49a/yarl-1.22.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d77e1b2c6d04711478cb1c4ab90db07f1609ccf06a287d5607fcd90dc9863acf", size = 323203, upload-time = "2025-10-06T14:11:54.225Z" },
{ url = "https://files.pythonhosted.org/packages/8c/96/475509110d3f0153b43d06164cf4195c64d16999e0c7e2d8a099adcd6907/yarl-1.22.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4647674b6150d2cae088fc07de2738a84b8bcedebef29802cf0b0a82ab6face", size = 363173, upload-time = "2025-10-06T14:11:56.069Z" },
{ url = "https://files.pythonhosted.org/packages/c9/66/59db471aecfbd559a1fd48aedd954435558cd98c7d0da8b03cc6c140a32c/yarl-1.22.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:efb07073be061c8f79d03d04139a80ba33cbd390ca8f0297aae9cce6411e4c6b", size = 373562, upload-time = "2025-10-06T14:11:58.783Z" },
{ url = "https://files.pythonhosted.org/packages/03/1f/c5d94abc91557384719da10ff166b916107c1b45e4d0423a88457071dd88/yarl-1.22.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51ac5435758ba97ad69617e13233da53908beccc6cfcd6c34bbed8dcbede486", size = 339828, upload-time = "2025-10-06T14:12:00.686Z" },
{ url = "https://files.pythonhosted.org/packages/5f/97/aa6a143d3afba17b6465733681c70cf175af89f76ec8d9286e08437a7454/yarl-1.22.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:33e32a0dd0c8205efa8e83d04fc9f19313772b78522d1bdc7d9aed706bfd6138", size = 347551, upload-time = "2025-10-06T14:12:02.628Z" },
{ url = "https://files.pythonhosted.org/packages/43/3c/45a2b6d80195959239a7b2a8810506d4eea5487dce61c2a3393e7fc3c52e/yarl-1.22.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:bf4a21e58b9cde0e401e683ebd00f6ed30a06d14e93f7c8fd059f8b6e8f87b6a", size = 334512, upload-time = "2025-10-06T14:12:04.871Z" },
{ url = "https://files.pythonhosted.org/packages/86/a0/c2ab48d74599c7c84cb104ebd799c5813de252bea0f360ffc29d270c2caa/yarl-1.22.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:e4b582bab49ac33c8deb97e058cd67c2c50dac0dd134874106d9c774fd272529", size = 352400, upload-time = "2025-10-06T14:12:06.624Z" },
{ url = "https://files.pythonhosted.org/packages/32/75/f8919b2eafc929567d3d8411f72bdb1a2109c01caaab4ebfa5f8ffadc15b/yarl-1.22.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0b5bcc1a9c4839e7e30b7b30dd47fe5e7e44fb7054ec29b5bb8d526aa1041093", size = 357140, upload-time = "2025-10-06T14:12:08.362Z" },
{ url = "https://files.pythonhosted.org/packages/cf/72/6a85bba382f22cf78add705d8c3731748397d986e197e53ecc7835e76de7/yarl-1.22.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c0232bce2170103ec23c454e54a57008a9a72b5d1c3105dc2496750da8cfa47c", size = 341473, upload-time = "2025-10-06T14:12:10.994Z" },
{ url = "https://files.pythonhosted.org/packages/35/18/55e6011f7c044dc80b98893060773cefcfdbf60dfefb8cb2f58b9bacbd83/yarl-1.22.0-cp314-cp314t-win32.whl", hash = "sha256:8009b3173bcd637be650922ac455946197d858b3630b6d8787aa9e5c4564533e", size = 89056, upload-time = "2025-10-06T14:12:13.317Z" },
{ url = "https://files.pythonhosted.org/packages/f9/86/0f0dccb6e59a9e7f122c5afd43568b1d31b8ab7dda5f1b01fb5c7025c9a9/yarl-1.22.0-cp314-cp314t-win_amd64.whl", hash = "sha256:9fb17ea16e972c63d25d4a97f016d235c78dd2344820eb35bc034bc32012ee27", size = 96292, upload-time = "2025-10-06T14:12:15.398Z" },
{ url = "https://files.pythonhosted.org/packages/48/b7/503c98092fb3b344a179579f55814b613c1fbb1c23b3ec14a7b008a66a6e/yarl-1.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:9f6d73c1436b934e3f01df1e1b21ff765cd1d28c77dfb9ace207f746d4610ee1", size = 85171, upload-time = "2025-10-06T14:12:16.935Z" },
{ url = "https://files.pythonhosted.org/packages/73/ae/b48f95715333080afb75a4504487cbe142cae1268afc482d06692d605ae6/yarl-1.22.0-py3-none-any.whl", hash = "sha256:1380560bdba02b6b6c90de54133c81c9f2a453dee9912fe58c1dcced1edb7cff", size = 46814, upload-time = "2025-10-06T14:12:53.872Z" },
{ url = "https://files.pythonhosted.org/packages/a2/aa/60da938b8f0997ba3a911263c40d82b6f645a67902a490b46f3355e10fae/yarl-1.23.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:b35d13d549077713e4414f927cdc388d62e543987c572baee613bf82f11a4b99", size = 123641, upload-time = "2026-03-01T22:04:42.841Z" },
{ url = "https://files.pythonhosted.org/packages/24/84/e237607faf4e099dbb8a4f511cfd5efcb5f75918baad200ff7380635631b/yarl-1.23.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cbb0fef01f0c6b38cb0f39b1f78fc90b807e0e3c86a7ff3ce74ad77ce5c7880c", size = 86248, upload-time = "2026-03-01T22:04:44.757Z" },
{ url = "https://files.pythonhosted.org/packages/b2/0d/71ceabc14c146ba8ee3804ca7b3d42b1664c8440439de5214d366fec7d3a/yarl-1.23.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc52310451fc7c629e13c4e061cbe2dd01684d91f2f8ee2821b083c58bd72432", size = 85988, upload-time = "2026-03-01T22:04:46.365Z" },
{ url = "https://files.pythonhosted.org/packages/8c/6c/4a90d59c572e46b270ca132aca66954f1175abd691f74c1ef4c6711828e2/yarl-1.23.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2c6b50c7b0464165472b56b42d4c76a7b864597007d9c085e8b63e185cf4a7a", size = 100566, upload-time = "2026-03-01T22:04:47.639Z" },
{ url = "https://files.pythonhosted.org/packages/49/fb/c438fb5108047e629f6282a371e6e91cf3f97ee087c4fb748a1f32ceef55/yarl-1.23.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:aafe5dcfda86c8af00386d7781d4c2181b5011b7be3f2add5e99899ea925df05", size = 92079, upload-time = "2026-03-01T22:04:48.925Z" },
{ url = "https://files.pythonhosted.org/packages/d9/13/d269aa1aed3e4f50a5a103f96327210cc5fa5dd2d50882778f13c7a14606/yarl-1.23.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9ee33b875f0b390564c1fb7bc528abf18c8ee6073b201c6ae8524aca778e2d83", size = 108741, upload-time = "2026-03-01T22:04:50.838Z" },
{ url = "https://files.pythonhosted.org/packages/85/fb/115b16f22c37ea4437d323e472945bea97301c8ec6089868fa560abab590/yarl-1.23.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4c41e021bc6d7affb3364dc1e1e5fa9582b470f283748784bd6ea0558f87f42c", size = 108099, upload-time = "2026-03-01T22:04:52.499Z" },
{ url = "https://files.pythonhosted.org/packages/9a/64/c53487d9f4968045b8afa51aed7ca44f58b2589e772f32745f3744476c82/yarl-1.23.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99c8a9ed30f4164bc4c14b37a90208836cbf50d4ce2a57c71d0f52c7fb4f7598", size = 102678, upload-time = "2026-03-01T22:04:55.176Z" },
{ url = "https://files.pythonhosted.org/packages/85/59/cd98e556fbb2bf8fab29c1a722f67ad45c5f3447cac798ab85620d1e70af/yarl-1.23.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f2af5c81a1f124609d5f33507082fc3f739959d4719b56877ab1ee7e7b3d602b", size = 100803, upload-time = "2026-03-01T22:04:56.588Z" },
{ url = "https://files.pythonhosted.org/packages/9e/c0/b39770b56d4a9f0bb5f77e2f1763cd2d75cc2f6c0131e3b4c360348fcd65/yarl-1.23.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6b41389c19b07c760c7e427a3462e8ab83c4bb087d127f0e854c706ce1b9215c", size = 100163, upload-time = "2026-03-01T22:04:58.492Z" },
{ url = "https://files.pythonhosted.org/packages/e7/64/6980f99ab00e1f0ff67cb84766c93d595b067eed07439cfccfc8fb28c1a6/yarl-1.23.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:1dc702e42d0684f42d6519c8d581e49c96cefaaab16691f03566d30658ee8788", size = 93859, upload-time = "2026-03-01T22:05:00.268Z" },
{ url = "https://files.pythonhosted.org/packages/38/69/912e6c5e146793e5d4b5fe39ff5b00f4d22463dfd5a162bec565ac757673/yarl-1.23.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0e40111274f340d32ebcc0a5668d54d2b552a6cca84c9475859d364b380e3222", size = 108202, upload-time = "2026-03-01T22:05:02.273Z" },
{ url = "https://files.pythonhosted.org/packages/59/97/35ca6767524687ad64e5f5c31ad54bc76d585585a9fcb40f649e7e82ffed/yarl-1.23.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:4764a6a7588561a9aef92f65bda2c4fb58fe7c675c0883862e6df97559de0bfb", size = 99866, upload-time = "2026-03-01T22:05:03.597Z" },
{ url = "https://files.pythonhosted.org/packages/d3/1c/1a3387ee6d73589f6f2a220ae06f2984f6c20b40c734989b0a44f5987308/yarl-1.23.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:03214408cfa590df47728b84c679ae4ef00be2428e11630277be0727eba2d7cc", size = 107852, upload-time = "2026-03-01T22:05:04.986Z" },
{ url = "https://files.pythonhosted.org/packages/a4/b8/35c0750fcd5a3f781058bfd954515dd4b1eab45e218cbb85cf11132215f1/yarl-1.23.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:170e26584b060879e29fac213e4228ef063f39128723807a312e5c7fec28eff2", size = 102919, upload-time = "2026-03-01T22:05:06.397Z" },
{ url = "https://files.pythonhosted.org/packages/e5/1c/9a1979aec4a81896d597bcb2177827f2dbee3f5b7cc48b2d0dadb644b41d/yarl-1.23.0-cp311-cp311-win32.whl", hash = "sha256:51430653db848d258336cfa0244427b17d12db63d42603a55f0d4546f50f25b5", size = 82602, upload-time = "2026-03-01T22:05:08.444Z" },
{ url = "https://files.pythonhosted.org/packages/93/22/b85eca6fa2ad9491af48c973e4c8cf6b103a73dbb271fe3346949449fca0/yarl-1.23.0-cp311-cp311-win_amd64.whl", hash = "sha256:bf49a3ae946a87083ef3a34c8f677ae4243f5b824bfc4c69672e72b3d6719d46", size = 87461, upload-time = "2026-03-01T22:05:10.145Z" },
{ url = "https://files.pythonhosted.org/packages/93/95/07e3553fe6f113e6864a20bdc53a78113cda3b9ced8784ee52a52c9f80d8/yarl-1.23.0-cp311-cp311-win_arm64.whl", hash = "sha256:b39cb32a6582750b6cc77bfb3c49c0f8760dc18dc96ec9fb55fbb0f04e08b928", size = 82336, upload-time = "2026-03-01T22:05:11.554Z" },
{ url = "https://files.pythonhosted.org/packages/88/8a/94615bc31022f711add374097ad4144d569e95ff3c38d39215d07ac153a0/yarl-1.23.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:1932b6b8bba8d0160a9d1078aae5838a66039e8832d41d2992daa9a3a08f7860", size = 124737, upload-time = "2026-03-01T22:05:12.897Z" },
{ url = "https://files.pythonhosted.org/packages/e3/6f/c6554045d59d64052698add01226bc867b52fe4a12373415d7991fdca95d/yarl-1.23.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:411225bae281f114067578891bc75534cfb3d92a3b4dfef7a6ca78ba354e6069", size = 87029, upload-time = "2026-03-01T22:05:14.376Z" },
{ url = "https://files.pythonhosted.org/packages/19/2a/725ecc166d53438bc88f76822ed4b1e3b10756e790bafd7b523fe97c322d/yarl-1.23.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:13a563739ae600a631c36ce096615fe307f131344588b0bc0daec108cdb47b25", size = 86310, upload-time = "2026-03-01T22:05:15.71Z" },
{ url = "https://files.pythonhosted.org/packages/99/30/58260ed98e6ff7f90ba84442c1ddd758c9170d70327394a6227b310cd60f/yarl-1.23.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cbf44c5cb4a7633d078788e1b56387e3d3cf2b8139a3be38040b22d6c3221c8", size = 97587, upload-time = "2026-03-01T22:05:17.384Z" },
{ url = "https://files.pythonhosted.org/packages/76/0a/8b08aac08b50682e65759f7f8dde98ae8168f72487e7357a5d684c581ef9/yarl-1.23.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:53ad387048f6f09a8969631e4de3f1bf70c50e93545d64af4f751b2498755072", size = 92528, upload-time = "2026-03-01T22:05:18.804Z" },
{ url = "https://files.pythonhosted.org/packages/52/07/0b7179101fe5f8385ec6c6bb5d0cb9f76bd9fb4a769591ab6fb5cdbfc69a/yarl-1.23.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4a59ba56f340334766f3a4442e0efd0af895fae9e2b204741ef885c446b3a1a8", size = 105339, upload-time = "2026-03-01T22:05:20.235Z" },
{ url = "https://files.pythonhosted.org/packages/d3/8a/36d82869ab5ec829ca8574dfcb92b51286fcfb1e9c7a73659616362dc880/yarl-1.23.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:803a3c3ce4acc62eaf01eaca1208dcf0783025ef27572c3336502b9c232005e7", size = 105061, upload-time = "2026-03-01T22:05:22.268Z" },
{ url = "https://files.pythonhosted.org/packages/66/3e/868e5c3364b6cee19ff3e1a122194fa4ce51def02c61023970442162859e/yarl-1.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3d2bff8f37f8d0f96c7ec554d16945050d54462d6e95414babaa18bfafc7f51", size = 100132, upload-time = "2026-03-01T22:05:23.638Z" },
{ url = "https://files.pythonhosted.org/packages/cf/26/9c89acf82f08a52cb52d6d39454f8d18af15f9d386a23795389d1d423823/yarl-1.23.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c75eb09e8d55bceb4367e83496ff8ef2bc7ea6960efb38e978e8073ea59ecb67", size = 99289, upload-time = "2026-03-01T22:05:25.749Z" },
{ url = "https://files.pythonhosted.org/packages/6f/54/5b0db00d2cb056922356104468019c0a132e89c8d3ab67d8ede9f4483d2a/yarl-1.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:877b0738624280e34c55680d6054a307aa94f7d52fa0e3034a9cc6e790871da7", size = 96950, upload-time = "2026-03-01T22:05:27.318Z" },
{ url = "https://files.pythonhosted.org/packages/f6/40/10fa93811fd439341fad7e0718a86aca0de9548023bbb403668d6555acab/yarl-1.23.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:b5405bb8f0e783a988172993cfc627e4d9d00432d6bbac65a923041edacf997d", size = 93960, upload-time = "2026-03-01T22:05:28.738Z" },
{ url = "https://files.pythonhosted.org/packages/bc/d2/8ae2e6cd77d0805f4526e30ec43b6f9a3dfc542d401ac4990d178e4bf0cf/yarl-1.23.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c3a3598a832590c5a3ce56ab5576361b5688c12cb1d39429cf5dba30b510760", size = 104703, upload-time = "2026-03-01T22:05:30.438Z" },
{ url = "https://files.pythonhosted.org/packages/2f/0c/b3ceacf82c3fe21183ce35fa2acf5320af003d52bc1fcf5915077681142e/yarl-1.23.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8419ebd326430d1cbb7efb5292330a2cf39114e82df5cc3d83c9a0d5ebeaf2f2", size = 98325, upload-time = "2026-03-01T22:05:31.835Z" },
{ url = "https://files.pythonhosted.org/packages/9d/e0/12900edd28bdab91a69bd2554b85ad7b151f64e8b521fe16f9ad2f56477a/yarl-1.23.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:be61f6fff406ca40e3b1d84716fde398fc08bc63dd96d15f3a14230a0973ed86", size = 105067, upload-time = "2026-03-01T22:05:33.358Z" },
{ url = "https://files.pythonhosted.org/packages/15/61/74bb1182cf79c9bbe4eb6b1f14a57a22d7a0be5e9cedf8e2d5c2086474c3/yarl-1.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3ceb13c5c858d01321b5d9bb65e4cf37a92169ea470b70fec6f236b2c9dd7e34", size = 100285, upload-time = "2026-03-01T22:05:35.4Z" },
{ url = "https://files.pythonhosted.org/packages/69/7f/cd5ef733f2550de6241bd8bd8c3febc78158b9d75f197d9c7baa113436af/yarl-1.23.0-cp312-cp312-win32.whl", hash = "sha256:fffc45637bcd6538de8b85f51e3df3223e4ad89bccbfca0481c08c7fc8b7ed7d", size = 82359, upload-time = "2026-03-01T22:05:36.811Z" },
{ url = "https://files.pythonhosted.org/packages/f5/be/25216a49daeeb7af2bec0db22d5e7df08ed1d7c9f65d78b14f3b74fd72fc/yarl-1.23.0-cp312-cp312-win_amd64.whl", hash = "sha256:f69f57305656a4852f2a7203efc661d8c042e6cc67f7acd97d8667fb448a426e", size = 87674, upload-time = "2026-03-01T22:05:38.171Z" },
{ url = "https://files.pythonhosted.org/packages/d2/35/aeab955d6c425b227d5b7247eafb24f2653fedc32f95373a001af5dfeb9e/yarl-1.23.0-cp312-cp312-win_arm64.whl", hash = "sha256:6e87a6e8735b44816e7db0b2fbc9686932df473c826b0d9743148432e10bb9b9", size = 81879, upload-time = "2026-03-01T22:05:40.006Z" },
{ url = "https://files.pythonhosted.org/packages/9a/4b/a0a6e5d0ee8a2f3a373ddef8a4097d74ac901ac363eea1440464ccbe0898/yarl-1.23.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:16c6994ac35c3e74fb0ae93323bf8b9c2a9088d55946109489667c510a7d010e", size = 123796, upload-time = "2026-03-01T22:05:41.412Z" },
{ url = "https://files.pythonhosted.org/packages/67/b6/8925d68af039b835ae876db5838e82e76ec87b9782ecc97e192b809c4831/yarl-1.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4a42e651629dafb64fd5b0286a3580613702b5809ad3f24934ea87595804f2c5", size = 86547, upload-time = "2026-03-01T22:05:42.841Z" },
{ url = "https://files.pythonhosted.org/packages/ae/50/06d511cc4b8e0360d3c94af051a768e84b755c5eb031b12adaaab6dec6e5/yarl-1.23.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c6b9461a2a8b47c65eef63bb1c76a4f1c119618ffa99ea79bc5bb1e46c5821b", size = 85854, upload-time = "2026-03-01T22:05:44.85Z" },
{ url = "https://files.pythonhosted.org/packages/c4/f4/4e30b250927ffdab4db70da08b9b8d2194d7c7b400167b8fbeca1e4701ca/yarl-1.23.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2569b67d616eab450d262ca7cb9f9e19d2f718c70a8b88712859359d0ab17035", size = 98351, upload-time = "2026-03-01T22:05:46.836Z" },
{ url = "https://files.pythonhosted.org/packages/86/fc/4118c5671ea948208bdb1492d8b76bdf1453d3e73df051f939f563e7dcc5/yarl-1.23.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e9d9a4d06d3481eab79803beb4d9bd6f6a8e781ec078ac70d7ef2dcc29d1bea5", size = 92711, upload-time = "2026-03-01T22:05:48.316Z" },
{ url = "https://files.pythonhosted.org/packages/56/11/1ed91d42bd9e73c13dc9e7eb0dd92298d75e7ac4dd7f046ad0c472e231cd/yarl-1.23.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f514f6474e04179d3d33175ed3f3e31434d3130d42ec153540d5b157deefd735", size = 106014, upload-time = "2026-03-01T22:05:50.028Z" },
{ url = "https://files.pythonhosted.org/packages/ce/c9/74e44e056a23fbc33aca71779ef450ca648a5bc472bdad7a82339918f818/yarl-1.23.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fda207c815b253e34f7e1909840fd14299567b1c0eb4908f8c2ce01a41265401", size = 105557, upload-time = "2026-03-01T22:05:51.416Z" },
{ url = "https://files.pythonhosted.org/packages/66/fe/b1e10b08d287f518994f1e2ff9b6d26f0adeecd8dd7d533b01bab29a3eda/yarl-1.23.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34b6cf500e61c90f305094911f9acc9c86da1a05a7a3f5be9f68817043f486e4", size = 101559, upload-time = "2026-03-01T22:05:52.872Z" },
{ url = "https://files.pythonhosted.org/packages/72/59/c5b8d94b14e3d3c2a9c20cb100119fd534ab5a14b93673ab4cc4a4141ea5/yarl-1.23.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d7504f2b476d21653e4d143f44a175f7f751cd41233525312696c76aa3dbb23f", size = 100502, upload-time = "2026-03-01T22:05:54.954Z" },
{ url = "https://files.pythonhosted.org/packages/77/4f/96976cb54cbfc5c9fd73ed4c51804f92f209481d1fb190981c0f8a07a1d7/yarl-1.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:578110dd426f0d209d1509244e6d4a3f1a3e9077655d98c5f22583d63252a08a", size = 98027, upload-time = "2026-03-01T22:05:56.409Z" },
{ url = "https://files.pythonhosted.org/packages/63/6e/904c4f476471afdbad6b7e5b70362fb5810e35cd7466529a97322b6f5556/yarl-1.23.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:609d3614d78d74ebe35f54953c5bbd2ac647a7ddb9c30a5d877580f5e86b22f2", size = 95369, upload-time = "2026-03-01T22:05:58.141Z" },
{ url = "https://files.pythonhosted.org/packages/9d/40/acfcdb3b5f9d68ef499e39e04d25e141fe90661f9d54114556cf83be8353/yarl-1.23.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4966242ec68afc74c122f8459abd597afd7d8a60dc93d695c1334c5fd25f762f", size = 105565, upload-time = "2026-03-01T22:06:00.286Z" },
{ url = "https://files.pythonhosted.org/packages/5e/c6/31e28f3a6ba2869c43d124f37ea5260cac9c9281df803c354b31f4dd1f3c/yarl-1.23.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e0fd068364a6759bc794459f0a735ab151d11304346332489c7972bacbe9e72b", size = 99813, upload-time = "2026-03-01T22:06:01.712Z" },
{ url = "https://files.pythonhosted.org/packages/08/1f/6f65f59e72d54aa467119b63fc0b0b1762eff0232db1f4720cd89e2f4a17/yarl-1.23.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:39004f0ad156da43e86aa71f44e033de68a44e5a31fc53507b36dd253970054a", size = 105632, upload-time = "2026-03-01T22:06:03.188Z" },
{ url = "https://files.pythonhosted.org/packages/a3/c4/18b178a69935f9e7a338127d5b77d868fdc0f0e49becd286d51b3a18c61d/yarl-1.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e5723c01a56c5028c807c701aa66722916d2747ad737a046853f6c46f4875543", size = 101895, upload-time = "2026-03-01T22:06:04.651Z" },
{ url = "https://files.pythonhosted.org/packages/8f/54/f5b870b5505663911dba950a8e4776a0dbd51c9c54c0ae88e823e4b874a0/yarl-1.23.0-cp313-cp313-win32.whl", hash = "sha256:1b6b572edd95b4fa8df75de10b04bc81acc87c1c7d16bcdd2035b09d30acc957", size = 82356, upload-time = "2026-03-01T22:06:06.04Z" },
{ url = "https://files.pythonhosted.org/packages/7a/84/266e8da36879c6edcd37b02b547e2d9ecdfea776be49598e75696e3316e1/yarl-1.23.0-cp313-cp313-win_amd64.whl", hash = "sha256:baaf55442359053c7d62f6f8413a62adba3205119bcb6f49594894d8be47e5e3", size = 87515, upload-time = "2026-03-01T22:06:08.107Z" },
{ url = "https://files.pythonhosted.org/packages/00/fd/7e1c66efad35e1649114fa13f17485f62881ad58edeeb7f49f8c5e748bf9/yarl-1.23.0-cp313-cp313-win_arm64.whl", hash = "sha256:fb4948814a2a98e3912505f09c9e7493b1506226afb1f881825368d6fb776ee3", size = 81785, upload-time = "2026-03-01T22:06:10.181Z" },
{ url = "https://files.pythonhosted.org/packages/9c/fc/119dd07004f17ea43bb91e3ece6587759edd7519d6b086d16bfbd3319982/yarl-1.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:aecfed0b41aa72b7881712c65cf764e39ce2ec352324f5e0837c7048d9e6daaa", size = 130719, upload-time = "2026-03-01T22:06:11.708Z" },
{ url = "https://files.pythonhosted.org/packages/e6/0d/9f2348502fbb3af409e8f47730282cd6bc80dec6630c1e06374d882d6eb2/yarl-1.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a41bcf68efd19073376eb8cf948b8d9be0af26256403e512bb18f3966f1f9120", size = 89690, upload-time = "2026-03-01T22:06:13.429Z" },
{ url = "https://files.pythonhosted.org/packages/50/93/e88f3c80971b42cfc83f50a51b9d165a1dbf154b97005f2994a79f212a07/yarl-1.23.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cde9a2ecd91668bcb7f077c4966d8ceddb60af01b52e6e3e2680e4cf00ad1a59", size = 89851, upload-time = "2026-03-01T22:06:15.53Z" },
{ url = "https://files.pythonhosted.org/packages/1c/07/61c9dd8ba8f86473263b4036f70fb594c09e99c0d9737a799dfd8bc85651/yarl-1.23.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5023346c4ee7992febc0068e7593de5fa2bf611848c08404b35ebbb76b1b0512", size = 95874, upload-time = "2026-03-01T22:06:17.553Z" },
{ url = "https://files.pythonhosted.org/packages/9e/e9/f9ff8ceefba599eac6abddcfb0b3bee9b9e636e96dbf54342a8577252379/yarl-1.23.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1009abedb49ae95b136a8904a3f71b342f849ffeced2d3747bf29caeda218c4", size = 88710, upload-time = "2026-03-01T22:06:19.004Z" },
{ url = "https://files.pythonhosted.org/packages/eb/78/0231bfcc5d4c8eec220bc2f9ef82cb4566192ea867a7c5b4148f44f6cbcd/yarl-1.23.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a8d00f29b42f534cc8aa3931cfe773b13b23e561e10d2b26f27a8d309b0e82a1", size = 101033, upload-time = "2026-03-01T22:06:21.203Z" },
{ url = "https://files.pythonhosted.org/packages/cd/9b/30ea5239a61786f18fd25797151a17fbb3be176977187a48d541b5447dd4/yarl-1.23.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:95451e6ce06c3e104556d73b559f5da6c34a069b6b62946d3ad66afcd51642ea", size = 100817, upload-time = "2026-03-01T22:06:22.738Z" },
{ url = "https://files.pythonhosted.org/packages/62/e2/a4980481071791bc83bce2b7a1a1f7adcabfa366007518b4b845e92eeee3/yarl-1.23.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:531ef597132086b6cf96faa7c6c1dcd0361dd5f1694e5cc30375907b9b7d3ea9", size = 97482, upload-time = "2026-03-01T22:06:24.21Z" },
{ url = "https://files.pythonhosted.org/packages/e5/1e/304a00cf5f6100414c4b5a01fc7ff9ee724b62158a08df2f8170dfc72a2d/yarl-1.23.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88f9fb0116fbfcefcab70f85cf4b74a2b6ce5d199c41345296f49d974ddb4123", size = 95949, upload-time = "2026-03-01T22:06:25.697Z" },
{ url = "https://files.pythonhosted.org/packages/68/03/093f4055ed4cae649ac53bca3d180bd37102e9e11d048588e9ab0c0108d0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e7b0460976dc75cb87ad9cc1f9899a4b97751e7d4e77ab840fc9b6d377b8fd24", size = 95839, upload-time = "2026-03-01T22:06:27.309Z" },
{ url = "https://files.pythonhosted.org/packages/b9/28/4c75ebb108f322aa8f917ae10a8ffa4f07cae10a8a627b64e578617df6a0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:115136c4a426f9da976187d238e84139ff6b51a20839aa6e3720cd1026d768de", size = 90696, upload-time = "2026-03-01T22:06:29.048Z" },
{ url = "https://files.pythonhosted.org/packages/23/9c/42c2e2dd91c1a570402f51bdf066bfdb1241c2240ba001967bad778e77b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:ead11956716a940c1abc816b7df3fa2b84d06eaed8832ca32f5c5e058c65506b", size = 100865, upload-time = "2026-03-01T22:06:30.525Z" },
{ url = "https://files.pythonhosted.org/packages/74/05/1bcd60a8a0a914d462c305137246b6f9d167628d73568505fce3f1cb2e65/yarl-1.23.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:fe8f8f5e70e6dbdfca9882cd9deaac058729bcf323cf7a58660901e55c9c94f6", size = 96234, upload-time = "2026-03-01T22:06:32.692Z" },
{ url = "https://files.pythonhosted.org/packages/90/b2/f52381aac396d6778ce516b7bc149c79e65bfc068b5de2857ab69eeea3b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:a0e317df055958a0c1e79e5d2aa5a5eaa4a6d05a20d4b0c9c3f48918139c9fc6", size = 100295, upload-time = "2026-03-01T22:06:34.268Z" },
{ url = "https://files.pythonhosted.org/packages/e5/e8/638bae5bbf1113a659b2435d8895474598afe38b4a837103764f603aba56/yarl-1.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f0fd84de0c957b2d280143522c4f91a73aada1923caee763e24a2b3fda9f8a5", size = 97784, upload-time = "2026-03-01T22:06:35.864Z" },
{ url = "https://files.pythonhosted.org/packages/80/25/a3892b46182c586c202629fc2159aa13975d3741d52ebd7347fd501d48d5/yarl-1.23.0-cp313-cp313t-win32.whl", hash = "sha256:93a784271881035ab4406a172edb0faecb6e7d00f4b53dc2f55919d6c9688595", size = 88313, upload-time = "2026-03-01T22:06:37.39Z" },
{ url = "https://files.pythonhosted.org/packages/43/68/8c5b36aa5178900b37387937bc2c2fe0e9505537f713495472dcf6f6fccc/yarl-1.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dd00607bffbf30250fe108065f07453ec124dbf223420f57f5e749b04295e090", size = 94932, upload-time = "2026-03-01T22:06:39.579Z" },
{ url = "https://files.pythonhosted.org/packages/c6/cc/d79ba8292f51f81f4dc533a8ccfb9fc6992cabf0998ed3245de7589dc07c/yarl-1.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ac09d42f48f80c9ee1635b2fcaa819496a44502737660d3c0f2ade7526d29144", size = 84786, upload-time = "2026-03-01T22:06:41.988Z" },
{ url = "https://files.pythonhosted.org/packages/90/98/b85a038d65d1b92c3903ab89444f48d3cee490a883477b716d7a24b1a78c/yarl-1.23.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:21d1b7305a71a15b4794b5ff22e8eef96ff4a6d7f9657155e5aa419444b28912", size = 124455, upload-time = "2026-03-01T22:06:43.615Z" },
{ url = "https://files.pythonhosted.org/packages/39/54/bc2b45559f86543d163b6e294417a107bb87557609007c007ad889afec18/yarl-1.23.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:85610b4f27f69984932a7abbe52703688de3724d9f72bceb1cca667deff27474", size = 86752, upload-time = "2026-03-01T22:06:45.425Z" },
{ url = "https://files.pythonhosted.org/packages/24/f9/e8242b68362bffe6fb536c8db5076861466fc780f0f1b479fc4ffbebb128/yarl-1.23.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23f371bd662cf44a7630d4d113101eafc0cfa7518a2760d20760b26021454719", size = 86291, upload-time = "2026-03-01T22:06:46.974Z" },
{ url = "https://files.pythonhosted.org/packages/ea/d8/d1cb2378c81dd729e98c716582b1ccb08357e8488e4c24714658cc6630e8/yarl-1.23.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a80f77dc1acaaa61f0934176fccca7096d9b1ff08c8ba9cddf5ae034a24319", size = 99026, upload-time = "2026-03-01T22:06:48.459Z" },
{ url = "https://files.pythonhosted.org/packages/0a/ff/7196790538f31debe3341283b5b0707e7feb947620fc5e8236ef28d44f72/yarl-1.23.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bd654fad46d8d9e823afbb4f87c79160b5a374ed1ff5bde24e542e6ba8f41434", size = 92355, upload-time = "2026-03-01T22:06:50.306Z" },
{ url = "https://files.pythonhosted.org/packages/c1/56/25d58c3eddde825890a5fe6aa1866228377354a3c39262235234ab5f616b/yarl-1.23.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:682bae25f0a0dd23a056739f23a134db9f52a63e2afd6bfb37ddc76292bbd723", size = 106417, upload-time = "2026-03-01T22:06:52.1Z" },
{ url = "https://files.pythonhosted.org/packages/51/8a/882c0e7bc8277eb895b31bce0138f51a1ba551fc2e1ec6753ffc1e7c1377/yarl-1.23.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a82836cab5f197a0514235aaf7ffccdc886ccdaa2324bc0aafdd4ae898103039", size = 106422, upload-time = "2026-03-01T22:06:54.424Z" },
{ url = "https://files.pythonhosted.org/packages/42/2b/fef67d616931055bf3d6764885990a3ac647d68734a2d6a9e1d13de437a2/yarl-1.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c57676bdedc94cd3bc37724cf6f8cd2779f02f6aba48de45feca073e714fe52", size = 101915, upload-time = "2026-03-01T22:06:55.895Z" },
{ url = "https://files.pythonhosted.org/packages/18/6a/530e16aebce27c5937920f3431c628a29a4b6b430fab3fd1c117b26ff3f6/yarl-1.23.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7f8dc16c498ff06497c015642333219871effba93e4a2e8604a06264aca5c5c", size = 100690, upload-time = "2026-03-01T22:06:58.21Z" },
{ url = "https://files.pythonhosted.org/packages/88/08/93749219179a45e27b036e03260fda05190b911de8e18225c294ac95bbc9/yarl-1.23.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5ee586fb17ff8f90c91cf73c6108a434b02d69925f44f5f8e0d7f2f260607eae", size = 98750, upload-time = "2026-03-01T22:06:59.794Z" },
{ url = "https://files.pythonhosted.org/packages/d9/cf/ea424a004969f5d81a362110a6ac1496d79efdc6d50c2c4b2e3ea0fc2519/yarl-1.23.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:17235362f580149742739cc3828b80e24029d08cbb9c4bda0242c7b5bc610a8e", size = 94685, upload-time = "2026-03-01T22:07:01.375Z" },
{ url = "https://files.pythonhosted.org/packages/e2/b7/14341481fe568e2b0408bcf1484c652accafe06a0ade9387b5d3fd9df446/yarl-1.23.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0793e2bd0cf14234983bbb371591e6bea9e876ddf6896cdcc93450996b0b5c85", size = 106009, upload-time = "2026-03-01T22:07:03.151Z" },
{ url = "https://files.pythonhosted.org/packages/0a/e6/5c744a9b54f4e8007ad35bce96fbc9218338e84812d36f3390cea616881a/yarl-1.23.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3650dc2480f94f7116c364096bc84b1d602f44224ef7d5c7208425915c0475dd", size = 100033, upload-time = "2026-03-01T22:07:04.701Z" },
{ url = "https://files.pythonhosted.org/packages/0c/23/e3bfc188d0b400f025bc49d99793d02c9abe15752138dcc27e4eaf0c4a9e/yarl-1.23.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f40e782d49630ad384db66d4d8b73ff4f1b8955dc12e26b09a3e3af064b3b9d6", size = 106483, upload-time = "2026-03-01T22:07:06.231Z" },
{ url = "https://files.pythonhosted.org/packages/72/42/f0505f949a90b3f8b7a363d6cbdf398f6e6c58946d85c6d3a3bc70595b26/yarl-1.23.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94f8575fbdf81749008d980c17796097e645574a3b8c28ee313931068dad14fe", size = 102175, upload-time = "2026-03-01T22:07:08.4Z" },
{ url = "https://files.pythonhosted.org/packages/aa/65/b39290f1d892a9dd671d1c722014ca062a9c35d60885d57e5375db0404b5/yarl-1.23.0-cp314-cp314-win32.whl", hash = "sha256:c8aa34a5c864db1087d911a0b902d60d203ea3607d91f615acd3f3108ac32169", size = 83871, upload-time = "2026-03-01T22:07:09.968Z" },
{ url = "https://files.pythonhosted.org/packages/a9/5b/9b92f54c784c26e2a422e55a8d2607ab15b7ea3349e28359282f84f01d43/yarl-1.23.0-cp314-cp314-win_amd64.whl", hash = "sha256:63e92247f383c85ab00dd0091e8c3fa331a96e865459f5ee80353c70a4a42d70", size = 89093, upload-time = "2026-03-01T22:07:11.501Z" },
{ url = "https://files.pythonhosted.org/packages/e0/7d/8a84dc9381fd4412d5e7ff04926f9865f6372b4c2fd91e10092e65d29eb8/yarl-1.23.0-cp314-cp314-win_arm64.whl", hash = "sha256:70efd20be968c76ece7baa8dafe04c5be06abc57f754d6f36f3741f7aa7a208e", size = 83384, upload-time = "2026-03-01T22:07:13.069Z" },
{ url = "https://files.pythonhosted.org/packages/dd/8d/d2fad34b1c08aa161b74394183daa7d800141aaaee207317e82c790b418d/yarl-1.23.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9a18d6f9359e45722c064c97464ec883eb0e0366d33eda61cb19a244bf222679", size = 131019, upload-time = "2026-03-01T22:07:14.903Z" },
{ url = "https://files.pythonhosted.org/packages/19/ff/33009a39d3ccf4b94d7d7880dfe17fb5816c5a4fe0096d9b56abceea9ac7/yarl-1.23.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2803ed8b21ca47a43da80a6fd1ed3019d30061f7061daa35ac54f63933409412", size = 89894, upload-time = "2026-03-01T22:07:17.372Z" },
{ url = "https://files.pythonhosted.org/packages/0c/f1/dab7ac5e7306fb79c0190766a3c00b4cb8d09a1f390ded68c85a5934faf5/yarl-1.23.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:394906945aa8b19fc14a61cf69743a868bb8c465efe85eee687109cc540b98f4", size = 89979, upload-time = "2026-03-01T22:07:19.361Z" },
{ url = "https://files.pythonhosted.org/packages/aa/b1/08e95f3caee1fad6e65017b9f26c1d79877b502622d60e517de01e72f95d/yarl-1.23.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71d006bee8397a4a89f469b8deb22469fe7508132d3c17fa6ed871e79832691c", size = 95943, upload-time = "2026-03-01T22:07:21.266Z" },
{ url = "https://files.pythonhosted.org/packages/c0/cc/6409f9018864a6aa186c61175b977131f373f1988e198e031236916e87e4/yarl-1.23.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:62694e275c93d54f7ccedcfef57d42761b2aad5234b6be1f3e3026cae4001cd4", size = 88786, upload-time = "2026-03-01T22:07:23.129Z" },
{ url = "https://files.pythonhosted.org/packages/76/40/cc22d1d7714b717fde2006fad2ced5efe5580606cb059ae42117542122f3/yarl-1.23.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31de1613658308efdb21ada98cbc86a97c181aa050ba22a808120bb5be3ab94", size = 101307, upload-time = "2026-03-01T22:07:24.689Z" },
{ url = "https://files.pythonhosted.org/packages/8f/0d/476c38e85ddb4c6ec6b20b815bdd779aa386a013f3d8b85516feee55c8dc/yarl-1.23.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb1e8b8d66c278b21d13b0a7ca22c41dd757a7c209c6b12c313e445c31dd3b28", size = 100904, upload-time = "2026-03-01T22:07:26.287Z" },
{ url = "https://files.pythonhosted.org/packages/72/32/0abe4a76d59adf2081dcb0397168553ece4616ada1c54d1c49d8936c74f8/yarl-1.23.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50f9d8d531dfb767c565f348f33dd5139a6c43f5cbdf3f67da40d54241df93f6", size = 97728, upload-time = "2026-03-01T22:07:27.906Z" },
{ url = "https://files.pythonhosted.org/packages/b7/35/7b30f4810fba112f60f5a43237545867504e15b1c7647a785fbaf588fac2/yarl-1.23.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575aa4405a656e61a540f4a80eaa5260f2a38fff7bfdc4b5f611840d76e9e277", size = 95964, upload-time = "2026-03-01T22:07:30.198Z" },
{ url = "https://files.pythonhosted.org/packages/2d/86/ed7a73ab85ef00e8bb70b0cb5421d8a2a625b81a333941a469a6f4022828/yarl-1.23.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:041b1a4cefacf65840b4e295c6985f334ba83c30607441ae3cf206a0eed1a2e4", size = 95882, upload-time = "2026-03-01T22:07:32.132Z" },
{ url = "https://files.pythonhosted.org/packages/19/90/d56967f61a29d8498efb7afb651e0b2b422a1e9b47b0ab5f4e40a19b699b/yarl-1.23.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d38c1e8231722c4ce40d7593f28d92b5fc72f3e9774fe73d7e800ec32299f63a", size = 90797, upload-time = "2026-03-01T22:07:34.404Z" },
{ url = "https://files.pythonhosted.org/packages/72/00/8b8f76909259f56647adb1011d7ed8b321bcf97e464515c65016a47ecdf0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d53834e23c015ee83a99377db6e5e37d8484f333edb03bd15b4bc312cc7254fb", size = 101023, upload-time = "2026-03-01T22:07:35.953Z" },
{ url = "https://files.pythonhosted.org/packages/ac/e2/cab11b126fb7d440281b7df8e9ddbe4851e70a4dde47a202b6642586b8d9/yarl-1.23.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2e27c8841126e017dd2a054a95771569e6070b9ee1b133366d8b31beb5018a41", size = 96227, upload-time = "2026-03-01T22:07:37.594Z" },
{ url = "https://files.pythonhosted.org/packages/c2/9b/2c893e16bfc50e6b2edf76c1a9eb6cb0c744346197e74c65e99ad8d634d0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:76855800ac56f878847a09ce6dba727c93ca2d89c9e9d63002d26b916810b0a2", size = 100302, upload-time = "2026-03-01T22:07:39.334Z" },
{ url = "https://files.pythonhosted.org/packages/28/ec/5498c4e3a6d5f1003beb23405671c2eb9cdbf3067d1c80f15eeafe301010/yarl-1.23.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e09fd068c2e169a7070d83d3bde728a4d48de0549f975290be3c108c02e499b4", size = 98202, upload-time = "2026-03-01T22:07:41.717Z" },
{ url = "https://files.pythonhosted.org/packages/fe/c3/cd737e2d45e70717907f83e146f6949f20cc23cd4bf7b2688727763aa458/yarl-1.23.0-cp314-cp314t-win32.whl", hash = "sha256:73309162a6a571d4cbd3b6a1dcc703c7311843ae0d1578df6f09be4e98df38d4", size = 90558, upload-time = "2026-03-01T22:07:43.433Z" },
{ url = "https://files.pythonhosted.org/packages/e1/19/3774d162f6732d1cfb0b47b4140a942a35ca82bb19b6db1f80e9e7bdc8f8/yarl-1.23.0-cp314-cp314t-win_amd64.whl", hash = "sha256:4503053d296bc6e4cbd1fad61cf3b6e33b939886c4f249ba7c78b602214fabe2", size = 97610, upload-time = "2026-03-01T22:07:45.773Z" },
{ url = "https://files.pythonhosted.org/packages/51/47/3fa2286c3cb162c71cdb34c4224d5745a1ceceb391b2bd9b19b668a8d724/yarl-1.23.0-cp314-cp314t-win_arm64.whl", hash = "sha256:44bb7bef4ea409384e3f8bc36c063d77ea1b8d4a5b2706956c0d6695f07dcc25", size = 86041, upload-time = "2026-03-01T22:07:49.026Z" },
{ url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" },
]
[[package]]

View File

@@ -81,6 +81,16 @@
@apply border;
}
/* When expanded, the header's bottom border acts as a separator between
header and body. It should always remain the default border color,
regardless of any status borderColor applied to the card. */
.opal-card-expandable-header:has(
+ .opal-card-expandable-wrapper[data-expanded="true"]
) {
@apply border-b-border-01;
}
/* ── Content wrapper: grid 0fr↔1fr animation ─────────────────────────── */
.opal-card-expandable-wrapper {

View File

@@ -14,6 +14,8 @@ and border colors.
| `icon` | `IconFunctionComponent` | per variant | Override the default variant icon |
| `title` | `string \| RichStr` | — | Main title text |
| `description` | `string \| RichStr` | — | Description below the title |
| `padding` | `"sm" \| "xs"` | `"sm"` | Padding preset for the outer card |
| `headerPadding` | `PaddingVariants` | `"fit"` | Padding around the header Content area. `"fit"` → no padding; `"sm"``p-2`. |
| `bottomChildren` | `ReactNode` | — | Content below a divider, under the main content |
| `rightChildren` | `ReactNode` | — | Content on the right side. Mutually exclusive with `onClose`. |
| `onClose` | `() => void` | — | Close button callback. When omitted, no close button is rendered. |

View File

@@ -38,6 +38,9 @@ interface MessageCardBaseProps {
/** Padding preset. @default "sm" */
padding?: Extract<PaddingVariants, "sm" | "xs">;
/** Padding around the header Content area. @default "fit" */
headerPadding?: PaddingVariants;
/**
* Content rendered below a divider, under the main content area.
* When provided, a `Divider` is inserted between the `ContentAction` and this node.
@@ -122,6 +125,7 @@ function MessageCard({
title,
description,
padding = "sm",
headerPadding = "fit",
bottomChildren,
rightChildren,
onClose,
@@ -146,19 +150,22 @@ function MessageCard({
<div
className={cn("opal-message-card", paddingVariants[padding])}
data-variant={variant}
data-opal-status-border={variant}
ref={ref}
>
<ContentAction
icon={(props) => (
<Icon {...props} className={cn(props.className, iconClass)} />
)}
title={title}
description={description}
sizePreset="main-ui"
variant="section"
padding="md"
rightChildren={right}
/>
<div className={paddingVariants[headerPadding]}>
<ContentAction
icon={(props) => (
<Icon {...props} className={cn(props.className, iconClass)} />
)}
title={title}
description={description}
sizePreset="main-ui"
variant="section"
padding="fit"
rightChildren={right}
/>
</div>
{bottomChildren && (
<>

View File

@@ -1,25 +1,26 @@
.opal-message-card {
@apply flex flex-col w-full self-stretch rounded-16 border;
@apply flex flex-col gap-1 w-full self-stretch rounded-16 border;
}
/* Variant colors */
/* Variant background colors. Border *color* lives in `cards/shared.css` and
is keyed off the `data-opal-status-border` attribute. */
.opal-message-card[data-variant="default"] {
@apply bg-background-tint-01 border-border-01;
@apply bg-background-tint-01;
}
.opal-message-card[data-variant="info"] {
@apply bg-status-info-00 border-status-info-02;
@apply bg-status-info-00;
}
.opal-message-card[data-variant="success"] {
@apply bg-status-success-00 border-status-success-02;
@apply bg-status-success-00;
}
.opal-message-card[data-variant="warning"] {
@apply bg-status-warning-00 border-status-warning-02;
@apply bg-status-warning-00;
}
.opal-message-card[data-variant="error"] {
@apply bg-status-error-00 border-status-error-02;
@apply bg-status-error-00;
}

View File

@@ -114,6 +114,7 @@ export { default as SvgMoon } from "@opal/icons/moon";
export { default as SvgMoreHorizontal } from "@opal/icons/more-horizontal";
export { default as SvgMusicSmall } from "@opal/icons/music-small";
export { default as SvgNetworkGraph } from "@opal/icons/network-graph";
export { default as SvgNoImage } from "@opal/icons/no-image";
export { default as SvgNotificationBubble } from "@opal/icons/notification-bubble";
export { default as SvgOnyxOctagon } from "@opal/icons/onyx-octagon";
export { default as SvgOrganization } from "@opal/icons/organization";
@@ -148,6 +149,7 @@ export { default as SvgSlack } from "@opal/icons/slack";
export { default as SvgSlash } from "@opal/icons/slash";
export { default as SvgSliders } from "@opal/icons/sliders";
export { default as SvgSlidersSmall } from "@opal/icons/sliders-small";
export { default as SvgSlowTime } from "@opal/icons/slow-time";
export { default as SvgSort } from "@opal/icons/sort";
export { default as SvgSortOrder } from "@opal/icons/sort-order";
export { default as SvgSparkle } from "@opal/icons/sparkle";

View File

@@ -0,0 +1,20 @@
import type { IconProps } from "@opal/types";
const SvgNoImage = ({ size, ...props }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
stroke="currentColor"
{...props}
>
<path
d="M11 14L6.06066 9.06072C5.47487 8.47498 4.52513 8.47498 3.93934 9.06072L2 11M11 14L12.5 13.9998C12.9142 13.9998 13.2892 13.832 13.5606 13.5606M11 14L3.5 13.9998C2.67157 13.9998 2 13.3283 2 12.4999V11M2 11V3.49998C2 3.08577 2.16789 2.71078 2.43934 2.43934M1 1L2.43934 2.43934M2.43934 2.43934L13.5606 13.5606M13.5606 13.5606L15 15M10.8033 7.30328C11.1515 7.0286 11.375 6.60288 11.375 6.12494C11.375 5.29653 10.7035 4.62496 9.875 4.62496C9.39706 4.62496 8.97135 4.84847 8.69666 5.19666M14 10.5V3.49998C14 2.67156 13.3285 2 12.5 2H5.5"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
export default SvgNoImage;

View File

@@ -0,0 +1,27 @@
import type { IconProps } from "@opal/types";
const SvgSlowTime = ({ size, ...props }: IconProps) => (
<svg
width={size}
height={size}
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
stroke="currentColor"
{...props}
>
<g clipPath="url(#clip0_997_17795)">
<path
d="M8 4.00001V8.00001L11 9.5M13.1404 12.2453C11.9176 13.7243 10.0689 14.6667 7.99999 14.6667C6.70211 14.6667 5.49086 14.2958 4.46643 13.6542M14.4826 9.5624C14.6029 9.06125 14.6667 8.53806 14.6667 7.99999C14.6667 4.83387 12.4596 2.18324 9.5 1.50275M6.5 1.50275C5.76902 1.67082 5.08394 1.95908 4.46668 2.3456M2.34559 4.4667C1.95907 5.08396 1.67082 5.76903 1.50275 6.50001M1.50276 9.50001C1.67083 10.231 1.95909 10.916 2.34561 11.5333"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
/>
</g>
<defs>
<clipPath id="clip0_997_17795">
<rect width={16} height={16} fill="white" />
</clipPath>
</defs>
</svg>
);
export default SvgSlowTime;

View File

@@ -1,5 +1,5 @@
import type { Meta, StoryObj } from "@storybook/react";
import { Card } from "@opal/layouts";
import { Card, Content } from "@opal/layouts";
import { Button } from "@opal/components";
import {
SvgArrowExchange,
@@ -39,12 +39,16 @@ export const Default: Story = {
render: () => (
<div className="w-[28rem] border rounded-16">
<Card.Header
sizePreset="main-ui"
variant="section"
icon={SvgGlobe}
title="Google Search"
description="Web search provider"
rightChildren={
headerChildren={
<Content
sizePreset="main-ui"
variant="section"
icon={SvgGlobe}
title="Google Search"
description="Web search provider"
/>
}
topRightChildren={
<Button prominence="tertiary" rightIcon={SvgArrowExchange}>
Connect
</Button>
@@ -58,12 +62,16 @@ export const WithBothSlots: Story = {
render: () => (
<div className="w-[28rem] border rounded-16">
<Card.Header
sizePreset="main-ui"
variant="section"
icon={SvgGlobe}
title="Google Search"
description="Currently the default provider."
rightChildren={
headerChildren={
<Content
sizePreset="main-ui"
variant="section"
icon={SvgGlobe}
title="Google Search"
description="Currently the default provider."
/>
}
topRightChildren={
<Button variant="action" prominence="tertiary" icon={SvgCheckSquare}>
Current Default
</Button>
@@ -93,12 +101,16 @@ export const RightChildrenOnly: Story = {
render: () => (
<div className="w-[28rem] border rounded-16">
<Card.Header
sizePreset="main-ui"
variant="section"
icon={SvgGlobe}
title="OpenAI"
description="Not configured"
rightChildren={
headerChildren={
<Content
sizePreset="main-ui"
variant="section"
icon={SvgGlobe}
title="OpenAI"
description="Not configured"
/>
}
topRightChildren={
<Button prominence="tertiary" rightIcon={SvgArrowExchange}>
Connect
</Button>
@@ -112,11 +124,15 @@ export const NoRightChildren: Story = {
render: () => (
<div className="w-[28rem] border rounded-16">
<Card.Header
sizePreset="main-ui"
variant="section"
icon={SvgGlobe}
title="Section Header"
description="No actions on the right."
headerChildren={
<Content
sizePreset="main-ui"
variant="section"
icon={SvgGlobe}
title="Section Header"
description="No actions on the right."
/>
}
/>
</div>
),
@@ -126,12 +142,16 @@ export const LongContent: Story = {
render: () => (
<div className="w-[28rem] border rounded-16">
<Card.Header
sizePreset="main-ui"
variant="section"
icon={SvgGlobe}
title="Very Long Provider Name That Should Truncate"
description="This is a much longer description that tests how the layout handles overflow when the content area needs to shrink."
rightChildren={
headerChildren={
<Content
sizePreset="main-ui"
variant="section"
icon={SvgGlobe}
title="Very Long Provider Name That Should Truncate"
description="This is a much longer description that tests how the layout handles overflow when the content area needs to shrink."
/>
}
topRightChildren={
<Button variant="action" prominence="tertiary" icon={SvgCheckSquare}>
Current Default
</Button>

View File

@@ -6,56 +6,62 @@ A namespace of card layout primitives. Each sub-component handles a specific reg
## Card.Header
A card header layout that pairs a [`Content`](../content/README.md) block with a right-side column and an optional full-width children slot.
A flexible card header with one slot for the main header content, two stacked slots in a right-side column, and a full-width slot below.
### Why Card.Header?
[`ContentAction`](../content-action/README.md) provides a single `rightChildren` slot. Card headers typically need two distinct right-side regions — a primary action on top and secondary actions on the bottom. `Card.Header` provides this with `rightChildren` and `bottomRightChildren` slots, plus a `children` slot for full-width content below the header row (e.g., search bars, expandable tool lists).
[`ContentAction`](../content-action/README.md) provides a single right-side slot. Card headers typically need more — a primary action on top, secondary actions on the bottom, and sometimes a full-width region beneath the entire row (e.g. expandable details, search bars, secondary info).
`Card.Header` is layout-only — it intentionally doesn't bake in `Content` props. Pass a `<Content />` (or any other element) into `headerChildren` for the icon/title/description region.
### Props
Inherits **all** props from [`Content`](../content/README.md) (icon, title, description, sizePreset, variant, editable, onTitleChange, suffix, etc.) plus:
| Prop | Type | Default | Description |
|---|---|---|---|
| `rightChildren` | `ReactNode` | `undefined` | Content rendered to the right of the Content block (top of right column). |
| `bottomRightChildren` | `ReactNode` | `undefined` | Content rendered below `rightChildren` in the same column. Laid out as `flex flex-row`. |
| `children` | `ReactNode` | `undefined` | Content rendered below the full header row, spanning the entire width. |
| `headerChildren` | `ReactNode` | `undefined` | Content rendered in the top-left header slot — typically a `<Content />` block. |
| `headerPadding` | `"sm" \| "fit"` | `"fit"` | Padding applied around `headerChildren`. `"sm"``p-2`; `"fit"``p-0`. |
| `topRightChildren` | `ReactNode` | `undefined` | Content rendered to the right of `headerChildren` (top of right column). |
| `bottomRightChildren` | `ReactNode` | `undefined` | Content rendered below `topRightChildren` in the same column. Laid out as `flex flex-row`. |
| `bottomChildren` | `ReactNode` | `undefined` | Content rendered below the entire header (left + right columns), spanning the full width. |
### Layout Structure
```
+---------------------------------------------------------+
| [Content (p-2, self-start)] [rightChildren] |
| icon + title + description [bottomRightChildren] |
+---------------------------------------------------------+
| [children — full width] |
+---------------------------------------------------------+
+------------------+----------------+
| headerChildren | topRight |
+ +----------------+
| | bottomRight |
+------------------+----------------+
| bottomChildren (full width) |
+-----------------------------------+
```
- Outer wrapper: `flex flex-col w-full`
- Header row: `flex flex-row items-stretch w-full`
- Content area: `flex-1 min-w-0 self-start p-2`top-aligned with fixed padding
- Right column: `flex flex-col items-end shrink-0`no padding, no gap
- `bottomRightChildren` wrapper: `flex flex-row` — lays children out horizontally
- `children` wrapper: `w-full` — only rendered when children are provided
- Header row: `flex flex-row items-start w-full` — columns are independent in height
- Left column (headerChildren wrapper): `self-start grow min-w-0` + `headerPadding` variant (default `p-0`)grows to fill available space
- Right column: `flex flex-col items-end shrink-0`shrinks to fit its content
- `bottomChildren` wrapper: `w-full` — only rendered when provided
### Usage
#### Card with primary and secondary actions
```tsx
import { Card } from "@opal/layouts";
import { Card, Content } from "@opal/layouts";
import { Button } from "@opal/components";
import { SvgGlobe, SvgSettings, SvgUnplug, SvgCheckSquare } from "@opal/icons";
<Card.Header
icon={SvgGlobe}
title="Google Search"
description="Web search provider"
sizePreset="main-ui"
variant="section"
rightChildren={
headerChildren={
<Content
icon={SvgGlobe}
title="Google Search"
description="Web search provider"
sizePreset="main-ui"
variant="section"
/>
}
topRightChildren={
<Button icon={SvgCheckSquare} variant="action" prominence="tertiary">
Current Default
</Button>
@@ -73,12 +79,16 @@ import { SvgGlobe, SvgSettings, SvgUnplug, SvgCheckSquare } from "@opal/icons";
```tsx
<Card.Header
icon={SvgCloud}
title="OpenAI"
description="Not configured"
sizePreset="main-ui"
variant="section"
rightChildren={
headerChildren={
<Content
icon={SvgCloud}
title="OpenAI"
description="Not configured"
sizePreset="main-ui"
variant="section"
/>
}
topRightChildren={
<Button rightIcon={SvgArrowExchange} prominence="tertiary">
Connect
</Button>
@@ -86,31 +96,36 @@ import { SvgGlobe, SvgSettings, SvgUnplug, SvgCheckSquare } from "@opal/icons";
/>
```
#### Card with expandable children
#### Card with extra info beneath the header
```tsx
<Card.Header
icon={SvgServer}
title="MCP Server"
description="12 tools available"
sizePreset="main-ui"
variant="section"
rightChildren={<Button icon={SvgSettings} prominence="tertiary" />}
>
<SearchBar placeholder="Search tools..." />
</Card.Header>
```
#### No right children
```tsx
<Card.Header
icon={SvgInfo}
title="Section Header"
description="Description text"
sizePreset="main-content"
variant="section"
headerChildren={
<Content
icon={SvgServer}
title="MCP Server"
description="12 tools available"
sizePreset="main-ui"
variant="section"
/>
}
topRightChildren={<Button icon={SvgSettings} prominence="tertiary" />}
bottomChildren={<SearchBar placeholder="Search tools..." />}
/>
```
When both `rightChildren` and `bottomRightChildren` are omitted and no `children` are provided, the component renders only the padded `Content`.
#### No slots
```tsx
<Card.Header
headerChildren={
<Content
icon={SvgInfo}
title="Section Header"
description="Description text"
sizePreset="main-content"
variant="section"
/>
}
/>
```

View File

@@ -2,10 +2,17 @@
// Types
// ---------------------------------------------------------------------------
import { paddingVariants } from "@opal/shared";
import type { PaddingVariants } from "@opal/types";
import { cn } from "@opal/utils";
interface CardHeaderProps {
/** Content rendered in the top-left header slot — typically a {@link Content} block. */
headerChildren?: React.ReactNode;
/** Padding applied around `headerChildren`. @default "fit" */
headerPadding?: Extract<PaddingVariants, "sm" | "fit">;
/** Content rendered to the right of `headerChildren` (top of right column). */
topRightChildren?: React.ReactNode;
@@ -68,6 +75,7 @@ interface CardHeaderProps {
*/
function Header({
headerChildren,
headerPadding = "fit",
topRightChildren,
bottomRightChildren,
bottomChildren,
@@ -78,7 +86,14 @@ function Header({
<div className="flex flex-col w-full">
<div className="flex flex-row items-start w-full">
{headerChildren != null && (
<div className="self-start p-2 grow min-w-0">{headerChildren}</div>
<div
className={cn(
"self-start grow min-w-0",
paddingVariants[headerPadding]
)}
>
{headerChildren}
</div>
)}
{hasRight && (
<div className="flex flex-col items-end shrink-0">

19
web/lib/opal/src/root.css Normal file
View File

@@ -0,0 +1,19 @@
/**
* @opal/root — Root-level design tokens for the opal design system.
*
* Import this file once at the top of the component tree (or from shared.ts)
* to make all opal CSS custom properties available globally.
*
* App-level tokens (container widths, page widths, etc.) live in the app's
* own CSS — this file is strictly for library-owned tokens.
*/
:root {
/* ── Line heights / container sizes ──────────────────────────────────── */
--opal-line-height-lg: 2.25rem;
--opal-line-height-md: 1.75rem;
--opal-line-height-sm: 1.5rem;
--opal-line-height-xs: 1.25rem;
--opal-line-height-2xs: 1rem;
}

View File

@@ -6,6 +6,8 @@
* circular imports and gives every consumer a single source of truth.
*/
import "@opal/root.css";
import type {
SizeVariants,
OverridableExtremaSizeVariants,
@@ -21,14 +23,16 @@ import type {
* Each entry maps a named preset to Tailwind utility classes for
* `height`, `min-width`, and `padding`.
*
* | Key | Height | Padding |
* |-------|---------------|----------|
* | `lg` | 2.25rem (36px)| `p-2` |
* | `md` | 1.75rem (28px)| `p-1` |
* | `sm` | 1.5rem (24px) | `p-1` |
* | `xs` | 1.25rem (20px)| `p-0.5` |
* | `2xs` | 1rem (16px) | `p-0.5` |
* | `fit` | h-fit | `p-0` |
* Heights are driven by CSS custom properties defined in `@opal/root.css`.
*
* | Key | Height | Padding |
* |-------|-----------------------------|----------|
* | `lg` | `--opal-line-height-lg` | `p-2` |
* | `md` | `--opal-line-height-md` | `p-1` |
* | `sm` | `--opal-line-height-sm` | `p-1` |
* | `xs` | `--opal-line-height-xs` | `p-0.5` |
* | `2xs` | `--opal-line-height-2xs` | `p-0.5` |
* | `fit` | `h-fit` | `p-0` |
*/
type ContainerProperties = {
height: string;
@@ -40,15 +44,31 @@ const containerSizeVariants: Record<
ContainerProperties
> = {
fit: { height: "h-fit", minWidth: "", padding: "p-0" },
lg: { height: "h-[2.25rem]", minWidth: "min-w-[2.25rem]", padding: "p-2" },
md: { height: "h-[1.75rem]", minWidth: "min-w-[1.75rem]", padding: "p-1" },
sm: { height: "h-[1.5rem]", minWidth: "min-w-[1.5rem]", padding: "p-1" },
lg: {
height: "h-[var(--opal-line-height-lg)]",
minWidth: "min-w-[var(--opal-line-height-lg)]",
padding: "p-2",
},
md: {
height: "h-[var(--opal-line-height-md)]",
minWidth: "min-w-[var(--opal-line-height-md)]",
padding: "p-1",
},
sm: {
height: "h-[var(--opal-line-height-sm)]",
minWidth: "min-w-[var(--opal-line-height-sm)]",
padding: "p-1",
},
xs: {
height: "h-[1.25rem]",
minWidth: "min-w-[1.25rem]",
height: "h-[var(--opal-line-height-xs)]",
minWidth: "min-w-[var(--opal-line-height-xs)]",
padding: "p-0.5",
},
"2xs": {
height: "h-[var(--opal-line-height-2xs)]",
minWidth: "min-w-[var(--opal-line-height-2xs)]",
padding: "p-0.5",
},
"2xs": { height: "h-[1rem]", minWidth: "min-w-[1rem]", padding: "p-0.5" },
} as const;
// ---------------------------------------------------------------------------

40
web/package-lock.json generated
View File

@@ -95,6 +95,7 @@
"zustand": "^5.0.8"
},
"devDependencies": {
"@axe-core/playwright": "^4.11.2",
"@playwright/test": "^1.39.0",
"@storybook/addon-essentials": "^8.6.18",
"@storybook/addon-themes": "^8.6.18",
@@ -191,6 +192,19 @@
"dev": true,
"license": "ISC"
},
"node_modules/@axe-core/playwright": {
"version": "4.11.2",
"resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.11.2.tgz",
"integrity": "sha512-iP6hfNl9G0j/SEUSo8M7D80RbcDo9KRAAfDP4IT5OHB+Wm6zUHIrm8Y51BKI+Oyqduvipf9u1hcRy57zCBKzWQ==",
"dev": true,
"license": "MPL-2.0",
"dependencies": {
"axe-core": "~4.11.3"
},
"peerDependencies": {
"playwright-core": ">= 1.0.0"
}
},
"node_modules/@babel/code-frame": {
"version": "7.27.1",
"license": "MIT",
@@ -5765,9 +5779,9 @@
}
},
"node_modules/@sentry/node/node_modules/brace-expansion": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
"integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==",
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
@@ -7196,9 +7210,9 @@
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
"integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==",
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -8207,7 +8221,9 @@
}
},
"node_modules/axe-core": {
"version": "4.11.0",
"version": "4.11.3",
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.3.tgz",
"integrity": "sha512-zBQouZixDTbo3jMGqHKyePxYxr1e5W8UdTmBQ7sNtaA9M2bE32daxxPLS/jojhKOHxQ7LWwPjfiwf/fhaJWzlg==",
"dev": true,
"license": "MPL-2.0",
"engines": {
@@ -8401,7 +8417,9 @@
}
},
"node_modules/brace-expansion": {
"version": "1.1.12",
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -10681,9 +10699,9 @@
}
},
"node_modules/glob/node_modules/brace-expansion": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz",
"integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==",
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"

View File

@@ -113,6 +113,7 @@
"zustand": "^5.0.8"
},
"devDependencies": {
"@axe-core/playwright": "^4.11.2",
"@playwright/test": "^1.39.0",
"@storybook/addon-essentials": "^8.6.18",
"@storybook/addon-themes": "^8.6.18",

View File

@@ -27,12 +27,12 @@ import {
connectorConfigs,
createConnectorInitialValues,
createConnectorValidationSchema,
defaultPruneFreqHours,
defaultRefreshFreqMinutes,
isLoadState,
Connector,
ConnectorBase,
} from "@/lib/connectors/connectors";
import { useSettings } from "@/hooks/useSettings";
import Modal from "@/refresh-components/Modal";
import { GmailMain } from "@/app/admin/connectors/[connector]/pages/gmail/GmailPage";
import {
@@ -149,6 +149,10 @@ export default function AddConnector({
}, []);
const router = useRouter();
const { settings } = useSettings();
const defaultPruneFreqHours = settings.default_pruning_freq
? settings.default_pruning_freq / 3600
: 600; // 25 days fallback until settings load
// State for managing credentials and files
const [currentCredential, setCurrentCredential] =
@@ -662,7 +666,7 @@ export default function AddConnector({
{formStep === 2 && (
<CardSection>
<AdvancedFormPage />
<AdvancedFormPage defaultPruneFreqHours={defaultPruneFreqHours} />
</CardSection>
)}

View File

@@ -3,7 +3,13 @@ import NumberInput from "./ConnectorInput/NumberInput";
import { TextFormField } from "@/components/Field";
import { Button } from "@opal/components";
import { SvgTrash } from "@opal/icons";
export default function AdvancedFormPage() {
interface AdvancedFormPageProps {
defaultPruneFreqHours?: number;
}
export default function AdvancedFormPage({
defaultPruneFreqHours = 600,
}: AdvancedFormPageProps) {
return (
<div className="py-4 flex flex-col gap-y-6 rounded-lg max-w-2xl mx-auto">
<h2 className="text-2xl font-bold mb-4 text-text-800">
@@ -14,7 +20,9 @@ export default function AdvancedFormPage() {
description={`
Checks all documents against the source to delete those that no longer exist.
Note: This process checks every document, so be cautious when increasing frequency.
Default is 720 hours (30 days). Decimal hours are supported (e.g., 0.1 hours = 6 minutes).
Default is ${defaultPruneFreqHours} hours (${Math.round(
defaultPruneFreqHours / 24
)} days). Decimal hours are supported (e.g., 0.1 hours = 6 minutes).
Enter 0 to disable pruning for this connector.
`}
label="Prune Frequency (hours)"

View File

@@ -44,7 +44,6 @@ export interface AdvancedSearchConfiguration {
enable_contextual_rag: boolean;
contextual_rag_llm_name: string | null;
contextual_rag_llm_provider: string | null;
multilingual_expansion: string[];
disable_rerank_for_streaming: boolean;
api_url: string | null;
num_rerank: number;

View File

@@ -101,7 +101,6 @@ const AdvancedEmbeddingFormPage = forwardRef<
contextual_rag_llm: getCurrentLLMValue,
}}
validationSchema={Yup.object().shape({
multilingual_expansion: Yup.array().of(Yup.string()),
multipass_indexing: Yup.boolean(),
enable_contextual_rag: Yup.boolean(),
contextual_rag_llm: Yup.string()
@@ -169,7 +168,6 @@ const AdvancedEmbeddingFormPage = forwardRef<
// Manually validate against the schema
Yup.object()
.shape({
multilingual_expansion: Yup.array().of(Yup.string()),
multipass_indexing: Yup.boolean(),
enable_contextual_rag: Yup.boolean(),
contextual_rag_llm: Yup.string()

View File

@@ -53,7 +53,6 @@ export default function EmbeddingForm() {
enable_contextual_rag: false,
contextual_rag_llm_name: null,
contextual_rag_llm_provider: null,
multilingual_expansion: [],
disable_rerank_for_streaming: false,
api_url: null,
num_rerank: 0,
@@ -144,7 +143,6 @@ export default function EmbeddingForm() {
enable_contextual_rag: searchSettings.enable_contextual_rag,
contextual_rag_llm_name: searchSettings.contextual_rag_llm_name,
contextual_rag_llm_provider: searchSettings.contextual_rag_llm_provider,
multilingual_expansion: searchSettings.multilingual_expansion,
disable_rerank_for_streaming:
searchSettings.disable_rerank_for_streaming,
num_rerank: searchSettings.num_rerank,

View File

@@ -23,7 +23,7 @@ export function SearchDateRangeSelector({
<TimeRangeSelector
value={value}
className={cn(
"border border-border bg-background rounded-lg flex flex-col w-64 max-h-96 overflow-y-auto flex overscroll-contain",
"border border-border bg-background rounded-lg flex flex-col w-64 max-h-96 overflow-y-auto overscroll-contain",
className
)}
timeRangeValues={timeRangeValues}

View File

@@ -74,6 +74,7 @@ export interface Settings {
max_allowed_upload_size_mb?: number;
// Factory defaults for the restore button.
default_pruning_freq?: number;
default_user_file_max_upload_size_mb?: number;
default_file_token_count_threshold_k?: number;
}

View File

@@ -1873,8 +1873,6 @@ export function createConnectorValidationSchema(
return object;
}
export const defaultPruneFreqHours = 720; // 30 days in hours
export const defaultRefreshFreqMinutes = 30; // 30 minutes
// CONNECTORS

View File

@@ -183,6 +183,11 @@ export async function upsertMCPServer(serverData: {
api_token?: string;
oauth_client_id?: string;
oauth_client_secret?: string;
// Mirrors the LLM-provider `api_key_changed` pattern: explicitly signal
// whether the OAuth credential fields were edited so the backend doesn't
// overwrite stored values with masked placeholders on resubmit.
oauth_client_id_changed?: boolean;
oauth_client_secret_changed?: boolean;
auth_template?: any;
admin_credentials?: Record<string, string>;
existing_server_id?: number;

View File

@@ -149,3 +149,20 @@ export const LoadingTab: Story = {
</Tabs>
),
};
// ---------------------------------------------------------------------------
// Underline variant
// ---------------------------------------------------------------------------
export const Underline: Story = {
render: () => (
<Tabs defaultValue="cloud">
<Tabs.List variant="underline">
<Tabs.Trigger value="cloud">Cloud-based</Tabs.Trigger>
<Tabs.Trigger value="self">Self-hosted</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="cloud">Cloud-based models</Tabs.Content>
<Tabs.Content value="self">Self-hosted models</Tabs.Content>
</Tabs>
),
};

View File

@@ -8,21 +8,19 @@ import React, {
useCallback,
} from "react";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import { cn, mergeRefs } from "@/lib/utils";
import { Tooltip } from "@opal/components";
import { WithoutStyles } from "@/types";
import { mergeRefs } from "@/lib/utils";
import { cn } from "@opal/utils";
import { Section, SectionProps } from "@/layouts/general-layouts";
import { IconProps } from "@opal/types";
import { IconProps, WithoutStyles } from "@opal/types";
import { SvgChevronLeft, SvgChevronRight } from "@opal/icons";
import Text from "./texts/Text";
import { Button } from "@opal/components";
import { Tooltip, Button, Text } from "@opal/components";
/* =============================================================================
CONTEXT
============================================================================= */
interface TabsContextValue {
variant: "contained" | "pill";
variant: "contained" | "pill" | "underline";
}
const TabsContext = React.createContext<TabsContextValue | undefined>(
@@ -69,21 +67,29 @@ const useTabsContext = () => {
============================================================================= */
/** Style classes for TabsList variants */
const PILL_LIST =
"relative flex w-full items-center pb-[5px] bg-background-tint-00 overflow-hidden";
const listVariants = {
contained: "grid w-full rounded-08 bg-background-tint-03",
pill: "relative flex w-full items-center pb-[5px] bg-background-tint-00 overflow-hidden",
pill: PILL_LIST,
underline: PILL_LIST,
} as const;
/** Base style classes for TabsTrigger variants */
const PILL_TRIGGER =
"p-1 font-secondary-action transition-all duration-200 ease-out";
const triggerBaseStyles = {
contained: "p-2 gap-2",
pill: "p-1 font-secondary-action transition-all duration-200 ease-out",
pill: PILL_TRIGGER,
underline: PILL_TRIGGER,
} as const;
/** Icon style classes for TabsTrigger variants */
const PILL_ICON = "stroke-current";
const iconVariants = {
contained: "stroke-text-03",
pill: "stroke-current",
pill: PILL_ICON,
underline: PILL_ICON,
} as const;
/* =============================================================================
@@ -297,16 +303,20 @@ function useHorizontalScroll(
function PillIndicator({
style,
rightOffset = 0,
hideBaseLine = false,
}: {
style: IndicatorStyle;
rightOffset?: number;
hideBaseLine?: boolean;
}) {
return (
<>
<div
className="absolute bottom-0 left-0 h-px bg-border-02 pointer-events-none"
style={{ right: rightOffset }}
/>
{!hideBaseLine && (
<div
className="absolute bottom-0 left-0 h-px bg-border-02 pointer-events-none"
style={{ right: rightOffset }}
/>
)}
<div
className="absolute bottom-0 h-[2px] bg-background-tint-inverted-03 z-10 pointer-events-none transition-all duration-200 ease-out"
style={{
@@ -360,7 +370,7 @@ interface TabsListProps
* - `pill`: Transparent background with a sliding underline indicator.
* Best for secondary navigation or filter-style tabs with flexible widths.
*/
variant?: "contained" | "pill";
variant?: "contained" | "pill" | "underline";
/**
* Content to render on the right side of the tab list.
@@ -415,7 +425,7 @@ const TabsList = React.forwardRef<
const scrollArrowsRef = useRef<HTMLDivElement>(null);
const rightContentRef = useRef<HTMLDivElement>(null);
const [rightOffset, setRightOffset] = useState(0);
const isPill = variant === "pill";
const isPill = variant === "pill" || variant === "underline";
const { style: indicatorStyle } = usePillIndicator(
listRef,
isPill,
@@ -529,7 +539,11 @@ const TabsList = React.forwardRef<
)}
{isPill && (
<PillIndicator style={indicatorStyle} rightOffset={rightOffset} />
<PillIndicator
style={indicatorStyle}
rightOffset={rightOffset}
hideBaseLine={variant === "underline"}
/>
)}
</TabsContext.Provider>
</TabsPrimitive.List>
@@ -558,7 +572,7 @@ interface TabsTriggerProps
* - `contained` (default): White background with shadow when active
* - `pill`: Dark pill background when active, transparent when inactive
*/
variant?: "contained" | "pill";
variant?: "contained" | "pill" | "underline";
/** Optional tooltip text to display on hover */
tooltip?: string;
@@ -617,7 +631,7 @@ const TabsTrigger = React.forwardRef<
)}
{typeof children === "string" ? (
<div className="px-0.5">
<Text>{children}</Text>
<Text color="inherit">{children}</Text>
</div>
) : (
children
@@ -649,6 +663,7 @@ const TabsTrigger = React.forwardRef<
"data-[state=active]:bg-background-tint-inverted-03",
"data-[state=active]:text-text-inverted-05",
],
variant === "underline" && ["data-[state=active]:text-text-05"],
variant === "contained" && [
"data-[state=inactive]:text-text-03",
"data-[state=inactive]:bg-transparent",
@@ -658,7 +673,8 @@ const TabsTrigger = React.forwardRef<
variant === "pill" && [
"data-[state=inactive]:bg-background-tint-00",
"data-[state=inactive]:text-text-03",
]
],
variant === "underline" && ["data-[state=inactive]:text-text-03"]
)}
{...props}
>
@@ -705,11 +721,14 @@ TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
SectionProps & { value: string }
>(({ children, value, ...props }, ref) => (
>(({ children, value, className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
value={value}
className="pt-4 focus:outline-none focus:border-theme-primary-05 w-full"
className={cn(
"pt-4 focus:outline-none focus:border-theme-primary-05 w-full",
className
)}
>
<Section padding={0} {...props}>
{children}

View File

@@ -105,6 +105,16 @@ export const WithRightChildren: Story = {
},
};
export const WithWrappedDescription: Story = {
args: {
icon: SvgSettings,
children: "Re-index All Connectors",
description:
"Safest option. Continue using the current document index with existing settings until all connectors have completed a successful index attempt.",
wrapDescription: true,
},
};
export const MenuExample: Story = {
render: () => (
<div style={{ display: "flex", flexDirection: "column", gap: 2 }}>

View File

@@ -1,6 +1,7 @@
import React from "react";
import { cn } from "@/lib/utils";
import type { IconProps } from "@opal/types";
import Text from "@/refresh-components/texts/Text";
import Truncated from "@/refresh-components/texts/Truncated";
import Link from "next/link";
import type { Route } from "next";
@@ -84,6 +85,8 @@ export interface LineItemProps
icon?: React.FunctionComponent<IconProps>;
strokeIcon?: boolean;
description?: string;
/** When true, the description text wraps instead of truncating. @default false */
wrapDescription?: boolean;
rightChildren?: React.ReactNode;
href?: string;
rel?: string;
@@ -157,6 +160,7 @@ export default function LineItem({
icon: Icon,
strokeIcon = true,
description,
wrapDescription,
children,
rightChildren,
href,
@@ -271,17 +275,28 @@ export default function LineItem({
</Section>
)}
</Section>
{description && (
{description &&
(wrapDescription ? (
<Text as="p" secondaryBody text03 className="text-left w-full">
{description}
</Text>
) : (
<Truncated secondaryBody text03 className="text-left w-full">
{description}
</Truncated>
))}
</>
) : description ? (
<Section flexDirection="row" gap={0.5}>
{wrapDescription ? (
<Text as="p" secondaryBody text03 className="text-left w-full">
{description}
</Text>
) : (
<Truncated secondaryBody text03 className="text-left w-full">
{description}
</Truncated>
)}
</>
) : description ? (
<Section flexDirection="row" gap={0.5}>
<Truncated secondaryBody text03 className="text-left w-full">
{description}
</Truncated>
{rightChildren && (
<Section alignItems="end" width="fit">
{rightChildren}

View File

@@ -279,7 +279,7 @@ function OpenApiToolCard({ tool }: OpenApiToolCardProps) {
const toolFieldName = `openapi_tool_${tool.id}`;
return (
<Card border="solid" rounding="lg" padding="sm">
<Card border="solid" rounding="lg" padding="md">
<CardLayout.Header
headerChildren={
<ContentAction
@@ -337,14 +337,14 @@ function MCPServerCard({
);
} else if (hasTools) {
cardContent = (
<div className="flex flex-col gap-2 p-2">
<GeneralLayouts.Section gap={0.5} padding={0.5}>
{filteredTools.map((tool) => {
const toolDisabled =
!tool.isAvailable ||
!getFieldMeta<boolean>(`${serverFieldName}.enabled`).value;
return (
<Disabled key={tool.id} disabled={toolDisabled}>
<Card border="solid" rounding="lg" padding="sm">
<Card border="solid" rounding="md" padding="sm">
<CardLayout.Header
headerChildren={
<ContentAction
@@ -367,7 +367,7 @@ function MCPServerCard({
</Disabled>
);
})}
</div>
</GeneralLayouts.Section>
);
}
@@ -416,6 +416,7 @@ function MCPServerCard({
}
/>
}
headerPadding="sm"
bottomChildren={
<GeneralLayouts.Section flexDirection="row" gap={0.5}>
<InputTypeIn

View File

@@ -117,9 +117,9 @@ function MCPServerCard({
padding="sm"
expandedContent={
hasContent ? (
<div className="flex flex-col gap-2 p-2">
<Section gap={0.5} padding={0.5}>
{filteredTools.map((tool) => (
<Card key={tool.id} border="solid" rounding="lg" padding="sm">
<Card key={tool.id} border="solid" rounding="md" padding="sm">
<CardLayout.Header
headerChildren={
<Content
@@ -144,11 +144,12 @@ function MCPServerCard({
/>
</Card>
))}
</div>
</Section>
) : undefined
}
>
<CardLayout.Header
headerPadding="sm"
headerChildren={
<ContentAction
icon={getActionIcon(server.server_url, server.name)}
@@ -891,7 +892,7 @@ export default function ChatPreferencesPage() {
key={tool.id}
border="solid"
rounding="lg"
padding="sm"
padding="md"
>
<CardLayout.Header
headerChildren={

View File

@@ -114,6 +114,7 @@ export default function CodeInterpreterPage() {
<Hoverable.Root group="code-interpreter/Card">
<SelectCard state="filled" padding="sm" rounding="lg">
<Card.Header
headerPadding="sm"
headerChildren={
<Content
sizePreset="main-ui"
@@ -166,6 +167,7 @@ export default function CodeInterpreterPage() {
onClick={() => handleToggle(true)}
>
<Card.Header
headerPadding="sm"
headerChildren={
<Content
sizePreset="main-ui"

View File

@@ -256,6 +256,7 @@ export default function ImageGenerationContent() {
}
>
<Card.Header
headerPadding="sm"
headerChildren={
<Content
sizePreset="main-ui"

View File

@@ -141,6 +141,7 @@ function ExistingProviderCard({
onClick={() => setIsOpen(true)}
>
<CardLayout.Header
headerPadding="sm"
headerChildren={
<Content
icon={icon}
@@ -211,6 +212,7 @@ function NewProviderCard({
onClick={() => setIsOpen(true)}
>
<CardLayout.Header
headerPadding="sm"
headerChildren={
<Content
icon={icon}
@@ -262,6 +264,7 @@ function NewCustomProviderCard({
onClick={() => setIsOpen(true)}
>
<CardLayout.Header
headerPadding="sm"
headerChildren={
<Content
icon={icon}

View File

@@ -277,6 +277,7 @@ function ProviderCard({
}
>
<Card.Header
headerPadding="sm"
headerChildren={
<Content
sizePreset="main-ui"

View File

@@ -199,9 +199,30 @@ export default function MCPAuthenticationModal({
};
}, [fullServer, mcpServer?.server_url]);
// Mirrors the LLM-provider `api_key_changed` pattern in
// `web/src/sections/modals/llmConfig/svc.ts`. The backend uses these flags
// to decide whether to overwrite the stored OAuth credentials or to leave
// them untouched, which prevents masked placeholders sent back from the
// GET response from accidentally wiping out the real stored values.
const computeOAuthChangedFlags = (values: MCPAuthFormValues) => {
if (values.auth_type !== MCPAuthenticationType.OAUTH) {
return {
oauth_client_id_changed: false,
oauth_client_secret_changed: false,
};
}
return {
oauth_client_id_changed:
values.oauth_client_id !== initialValues.oauth_client_id,
oauth_client_secret_changed:
values.oauth_client_secret !== initialValues.oauth_client_secret,
};
};
const constructServerData = (values: MCPAuthFormValues) => {
if (!mcpServer) return null;
const authType = values.auth_type;
const oauthChangedFlags = computeOAuthChangedFlags(values);
return {
name: mcpServer.name,
@@ -233,6 +254,7 @@ export default function MCPAuthenticationModal({
authType === MCPAuthenticationType.OAUTH
? values.oauth_client_secret
: undefined,
...oauthChangedFlags,
existing_server_id: mcpServer.id,
};
};
@@ -263,6 +285,7 @@ export default function MCPAuthenticationModal({
// Step 3: For OAuth, initiate the OAuth flow
if (authType === MCPAuthenticationType.OAUTH) {
const oauthChangedFlags = computeOAuthChangedFlags(values);
const oauthResponse = await fetch("/api/admin/mcp/oauth/connect", {
method: "POST",
headers: {
@@ -272,6 +295,7 @@ export default function MCPAuthenticationModal({
server_id: mcpServer.id.toString(),
oauth_client_id: values.oauth_client_id,
oauth_client_secret: values.oauth_client_secret,
...oauthChangedFlags,
return_path: `/admin/actions/mcp/?server_id=${mcpServer.id}&trigger_fetch=true`,
include_resource_param: true,
}),

View File

@@ -93,6 +93,7 @@ export default function ProviderCard({
onClick={isDisconnected && onConnect ? onConnect : undefined}
>
<Card.Header
headerPadding="sm"
headerChildren={
<Content
sizePreset="main-ui"

View File

@@ -81,6 +81,7 @@ function ViewerMCPServerCard({ server, tools }: ViewerMCPServerCardProps) {
}
>
<CardLayout.Header
headerPadding="sm"
headerChildren={
<ContentAction
icon={serverIcon}
@@ -113,6 +114,7 @@ function ViewerOpenApiToolCard({ tool }: { tool: ToolSnapshot }) {
return (
<Card border="solid" rounding="lg" padding="sm">
<CardLayout.Header
headerPadding="sm"
headerChildren={
<Content
icon={SvgActions}
@@ -381,7 +383,6 @@ export default function AgentViewerModal({ agent }: AgentViewerModalProps) {
<InputHorizontal
title="Overwrite System Prompts"
description='Remove the base system prompt which includes useful instructions (e.g. "You can use Markdown tables"). This may affect response quality.'
withLabel
>
<Switch disabled checked={agent.replace_base_system_prompt} />
</InputHorizontal>

View File

@@ -0,0 +1,185 @@
# Accessibility Tests
Automated accessibility (a11y) testing for the Onyx frontend using
[axe-core](https://github.com/dequelabs/axe-core) via
[@axe-core/playwright](https://github.com/dequelabs/axe-core-npm/tree/develop/packages/playwright).
## How it works
axe-core is the industry-standard accessibility rules engine maintained by Deque Systems.
`@axe-core/playwright` injects it into a live Playwright browser page and evaluates the
rendered DOM against WCAG success criteria. Every violation includes the rule ID, impact
level, affected elements, and a link to remediation guidance.
Tests target **WCAG 2.1 Level AA** by default — the conformance level required by most
accessibility regulations (ADA, Section 508, EN 301 549).
## Strict vs. warning mode (the ratchet)
Not all violations fail CI immediately. The system uses a **ratchet** — rules graduate
from warning to strict as they are fixed:
- **Warning rules** (default): violations are logged as test annotations visible in the
Playwright HTML report, but the test passes. This gives visibility without blocking PRs.
- **Strict rules**: violations fail the test. Once a rule has zero violations across the
entire app, it gets added to `STRICT_RULES` in `utils/accessibility.ts`. CI then
prevents regressions.
The ratchet only tightens — once a rule is strict, it stays strict.
### Promoting a rule to strict
1. Fix all instances of the rule across the app (e.g. add `aria-label` to every icon-only
button for `button-name`).
2. Add the rule ID to `STRICT_RULES` in `tests/e2e/utils/accessibility.ts`:
```ts
export const STRICT_RULES: string[] = [
"button-name",
];
```
3. Run the tests to confirm zero violations for that rule.
4. Merge. CI now blocks any PR that reintroduces that violation.
## Directory structure
```
accessibility/
├── README.md # You are here
├── public_pages.spec.ts # Unauthenticated pages (login, signup)
├── app_pages.spec.ts # Core authenticated pages (chat, search)
├── admin_pages.spec.ts # All admin pages (auto-discovered from sidebar)
└── settings_pages.spec.ts # All settings tabs (auto-discovered from nav)
utils/
└── accessibility.ts # scanAccessibility(), STRICT_RULES, formatViolations()
fixtures/
└── accessibility.ts # Playwright fixture: expectAccessible(), a11yScan()
```
## Philosophy
**Scan the real app, not mocks.** These tests run against a full Onyx deployment — the
same pages users see. axe-core evaluates the actual rendered DOM, catching issues that
static analysis and component-level tests miss (z-index stacking, dynamic content, focus
management, color contrast with real theme variables).
**Auto-discover pages.** Admin and settings tests scrape the sidebar/nav for links rather
than hardcoding routes. When someone adds a new admin page, it gets tested automatically.
**Start permissive, tighten progressively.** The ratchet approach means the initial merge
doesn't block anyone. Each rule fix is a small, focused PR. Once fixed, it never regresses.
**Fix by rule, not by page.** Group fixes by violation type (`button-name`, `link-name`,
`color-contrast`) rather than by page. Each rule type usually involves the same pattern
applied across many components — fixing the component fixes every page that uses it.
## CI integration
These tests run automatically as part of the `admin` Playwright project in
`pr-playwright-tests.yml`. No additional CI configuration is needed — they use the same
global setup, auth fixtures, retry/worker settings, and artifact uploads as all other
E2E tests.
Warning-mode violations appear in the Playwright HTML report as test annotations.
Strict-mode violations fail the test and block the PR.
## Running locally
```bash
# Run all accessibility tests
npx playwright test accessibility/
# Run a specific file
npx playwright test accessibility/public_pages.spec.ts
# Run with the HTML reporter for a browsable results page
npx playwright test accessibility/ --reporter=html
npx playwright show-report
```
## Writing new tests
### Simple: scan a page
```ts
import { test } from "@tests/e2e/fixtures/accessibility";
test("my page is accessible", async ({ page, expectAccessible }) => {
await page.goto("/my-page");
await page.waitForLoadState("networkidle");
await expectAccessible();
});
```
### Scoping: include/exclude regions
```ts
await expectAccessible({
include: ["main"], // Only scan <main>
exclude: ["#third-party-widget"], // Skip a known-bad embed
});
```
### Disabling specific rules
```ts
await expectAccessible({
disableRules: ["label"], // Third-party datepicker, can't fix
});
```
### Using raw results
When you need programmatic access to violations (filtering, counting by impact):
```ts
import { test, expect } from "@tests/e2e/fixtures/accessibility";
test("no critical violations", async ({ page, a11yScan }) => {
await page.goto("/my-page");
await page.waitForLoadState("networkidle");
const results = await a11yScan();
const critical = results.violations.filter((v) => v.impact === "critical");
expect(critical).toHaveLength(0);
});
```
### Adjusting WCAG level
```ts
// Stricter: WCAG 2.1 Level AAA
await expectAccessible({ level: "wcag-aaa" });
// Broadest: all axe rules including best-practice checks
await expectAccessible({ level: "all" });
```
## Current violations to fix
Prioritized by impact. Each row is a single focused PR.
| Priority | Rule | Impact | Scope | Fix |
|----------|------|--------|-------|-----|
| 1 | `button-name` | Critical | 7 icon-only buttons | Add `aria-label` to icon-only `<Button>` components |
| 2 | `aria-roles` | Critical | Chat input textarea | Fix invalid ARIA role on `#onyx-chat-input-textarea` |
| 3 | `link-name` | Serious | 57+ sidebar links | Add accessible text to sidebar agent/page links |
| 4 | `nested-interactive` | Serious | 53 sortable items | Restructure DnD sortable items to avoid nesting interactive elements |
| 5 | `color-contrast` | Serious | Sidebar section labels | Adjust `text-text-02` color token or use `text-text-03`+ |
| 6 | `aria-allowed-attr` | Critical | Radix collapsibles | Investigate `aria-controls` on Radix components (may need upstream fix or wrapper) |
| 7 | `scrollable-region-focusable` | Serious | Admin sidebar | Add `tabindex="0"` to scrollable sidebar container |
| 8 | `meta-viewport` | Moderate | Next.js viewport config | Remove `maximum-scale=1` / `user-scalable=no` from viewport meta |
## Common violations reference
| Rule | What it checks | Typical fix |
|------|---------------|-------------|
| `color-contrast` | Text has 4.5:1 contrast ratio | Use design system color tokens (text-03+ on neutral backgrounds) |
| `label` | Form inputs have accessible labels | Add `<label>`, `aria-label`, or `aria-labelledby` |
| `button-name` | Buttons have discernible text | Add text content or `aria-label` to icon-only buttons |
| `image-alt` | Images have alt text | Add `alt` attribute; decorative images get `alt=""` |
| `link-name` | Links have discernible text | Ensure link text is not empty |
| `region` | Content is within landmark regions | Wrap page sections in `<main>`, `<nav>`, `<aside>` |
Full rule reference: https://dequeuniversity.com/rules/axe/4.10

View File

@@ -0,0 +1,51 @@
/**
* Accessibility scan for every admin page discovered from the sidebar.
*
* Uses the same dynamic discovery approach as the visual regression admin
* tests — scrapes sidebar links so the test automatically picks up new pages.
*/
import { test, expect } from "@tests/e2e/fixtures/accessibility";
import type { Page } from "@playwright/test";
test.use({ storageState: "admin_auth.json" });
async function discoverAdminPages(page: Page): Promise<string[]> {
await page.goto("/admin/configuration/language-models");
await page.waitForLoadState("networkidle");
return page.evaluate(() => {
const sidebar = document.querySelector('[class*="group/SidebarWrapper"]');
if (!sidebar) return [];
const hrefs = new Set<string>();
sidebar
.querySelectorAll<HTMLAnchorElement>('a[href^="/admin/"]')
.forEach((a) => hrefs.add(a.getAttribute("href")!));
return Array.from(hrefs);
});
}
test("Accessibility — all admin pages", async ({ page, expectAccessible }) => {
const adminHrefs = await discoverAdminPages(page);
expect(
adminHrefs.length,
"Expected to discover at least one admin page"
).toBeGreaterThan(0);
for (const href of adminHrefs) {
const slug = href.replace(/^\/admin\//, "").replace(/\//g, "--");
await test.step(`/admin/${slug}`, async () => {
await page.goto(href);
await page.waitForLoadState("networkidle");
await expectAccessible({
exclude: [
// Dynamic date content that may not be accessible by default
'[data-testid="admin-date-range-selector-button"]',
],
});
});
}
});

View File

@@ -0,0 +1,24 @@
/**
* Accessibility tests for authenticated app pages.
*
* Covers the core user-facing routes: chat welcome, search, and settings.
*/
import { test, expect } from "@tests/e2e/fixtures/accessibility";
test.use({ storageState: "admin_auth.json" });
test.describe.configure({ mode: "parallel" });
test.describe("Accessibility — app pages", () => {
test("chat welcome page (/app)", async ({ page, expectAccessible }) => {
await page.goto("/app");
await page.waitForLoadState("networkidle");
await expectAccessible();
});
test("search page", async ({ page, expectAccessible }) => {
await page.goto("/search");
await page.waitForLoadState("networkidle");
await expectAccessible();
});
});

View File

@@ -0,0 +1,28 @@
/**
* Accessibility tests for unauthenticated (public) pages.
*
* These pages are the first thing users encounter and are most likely to be
* evaluated by external auditors or automated compliance scanners.
*/
import { test } from "@tests/e2e/fixtures/accessibility";
test.describe("Accessibility — public pages", () => {
test.describe.configure({ mode: "parallel" });
test.beforeEach(async ({ page }) => {
await page.context().clearCookies();
});
test("login page", async ({ page, expectAccessible }) => {
await page.goto("/auth/login");
await page.waitForLoadState("networkidle");
await expectAccessible();
});
test("signup page", async ({ page, expectAccessible }) => {
await page.goto("/auth/signup");
await page.waitForLoadState("networkidle");
await expectAccessible();
});
});

View File

@@ -0,0 +1,36 @@
/**
* Accessibility scan for every settings tab.
*
* Navigates to /app/settings, discovers tabs from the left nav, then scans
* each one.
*/
import { test, expect } from "@tests/e2e/fixtures/accessibility";
test.use({ storageState: "admin_auth.json" });
test("Accessibility — all settings pages", async ({
page,
expectAccessible,
}) => {
await page.goto("/app/settings/general");
const nav = page.getByTestId("settings-left-tab-navigation");
await nav.waitFor({ state: "visible", timeout: 10_000 });
const tabs = nav.locator("a");
await expect(tabs.first()).toBeVisible({ timeout: 10_000 });
const count = await tabs.count();
for (let i = 0; i < count; i++) {
const tab = tabs.nth(i);
const href = await tab.getAttribute("href");
const slug = href ? href.replace("/app/settings/", "") : `tab-${i}`;
await test.step(`settings/${slug}`, async () => {
await tab.click();
await page.waitForLoadState("networkidle");
await expectAccessible();
});
}
});

View File

@@ -0,0 +1,72 @@
/**
* Playwright fixture that provides accessibility scanning helpers to any test.
*
* Usage:
* ```ts
* import { test, expect } from "@tests/e2e/fixtures/accessibility";
*
* test("page is accessible", async ({ page, expectAccessible }) => {
* await page.goto("/app");
* await page.waitForLoadState("networkidle");
* await expectAccessible();
* });
* ```
*
* Behavior:
* - Violations of rules listed in STRICT_RULES → test fails (regressions blocked)
* - All other violations → logged as test warnings (visible in reports, non-blocking)
*
* As rules are fixed across the app, add their IDs to STRICT_RULES in
* `utils/accessibility.ts` to lock in the fix and prevent regressions.
*/
import { test as base, expect } from "@playwright/test";
import type { TestInfo } from "@playwright/test";
import type { AxeResults } from "axe-core";
import {
scanAccessibility,
partitionViolations,
formatViolations,
formatWarnings,
type A11yScanOptions,
} from "@tests/e2e/utils/accessibility";
interface A11yFixtures {
/** Scan the page; fail on strict rule violations, warn on the rest. */
expectAccessible: (options?: A11yScanOptions) => Promise<void>;
/** Run an axe scan and return raw results for custom handling. */
a11yScan: (options?: A11yScanOptions) => Promise<AxeResults>;
}
export const test = base.extend<A11yFixtures>({
expectAccessible: async ({ page }, use, testInfo: TestInfo) => {
await use(async (options?: A11yScanOptions) => {
const results = await scanAccessibility(page, options);
const { strict, warnings } = partitionViolations(results.violations);
if (warnings.length > 0) {
testInfo.annotations.push({
type: "a11y-warnings",
description: `${
warnings.length
} non-strict a11y violation(s):\n${formatWarnings(warnings)}`,
});
}
expect(
strict,
strict.length > 0
? `Strict a11y rule regression:\n${formatViolations(strict)}`
: ""
).toHaveLength(0);
});
},
a11yScan: async ({ page }, use) => {
await use(async (options?: A11yScanOptions) => {
return scanAccessibility(page, options);
});
},
});
export { expect };

View File

@@ -0,0 +1,155 @@
import AxeBuilder from "@axe-core/playwright";
import type { Page } from "@playwright/test";
import type { AxeResults, Result as AxeViolation } from "axe-core";
/**
* WCAG tag sets used to scope axe-core analysis.
*
* "wcag-aa" targets WCAG 2.1 Level AA — the standard compliance bar for most
* products. "wcag-aaa" adds Level AAA rules for stricter audits. "all" runs
* every rule axe-core ships, including best-practice checks that aren't part
* of any WCAG success criterion.
*/
export type WcagLevel = "wcag-aa" | "wcag-aaa" | "all";
const WCAG_TAGS: Record<WcagLevel, string[]> = {
"wcag-aa": ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"],
"wcag-aaa": [
"wcag2a",
"wcag2aa",
"wcag2aaa",
"wcag21a",
"wcag21aa",
"wcag21aaa",
],
all: [],
};
/**
* Rules that have been fixed and MUST NOT regress. Violations of these rules
* will fail the test. All other rules are reported as warnings only.
*
* Workflow: fix all instances of a rule across the app, then move the rule ID
* here so CI prevents regressions. This is the ratchet — it only tightens.
*/
export const STRICT_RULES: string[] = [
// Add rule IDs here as they are fixed, e.g.:
// "button-name",
// "image-alt",
];
export interface A11yScanOptions {
/** CSS selectors to include in the scan. Defaults to the full page. */
include?: string[];
/** CSS selectors to exclude from the scan. */
exclude?: string[];
/** Rule IDs to disable for this scan. */
disableRules?: string[];
/** WCAG conformance level. Defaults to "wcag-aa". */
level?: WcagLevel;
}
/**
* Run an axe-core accessibility scan against the current page state and return
* the raw results. Use this when you need programmatic access to violations
* (e.g. to generate reports or filter results). For simple pass/fail
* assertions, prefer {@link expectAccessible} via the fixture.
*/
export async function scanAccessibility(
page: Page,
options: A11yScanOptions = {}
): Promise<AxeResults> {
const { include, exclude, disableRules, level = "wcag-aa" } = options;
let builder = new AxeBuilder({ page });
const tags = WCAG_TAGS[level];
if (tags.length > 0) {
builder = builder.withTags(tags);
}
if (include) {
for (const selector of include) {
builder = builder.include(selector);
}
}
if (exclude) {
for (const selector of exclude) {
builder = builder.exclude(selector);
}
}
if (disableRules) {
builder = builder.disableRules(disableRules);
}
return builder.analyze();
}
/**
* Split violations into strict (must fail) and warnings (report only).
*/
export function partitionViolations(violations: AxeViolation[]): {
strict: AxeViolation[];
warnings: AxeViolation[];
} {
const strictSet = new Set(STRICT_RULES);
const strict: AxeViolation[] = [];
const warnings: AxeViolation[] = [];
for (const v of violations) {
if (strictSet.has(v.id)) {
strict.push(v);
} else {
warnings.push(v);
}
}
return { strict, warnings };
}
/**
* Format a single axe violation into a human-readable string.
*/
function formatViolation(v: AxeViolation): string {
const nodes = v.nodes
.slice(0, 5)
.map((n) => ` ${n.target.join(" > ")}`)
.join("\n");
const truncated =
v.nodes.length > 5 ? `\n ... and ${v.nodes.length - 5} more` : "";
return [
` [${v.impact?.toUpperCase()}] ${v.id}: ${v.help} (${
v.nodes.length
} nodes)`,
` ${v.helpUrl}`,
nodes + truncated,
].join("\n");
}
/**
* Format violations for assertion failure messages.
*/
export function formatViolations(violations: AxeViolation[]): string {
if (violations.length === 0) return "No accessibility violations found.";
const header = `${violations.length} accessibility violation(s):\n`;
return header + violations.map(formatViolation).join("\n\n");
}
/**
* Format violations as a compact warning summary for test annotations.
*/
export function formatWarnings(violations: AxeViolation[]): string {
if (violations.length === 0) return "";
return violations
.map(
(v) =>
`[${v.impact?.toUpperCase()}] ${v.id}: ${v.help} (${
v.nodes.length
} nodes)`
)
.join("\n");
}