Spaces:
Sleeping
Sleeping
Deploy gateway e062ef52b65ba61a8a7a891b6b583130528f3164
Browse files- .env.example +7 -0
- .gitattributes +1 -0
- README.md +17 -2
- backend/alembic/versions/a481c6e724df_admin_and_shared_models.py +45 -0
- backend/app/admin.py +203 -0
- backend/app/admin_bootstrap.py +57 -0
- backend/app/auth.py +19 -4
- backend/app/dashboard.py +28 -4
- backend/app/main.py +15 -4
- backend/app/models.py +31 -1
- backend/app/routing.py +103 -8
- backend/app/site_config.py +56 -0
- backend/tests/test_admin.py +276 -0
- backend/tests/test_admin_bootstrap.py +53 -0
- deploy/start.py +16 -0
- docs/administration.md +75 -0
- docs/openapi.json +512 -1
- frontend/app/admin-panel.tsx +620 -0
- frontend/app/forms.tsx +7 -8
- frontend/app/globals.css +467 -0
- frontend/app/markdown-output.tsx +57 -0
- frontend/app/model-picker.tsx +191 -0
- frontend/app/playground.tsx +178 -46
- frontend/app/request-table.tsx +2 -2
- frontend/app/ui.tsx +7 -3
- frontend/app/workspace.tsx +245 -47
- frontend/lib/api.ts +46 -2
- frontend/lib/demo.ts +6 -4
- frontend/lib/errors.ts +18 -0
- frontend/lib/site.ts +20 -0
- frontend/package-lock.json +1520 -60
- frontend/package.json +4 -1
- frontend/tests/catalog.test.tsx +147 -0
- frontend/tests/playground.test.tsx +1 -0
- frontend/tests/setup.ts +1 -0
- frontend/tests/workspace.test.tsx +3 -1
- scripts/admin.py +67 -0
- universal-ai-gateway-v1.zip +3 -0
.env.example
CHANGED
|
@@ -23,3 +23,10 @@ LOG_RETENTION_DAYS=30
|
|
| 23 |
PRIVATE_UPSTREAM_HOSTS=[]
|
| 24 |
STATIC_DIR=frontend/out
|
| 25 |
PORT=7860
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
PRIVATE_UPSTREAM_HOSTS=[]
|
| 24 |
STATIC_DIR=frontend/out
|
| 25 |
PORT=7860
|
| 26 |
+
|
| 27 |
+
# Server-only administrator provisioning by deploy/start.py.
|
| 28 |
+
# With durable storage, remove bootstrap secrets after activation.
|
| 29 |
+
# A fresh database requires an explicit Argon2id password-hash seed.
|
| 30 |
+
# ADMIN_BOOTSTRAP_EMAIL=owner@example.com
|
| 31 |
+
|
| 32 |
+
# ADMIN_BOOTSTRAP_PASSWORD_HASH=
|
.gitattributes
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
universal-ai-gateway-v1.zip filter=lfs diff=lfs merge=lfs -text
|
README.md
CHANGED
|
@@ -9,6 +9,17 @@ pinned: false
|
|
| 9 |
license: mit
|
| 10 |
---
|
| 11 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
# Nexus — Universal AI Gateway
|
| 13 |
|
| 14 |
A self-hosted, bring-your-own-key AI gateway with a Next.js dashboard and an async FastAPI backend. Connect provider accounts, generate a `gw_` key, and give your applications one OpenAI-compatible base URL.
|
|
@@ -266,11 +277,15 @@ scripts/ Secret setup and credential rotation
|
|
| 266 |
deploy/ Rootless supervisor, hosting templates, Caddy
|
| 267 |
```
|
| 268 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 269 |
## Scope and operational limits
|
| 270 |
|
| 271 |
-
This v1 implements the requested core and selected providers. Favorites, pins, model search and usage export are included. Automated paid benchmarks, public leaderboards, webhooks,
|
| 272 |
|
| 273 |
-
Prices/capabilities depend on provider metadata and manual configuration. Catalog-listed does not guarantee your account has quota or access. Free-tier usage is still subject to each provider’s rules, credits and rate limits.
|
| 274 |
|
| 275 |
This is a production-oriented initial implementation, not an assertion of independent security audit, live-provider certification, load-tested capacity or high availability. The included single-container database topology is a single node. Use external database services and backups for stronger availability requirements.
|
| 276 |
|
|
|
|
| 9 |
license: mit
|
| 10 |
---
|
| 11 |
|
| 12 |
+
---
|
| 13 |
+
title: Nexus Universal AI Gateway
|
| 14 |
+
emoji: 🔗
|
| 15 |
+
colorFrom: purple
|
| 16 |
+
colorTo: indigo
|
| 17 |
+
sdk: docker
|
| 18 |
+
app_port: 7860
|
| 19 |
+
pinned: false
|
| 20 |
+
license: mit
|
| 21 |
+
---
|
| 22 |
+
|
| 23 |
# Nexus — Universal AI Gateway
|
| 24 |
|
| 25 |
A self-hosted, bring-your-own-key AI gateway with a Next.js dashboard and an async FastAPI backend. Connect provider accounts, generate a `gw_` key, and give your applications one OpenAI-compatible base URL.
|
|
|
|
| 277 |
deploy/ Rootless supervisor, hosting templates, Caddy
|
| 278 |
```
|
| 279 |
|
| 280 |
+
## Restricted administration and community models
|
| 281 |
+
|
| 282 |
+
The `/#admin` panel manages branding, Playground defaults, registration and request limits, user suspension, and explicitly published chat models. Roles require server access; public signup cannot create an administrator. See [Administration guide](docs/administration.md) for activation, deployment, publishing your own model, and revocation.
|
| 283 |
+
|
| 284 |
## Scope and operational limits
|
| 285 |
|
| 286 |
+
This v1 implements the requested core and selected providers. Favorites, pins, model search and usage export are included. Automated paid benchmarks, public leaderboards, webhooks, saved prompt templates and prompt replay are **not included**. Prompt replay would require an explicit opt-in content-retention design; this version never logs prompts or completions.
|
| 287 |
|
| 288 |
+
Prices/capabilities depend on provider metadata and manual configuration. Catalog-listed does not guarantee your account has quota or access. Free-tier usage is still subject to each provider’s rules, credits and rate limits. Administrators can explicitly publish selected chat models for community use. Credentials remain encrypted on the server; the administrator covers upstream charges. No provider quota bypass is provided. Usage costs are partial estimates, not billing records.
|
| 289 |
|
| 290 |
This is a production-oriented initial implementation, not an assertion of independent security audit, live-provider certification, load-tested capacity or high availability. The included single-container database topology is a single node. Use external database services and backups for stronger availability requirements.
|
| 291 |
|
backend/alembic/versions/a481c6e724df_admin_and_shared_models.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Restricted administration and explicitly published community models."""
|
| 2 |
+
|
| 3 |
+
import sqlalchemy as sa
|
| 4 |
+
from alembic import op
|
| 5 |
+
|
| 6 |
+
revision = "a481c6e724df"
|
| 7 |
+
down_revision = "36bb49c6546c"
|
| 8 |
+
branch_labels = None
|
| 9 |
+
depends_on = None
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def upgrade():
|
| 13 |
+
op.add_column(
|
| 14 |
+
"users", sa.Column("is_admin", sa.Boolean(), server_default=sa.false(), nullable=False)
|
| 15 |
+
)
|
| 16 |
+
op.add_column(
|
| 17 |
+
"users", sa.Column("disabled", sa.Boolean(), server_default=sa.false(), nullable=False)
|
| 18 |
+
)
|
| 19 |
+
op.add_column("request_logs", sa.Column("sponsored_cost", sa.Float(), nullable=True))
|
| 20 |
+
op.create_table(
|
| 21 |
+
"site_configuration",
|
| 22 |
+
sa.Column("id", sa.String(16), primary_key=True),
|
| 23 |
+
sa.Column("settings", sa.JSON(), nullable=False),
|
| 24 |
+
)
|
| 25 |
+
op.create_table(
|
| 26 |
+
"published_models",
|
| 27 |
+
sa.Column(
|
| 28 |
+
"model_id",
|
| 29 |
+
sa.String(36),
|
| 30 |
+
sa.ForeignKey("registry_models.id", ondelete="CASCADE"),
|
| 31 |
+
primary_key=True,
|
| 32 |
+
),
|
| 33 |
+
sa.Column("enabled", sa.Boolean(), nullable=False),
|
| 34 |
+
sa.Column("requests_per_minute", sa.Integer(), nullable=False),
|
| 35 |
+
sa.Column("max_output_tokens", sa.Integer(), nullable=False),
|
| 36 |
+
sa.Column("created_at", sa.Float(), nullable=False),
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def downgrade():
|
| 41 |
+
op.drop_table("published_models")
|
| 42 |
+
op.drop_table("site_configuration")
|
| 43 |
+
op.drop_column("request_logs", "sponsored_cost")
|
| 44 |
+
op.drop_column("users", "disabled")
|
| 45 |
+
op.drop_column("users", "is_admin")
|
backend/app/admin.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Restricted management. Roles are granted using the server CLI, never public signup."""
|
| 2 |
+
|
| 3 |
+
from typing import Annotated
|
| 4 |
+
|
| 5 |
+
from fastapi import APIRouter, Depends, Query, Request
|
| 6 |
+
from pydantic import BaseModel, ConfigDict, Field
|
| 7 |
+
from sqlalchemy import delete, func, select
|
| 8 |
+
|
| 9 |
+
from .auth import DB, CurrentUser, user_view
|
| 10 |
+
from .errors import fail
|
| 11 |
+
from .models import (
|
| 12 |
+
AuditLog,
|
| 13 |
+
Provider,
|
| 14 |
+
PublishedModel,
|
| 15 |
+
RegistryModel,
|
| 16 |
+
RequestLog,
|
| 17 |
+
Session,
|
| 18 |
+
SiteConfiguration,
|
| 19 |
+
User,
|
| 20 |
+
)
|
| 21 |
+
from .site_config import SiteOptions, site_options
|
| 22 |
+
|
| 23 |
+
router = APIRouter(prefix="/api", tags=["Administration"])
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
async def administrator(user: CurrentUser) -> User:
|
| 27 |
+
if not user.is_admin:
|
| 28 |
+
raise fail(403, "Administrator access is required.", "admin_required")
|
| 29 |
+
return user
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
AdminUser = Annotated[User, Depends(administrator)]
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@router.get("/site")
|
| 36 |
+
async def public_settings(request: Request, db: DB) -> dict:
|
| 37 |
+
options = await site_options(db, request.app.state.settings)
|
| 38 |
+
return options.model_dump(
|
| 39 |
+
include={
|
| 40 |
+
"site_name",
|
| 41 |
+
"tagline",
|
| 42 |
+
"welcome_text",
|
| 43 |
+
"accent",
|
| 44 |
+
"default_mode",
|
| 45 |
+
"default_stream",
|
| 46 |
+
"default_retries",
|
| 47 |
+
"shared_models_enabled",
|
| 48 |
+
}
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
@router.get("/admin/settings")
|
| 53 |
+
async def get_settings(request: Request, db: DB, user: AdminUser) -> dict:
|
| 54 |
+
options = await site_options(db, request.app.state.settings)
|
| 55 |
+
return {
|
| 56 |
+
"settings": options.model_dump(),
|
| 57 |
+
"limits": {
|
| 58 |
+
"requests_per_minute": request.app.state.settings.requests_per_minute,
|
| 59 |
+
"max_retries": request.app.state.settings.max_retries,
|
| 60 |
+
"registration_allowed": request.app.state.settings.allow_registration,
|
| 61 |
+
},
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
@router.put("/admin/settings")
|
| 66 |
+
async def save_settings(body: SiteOptions, request: Request, db: DB, user: AdminUser) -> dict:
|
| 67 |
+
config = request.app.state.settings
|
| 68 |
+
if (
|
| 69 |
+
body.requests_per_minute > config.requests_per_minute
|
| 70 |
+
or body.default_retries > config.max_retries
|
| 71 |
+
):
|
| 72 |
+
raise fail(422, "Keep rate and retry limits within the server's configured maximums.")
|
| 73 |
+
if body.registration_open and not config.allow_registration:
|
| 74 |
+
raise fail(422, "Registration is disabled in the server configuration.")
|
| 75 |
+
row = await db.get(SiteConfiguration, "global")
|
| 76 |
+
if not row:
|
| 77 |
+
row = SiteConfiguration(id="global")
|
| 78 |
+
db.add(row)
|
| 79 |
+
row.settings = body.model_dump()
|
| 80 |
+
db.add(AuditLog(user_id=user.id, action="admin.settings.updated", target_id="global"))
|
| 81 |
+
await db.commit()
|
| 82 |
+
return body.model_dump()
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
@router.get("/admin/summary")
|
| 86 |
+
async def summary(db: DB, user: AdminUser) -> dict:
|
| 87 |
+
return {
|
| 88 |
+
"users": await db.scalar(select(func.count(User.id))),
|
| 89 |
+
"suspended_users": await db.scalar(
|
| 90 |
+
select(func.count(User.id)).where(User.disabled.is_(True))
|
| 91 |
+
),
|
| 92 |
+
"requests": await db.scalar(select(func.count(RequestLog.id))),
|
| 93 |
+
"published_models": await db.scalar(
|
| 94 |
+
select(func.count(PublishedModel.model_id)).where(PublishedModel.enabled.is_(True))
|
| 95 |
+
),
|
| 96 |
+
"sponsored_cost": await db.scalar(select(func.sum(RequestLog.sponsored_cost))),
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
@router.get("/admin/users")
|
| 101 |
+
async def users(db: DB, user: AdminUser, search: str = "", offset: int = Query(0, ge=0)) -> dict:
|
| 102 |
+
query = select(User)
|
| 103 |
+
if search:
|
| 104 |
+
query = query.where(User.email.icontains(search[:120], autoescape=True))
|
| 105 |
+
total = await db.scalar(select(func.count()).select_from(query.subquery()))
|
| 106 |
+
rows = (
|
| 107 |
+
await db.scalars(query.order_by(User.created_at, User.id).limit(50).offset(offset))
|
| 108 |
+
).all()
|
| 109 |
+
return {
|
| 110 |
+
"data": [user_view(u) | {"disabled": u.disabled, "created_at": u.created_at} for u in rows],
|
| 111 |
+
"total": total,
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
class AccountStatus(BaseModel):
|
| 116 |
+
model_config = ConfigDict(extra="forbid")
|
| 117 |
+
disabled: bool
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
@router.patch("/admin/users/{uid}")
|
| 121 |
+
async def account_status(uid: str, body: AccountStatus, db: DB, user: AdminUser) -> dict:
|
| 122 |
+
target = await db.get(User, uid)
|
| 123 |
+
if not target:
|
| 124 |
+
raise fail(404, "Account not found.")
|
| 125 |
+
if target.id == user.id or target.is_admin:
|
| 126 |
+
raise fail(
|
| 127 |
+
403, "Administrator accounts can only be changed using the server admin command."
|
| 128 |
+
)
|
| 129 |
+
target.disabled = body.disabled
|
| 130 |
+
if body.disabled:
|
| 131 |
+
await db.execute(delete(Session).where(Session.user_id == target.id))
|
| 132 |
+
db.add(
|
| 133 |
+
AuditLog(
|
| 134 |
+
user_id=user.id,
|
| 135 |
+
action="admin.user.suspended" if body.disabled else "admin.user.restored",
|
| 136 |
+
target_id=uid,
|
| 137 |
+
)
|
| 138 |
+
)
|
| 139 |
+
await db.commit()
|
| 140 |
+
return {"id": uid, "disabled": target.disabled}
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
class PublishOptions(BaseModel):
|
| 144 |
+
model_config = ConfigDict(extra="forbid")
|
| 145 |
+
enabled: bool = True
|
| 146 |
+
requests_per_minute: int = Field(default=5, ge=1, le=120)
|
| 147 |
+
max_output_tokens: int = Field(default=1024, ge=128, le=8192)
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
@router.get("/admin/shared-models")
|
| 151 |
+
async def shared_models(db: DB, user: AdminUser) -> list[dict]:
|
| 152 |
+
rows = (
|
| 153 |
+
await db.execute(
|
| 154 |
+
select(PublishedModel, RegistryModel, Provider)
|
| 155 |
+
.join(RegistryModel, RegistryModel.id == PublishedModel.model_id)
|
| 156 |
+
.join(Provider, Provider.id == RegistryModel.provider_id)
|
| 157 |
+
.order_by(RegistryModel.name)
|
| 158 |
+
)
|
| 159 |
+
).all()
|
| 160 |
+
return [
|
| 161 |
+
{
|
| 162 |
+
"id": m.id,
|
| 163 |
+
"name": m.name,
|
| 164 |
+
"model_id": m.model_id,
|
| 165 |
+
"provider_name": p.name,
|
| 166 |
+
"enabled": shared.enabled,
|
| 167 |
+
"requests_per_minute": shared.requests_per_minute,
|
| 168 |
+
"max_output_tokens": shared.max_output_tokens,
|
| 169 |
+
"owned": p.user_id == user.id,
|
| 170 |
+
}
|
| 171 |
+
for shared, m, p in rows
|
| 172 |
+
]
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
@router.put("/admin/shared-models/{mid}")
|
| 176 |
+
async def publish(mid: str, body: PublishOptions, db: DB, user: AdminUser) -> dict:
|
| 177 |
+
model = await db.scalar(
|
| 178 |
+
select(RegistryModel)
|
| 179 |
+
.join(Provider)
|
| 180 |
+
.where(
|
| 181 |
+
RegistryModel.id == mid,
|
| 182 |
+
Provider.user_id == user.id,
|
| 183 |
+
)
|
| 184 |
+
)
|
| 185 |
+
if not model:
|
| 186 |
+
raise fail(404, "Select a model from a provider you own.")
|
| 187 |
+
if model.capabilities.get("chat") is not True:
|
| 188 |
+
raise fail(422, "Shared models must have confirmed chat capability.")
|
| 189 |
+
row = await db.get(PublishedModel, mid)
|
| 190 |
+
if not row:
|
| 191 |
+
row = PublishedModel(model_id=mid)
|
| 192 |
+
db.add(row)
|
| 193 |
+
for key, value in body.model_dump().items():
|
| 194 |
+
setattr(row, key, value)
|
| 195 |
+
db.add(
|
| 196 |
+
AuditLog(
|
| 197 |
+
user_id=user.id,
|
| 198 |
+
action="admin.model.published" if body.enabled else "admin.model.unpublished",
|
| 199 |
+
target_id=mid,
|
| 200 |
+
)
|
| 201 |
+
)
|
| 202 |
+
await db.commit()
|
| 203 |
+
return {"id": mid, **body.model_dump()}
|
backend/app/admin_bootstrap.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Server-only role provisioning, including a password-hash seed for a fresh database."""
|
| 2 |
+
|
| 3 |
+
from typing import Literal
|
| 4 |
+
|
| 5 |
+
from argon2 import Type, extract_parameters
|
| 6 |
+
from pydantic import EmailStr, TypeAdapter
|
| 7 |
+
from sqlalchemy import select
|
| 8 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 9 |
+
|
| 10 |
+
from .models import AuditLog, User
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
async def provision_admin(
|
| 14 |
+
db: AsyncSession,
|
| 15 |
+
email: str,
|
| 16 |
+
action: Literal["grant", "revoke", "bootstrap"],
|
| 17 |
+
password_hash: str | None = None,
|
| 18 |
+
) -> User:
|
| 19 |
+
email = str(TypeAdapter(EmailStr).validate_python(email.strip())).lower()
|
| 20 |
+
user = await db.scalar(select(User).where(User.email == email))
|
| 21 |
+
created = False
|
| 22 |
+
if not user:
|
| 23 |
+
if action != "bootstrap" or not password_hash:
|
| 24 |
+
raise ValueError("Account not found. No account was created.")
|
| 25 |
+
try:
|
| 26 |
+
params = extract_parameters(password_hash)
|
| 27 |
+
valid = (
|
| 28 |
+
params.type == Type.ID
|
| 29 |
+
and params.version == 19
|
| 30 |
+
and params.time_cost >= 2
|
| 31 |
+
and params.memory_cost >= 19456
|
| 32 |
+
and params.salt_len >= 16
|
| 33 |
+
and params.hash_len >= 16
|
| 34 |
+
)
|
| 35 |
+
except (ValueError, TypeError):
|
| 36 |
+
valid = False
|
| 37 |
+
if not valid:
|
| 38 |
+
raise ValueError(
|
| 39 |
+
"ADMIN_BOOTSTRAP_PASSWORD_HASH must be a valid Argon2id password hash."
|
| 40 |
+
)
|
| 41 |
+
user = User(email=email, name="Gateway administrator", password_hash=password_hash)
|
| 42 |
+
db.add(user)
|
| 43 |
+
await db.flush()
|
| 44 |
+
created = True
|
| 45 |
+
if user.disabled and action != "revoke":
|
| 46 |
+
raise ValueError("Restore this account before granting administrator access.")
|
| 47 |
+
# Existing accounts keep their password, identity and data, even when a seed is set.
|
| 48 |
+
user.is_admin = action != "revoke"
|
| 49 |
+
db.add(
|
| 50 |
+
AuditLog(
|
| 51 |
+
user_id=user.id,
|
| 52 |
+
action="admin.account.bootstrapped" if created else f"admin.role.{action}",
|
| 53 |
+
target_id=user.id,
|
| 54 |
+
)
|
| 55 |
+
)
|
| 56 |
+
await db.commit()
|
| 57 |
+
return user
|
backend/app/auth.py
CHANGED
|
@@ -13,6 +13,7 @@ from .errors import fail
|
|
| 13 |
from .models import AuditLog, GatewayKey, Session, User
|
| 14 |
from .schemas import Credentials, NewGatewayKey, Register
|
| 15 |
from .security import DUMMY_HASH, hash_password, new_key, token_hash, verify_password
|
|
|
|
| 16 |
|
| 17 |
router = APIRouter(prefix="/api/auth", tags=["Accounts"])
|
| 18 |
DB = Annotated[AsyncSession, Depends(get_db)]
|
|
@@ -30,6 +31,10 @@ async def current_user(request: Request, db: DB) -> User:
|
|
| 30 |
user = await db.get(User, session.user_id)
|
| 31 |
if not user:
|
| 32 |
raise fail(401, "Please sign in.", "authentication_required")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
return user
|
| 34 |
|
| 35 |
|
|
@@ -56,8 +61,13 @@ async def gateway_auth(request: Request, db: DB) -> Principal:
|
|
| 56 |
)
|
| 57 |
if not key or (key.expires_at and key.expires_at < time.time()):
|
| 58 |
raise fail(401, "Invalid or expired Gateway API key.", "invalid_api_key")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
await request.app.state.shared.rate_limit(
|
| 60 |
-
"user:" + key.user_id,
|
|
|
|
| 61 |
)
|
| 62 |
key.last_used_at = time.time()
|
| 63 |
await db.commit()
|
|
@@ -98,8 +108,8 @@ async def establish_session(
|
|
| 98 |
)
|
| 99 |
|
| 100 |
|
| 101 |
-
def user_view(user: User) -> dict[str, str]:
|
| 102 |
-
return {"id": user.id, "name": user.name, "email": user.email}
|
| 103 |
|
| 104 |
|
| 105 |
async def issue_key(db: AsyncSession, user_id: str, body: NewGatewayKey) -> tuple[GatewayKey, str]:
|
|
@@ -120,7 +130,8 @@ async def issue_key(db: AsyncSession, user_id: str, body: NewGatewayKey) -> tupl
|
|
| 120 |
@router.post("/register", status_code=201)
|
| 121 |
async def register(body: Register, request: Request, response: Response, db: DB) -> dict:
|
| 122 |
settings = request.app.state.settings
|
| 123 |
-
|
|
|
|
| 124 |
raise fail(403, "Registration is currently closed.", "registration_closed")
|
| 125 |
if settings.registration_code and not secrets.compare_digest(
|
| 126 |
body.registration_code, settings.registration_code
|
|
@@ -151,6 +162,10 @@ async def login(body: Credentials, request: Request, response: Response, db: DB)
|
|
| 151 |
valid = await verify_password(user.password_hash if user else DUMMY_HASH, body.password)
|
| 152 |
if not user or not valid:
|
| 153 |
raise fail(401, "Incorrect email or password.", "invalid_credentials")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
db.add(AuditLog(user_id=user.id, action="account.login"))
|
| 155 |
await establish_session(request, response, db, user)
|
| 156 |
return {"user": user_view(user)}
|
|
|
|
| 13 |
from .models import AuditLog, GatewayKey, Session, User
|
| 14 |
from .schemas import Credentials, NewGatewayKey, Register
|
| 15 |
from .security import DUMMY_HASH, hash_password, new_key, token_hash, verify_password
|
| 16 |
+
from .site_config import site_options
|
| 17 |
|
| 18 |
router = APIRouter(prefix="/api/auth", tags=["Accounts"])
|
| 19 |
DB = Annotated[AsyncSession, Depends(get_db)]
|
|
|
|
| 31 |
user = await db.get(User, session.user_id)
|
| 32 |
if not user:
|
| 33 |
raise fail(401, "Please sign in.", "authentication_required")
|
| 34 |
+
if user.disabled:
|
| 35 |
+
raise fail(
|
| 36 |
+
403, "This account is suspended. Contact the administrator.", "account_suspended"
|
| 37 |
+
)
|
| 38 |
return user
|
| 39 |
|
| 40 |
|
|
|
|
| 61 |
)
|
| 62 |
if not key or (key.expires_at and key.expires_at < time.time()):
|
| 63 |
raise fail(401, "Invalid or expired Gateway API key.", "invalid_api_key")
|
| 64 |
+
user = await db.get(User, key.user_id)
|
| 65 |
+
if not user or user.disabled:
|
| 66 |
+
raise fail(403, "This account is suspended.", "account_suspended")
|
| 67 |
+
options = await site_options(db, request.app.state.settings)
|
| 68 |
await request.app.state.shared.rate_limit(
|
| 69 |
+
"user:" + key.user_id,
|
| 70 |
+
min(options.requests_per_minute, request.app.state.settings.requests_per_minute),
|
| 71 |
)
|
| 72 |
key.last_used_at = time.time()
|
| 73 |
await db.commit()
|
|
|
|
| 108 |
)
|
| 109 |
|
| 110 |
|
| 111 |
+
def user_view(user: User) -> dict[str, str | bool]:
|
| 112 |
+
return {"id": user.id, "name": user.name, "email": user.email, "is_admin": user.is_admin}
|
| 113 |
|
| 114 |
|
| 115 |
async def issue_key(db: AsyncSession, user_id: str, body: NewGatewayKey) -> tuple[GatewayKey, str]:
|
|
|
|
| 130 |
@router.post("/register", status_code=201)
|
| 131 |
async def register(body: Register, request: Request, response: Response, db: DB) -> dict:
|
| 132 |
settings = request.app.state.settings
|
| 133 |
+
options = await site_options(db, settings)
|
| 134 |
+
if not settings.allow_registration or not options.registration_open:
|
| 135 |
raise fail(403, "Registration is currently closed.", "registration_closed")
|
| 136 |
if settings.registration_code and not secrets.compare_digest(
|
| 137 |
body.registration_code, settings.registration_code
|
|
|
|
| 162 |
valid = await verify_password(user.password_hash if user else DUMMY_HASH, body.password)
|
| 163 |
if not user or not valid:
|
| 164 |
raise fail(401, "Incorrect email or password.", "invalid_credentials")
|
| 165 |
+
if user.disabled:
|
| 166 |
+
raise fail(
|
| 167 |
+
403, "This account is suspended. Contact the administrator.", "account_suspended"
|
| 168 |
+
)
|
| 169 |
db.add(AuditLog(user_id=user.id, action="account.login"))
|
| 170 |
await establish_session(request, response, db, user)
|
| 171 |
return {"user": user_view(user)}
|
backend/app/dashboard.py
CHANGED
|
@@ -5,7 +5,7 @@ from typing import Any
|
|
| 5 |
|
| 6 |
from fastapi import APIRouter, Query, Request
|
| 7 |
from fastapi.responses import StreamingResponse
|
| 8 |
-
from sqlalchemy import Integer, case, cast, func, select
|
| 9 |
from sqlalchemy.exc import IntegrityError
|
| 10 |
|
| 11 |
from .auth import DB, CurrentUser, issue_key
|
|
@@ -15,6 +15,7 @@ from .models import AuditLog, GatewayKey, Provider, RegistryModel, RequestLog
|
|
| 15 |
from .providers import ADAPTERS
|
| 16 |
from .schemas import ModelInput, NewGatewayKey, ProviderInput, ProviderPatch
|
| 17 |
from .security import validate_base_url, validate_headers
|
|
|
|
| 18 |
|
| 19 |
router = APIRouter(prefix="/api", tags=["Dashboard"])
|
| 20 |
|
|
@@ -166,13 +167,32 @@ async def models(
|
|
| 166 |
search: str = "",
|
| 167 |
capability: str = "",
|
| 168 |
provider: str = "",
|
|
|
|
|
|
|
| 169 |
limit: int = Query(200, ge=1, le=1000),
|
| 170 |
offset: int = Query(0, ge=0),
|
| 171 |
) -> dict:
|
| 172 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
if search:
|
|
|
|
| 174 |
query = query.where(
|
| 175 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
)
|
| 177 |
if provider:
|
| 178 |
query = query.where(Provider.id == provider)
|
|
@@ -197,7 +217,7 @@ async def models(
|
|
| 197 |
total = await db.scalar(select(func.count()).select_from(query.subquery()))
|
| 198 |
rows = (
|
| 199 |
await db.execute(
|
| 200 |
-
query.order_by(RegistryModel.favorite.desc(), RegistryModel.model_id)
|
| 201 |
.limit(limit)
|
| 202 |
.offset(offset)
|
| 203 |
)
|
|
@@ -215,7 +235,11 @@ async def models(
|
|
| 215 |
enabled=p.enabled,
|
| 216 |
route_id=p.id + "::" + m.model_id,
|
| 217 |
health=await request.app.state.shared.health(p.id + ":" + m.id),
|
|
|
|
|
|
|
| 218 |
)
|
|
|
|
|
|
|
| 219 |
result.append(item)
|
| 220 |
return {"data": result, "total": total, "offset": offset, "limit": limit}
|
| 221 |
|
|
|
|
| 5 |
|
| 6 |
from fastapi import APIRouter, Query, Request
|
| 7 |
from fastapi.responses import StreamingResponse
|
| 8 |
+
from sqlalchemy import Integer, case, cast, func, or_, select
|
| 9 |
from sqlalchemy.exc import IntegrityError
|
| 10 |
|
| 11 |
from .auth import DB, CurrentUser, issue_key
|
|
|
|
| 15 |
from .providers import ADAPTERS
|
| 16 |
from .schemas import ModelInput, NewGatewayKey, ProviderInput, ProviderPatch
|
| 17 |
from .security import validate_base_url, validate_headers
|
| 18 |
+
from .site_config import published_ids, site_options
|
| 19 |
|
| 20 |
router = APIRouter(prefix="/api", tags=["Dashboard"])
|
| 21 |
|
|
|
|
| 167 |
search: str = "",
|
| 168 |
capability: str = "",
|
| 169 |
provider: str = "",
|
| 170 |
+
free_only: bool = False,
|
| 171 |
+
owned_only: bool = False,
|
| 172 |
limit: int = Query(200, ge=1, le=1000),
|
| 173 |
offset: int = Query(0, ge=0),
|
| 174 |
) -> dict:
|
| 175 |
+
options = await site_options(db, request.app.state.settings)
|
| 176 |
+
shared = set(await db.scalars(published_ids())) if options.shared_models_enabled else set()
|
| 177 |
+
visible = Provider.user_id == user.id
|
| 178 |
+
if not owned_only:
|
| 179 |
+
visible = or_(visible, RegistryModel.id.in_(shared))
|
| 180 |
+
query = select(RegistryModel, Provider).join(Provider).where(visible)
|
| 181 |
if search:
|
| 182 |
+
term = search[:120]
|
| 183 |
query = query.where(
|
| 184 |
+
or_(
|
| 185 |
+
RegistryModel.model_id.icontains(term, autoescape=True),
|
| 186 |
+
RegistryModel.name.icontains(term, autoescape=True),
|
| 187 |
+
Provider.name.icontains(term, autoescape=True),
|
| 188 |
+
)
|
| 189 |
+
)
|
| 190 |
+
if free_only:
|
| 191 |
+
query = query.where(
|
| 192 |
+
or_(
|
| 193 |
+
(RegistryModel.input_price == 0) & (RegistryModel.output_price == 0),
|
| 194 |
+
RegistryModel.id.in_(shared) & (Provider.user_id != user.id),
|
| 195 |
+
)
|
| 196 |
)
|
| 197 |
if provider:
|
| 198 |
query = query.where(Provider.id == provider)
|
|
|
|
| 217 |
total = await db.scalar(select(func.count()).select_from(query.subquery()))
|
| 218 |
rows = (
|
| 219 |
await db.execute(
|
| 220 |
+
query.order_by(RegistryModel.favorite.desc(), RegistryModel.model_id, RegistryModel.id)
|
| 221 |
.limit(limit)
|
| 222 |
.offset(offset)
|
| 223 |
)
|
|
|
|
| 235 |
enabled=p.enabled,
|
| 236 |
route_id=p.id + "::" + m.model_id,
|
| 237 |
health=await request.app.state.shared.health(p.id + ":" + m.id),
|
| 238 |
+
shared=m.id in shared and p.user_id != user.id,
|
| 239 |
+
owned=p.user_id == user.id,
|
| 240 |
)
|
| 241 |
+
if p.user_id != user.id:
|
| 242 |
+
item.update(input_price=0, output_price=0, provider_name="Community", favorite=False)
|
| 243 |
result.append(item)
|
| 244 |
return {"data": result, "total": total, "offset": offset, "limit": limit}
|
| 245 |
|
backend/app/main.py
CHANGED
|
@@ -12,12 +12,12 @@ from fastapi.exceptions import RequestValidationError
|
|
| 12 |
from fastapi.openapi.utils import get_openapi
|
| 13 |
from fastapi.responses import FileResponse, JSONResponse, Response
|
| 14 |
from redis.exceptions import RedisError
|
| 15 |
-
from sqlalchemy import select, text
|
| 16 |
from starlette.datastructures import UploadFile
|
| 17 |
from starlette.exceptions import HTTPException
|
| 18 |
from starlette.types import ASGIApp, Receive, Scope, Send
|
| 19 |
|
| 20 |
-
from . import anthropic, auth, dashboard
|
| 21 |
from .auth import CurrentUser, GatewayPrincipal, Principal
|
| 22 |
from .config import Settings
|
| 23 |
from .db import Base, create_database
|
|
@@ -34,6 +34,7 @@ from .schemas import (
|
|
| 34 |
SpeechRequest,
|
| 35 |
)
|
| 36 |
from .security import Vault, secure_transport, token_hash
|
|
|
|
| 37 |
from .state import SharedState
|
| 38 |
|
| 39 |
logger = logging.getLogger("gateway")
|
|
@@ -217,6 +218,7 @@ def create_app(
|
|
| 217 |
|
| 218 |
app.include_router(auth.router)
|
| 219 |
app.include_router(dashboard.router)
|
|
|
|
| 220 |
app.include_router(anthropic.router)
|
| 221 |
|
| 222 |
@app.get("/health/live", tags=["Health"])
|
|
@@ -237,12 +239,16 @@ def create_app(
|
|
| 237 |
@app.get("/v1/models", tags=["OpenAI compatible"])
|
| 238 |
async def list_models(request: Request, principal: GatewayPrincipal) -> dict:
|
| 239 |
async with request.app.state.db() as db:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 240 |
rows = (
|
| 241 |
await db.execute(
|
| 242 |
select(Provider, RegistryModel)
|
| 243 |
.join(RegistryModel)
|
| 244 |
.where(
|
| 245 |
-
Provider.user_id == principal.user_id,
|
| 246 |
Provider.enabled.is_(True),
|
| 247 |
RegistryModel.available.is_(True),
|
| 248 |
)
|
|
@@ -260,6 +266,7 @@ def create_app(
|
|
| 260 |
"provider": p.kind,
|
| 261 |
"capabilities": m.capabilities,
|
| 262 |
"context_window": m.context_window,
|
|
|
|
| 263 |
}
|
| 264 |
for p, m in rows
|
| 265 |
],
|
|
@@ -313,8 +320,11 @@ def create_app(
|
|
| 313 |
|
| 314 |
@app.post("/api/playground", tags=["Dashboard"])
|
| 315 |
async def playground(body: ChatRequest, request: Request, user: CurrentUser) -> Response:
|
|
|
|
|
|
|
| 316 |
await request.app.state.shared.rate_limit(
|
| 317 |
-
"user:" + user.id,
|
|
|
|
| 318 |
)
|
| 319 |
return await Execution(request, Principal(user.id), body, "chat/completions").run()
|
| 320 |
|
|
@@ -352,6 +362,7 @@ def create_app(
|
|
| 352 |
elif path.startswith("/api/") and path not in (
|
| 353 |
"/api/auth/login",
|
| 354 |
"/api/auth/register",
|
|
|
|
| 355 |
):
|
| 356 |
operation["security"] = [{"DashboardSession": []}]
|
| 357 |
if method != "get":
|
|
|
|
| 12 |
from fastapi.openapi.utils import get_openapi
|
| 13 |
from fastapi.responses import FileResponse, JSONResponse, Response
|
| 14 |
from redis.exceptions import RedisError
|
| 15 |
+
from sqlalchemy import or_, select, text
|
| 16 |
from starlette.datastructures import UploadFile
|
| 17 |
from starlette.exceptions import HTTPException
|
| 18 |
from starlette.types import ASGIApp, Receive, Scope, Send
|
| 19 |
|
| 20 |
+
from . import admin, anthropic, auth, dashboard
|
| 21 |
from .auth import CurrentUser, GatewayPrincipal, Principal
|
| 22 |
from .config import Settings
|
| 23 |
from .db import Base, create_database
|
|
|
|
| 34 |
SpeechRequest,
|
| 35 |
)
|
| 36 |
from .security import Vault, secure_transport, token_hash
|
| 37 |
+
from .site_config import published_ids, site_options
|
| 38 |
from .state import SharedState
|
| 39 |
|
| 40 |
logger = logging.getLogger("gateway")
|
|
|
|
| 218 |
|
| 219 |
app.include_router(auth.router)
|
| 220 |
app.include_router(dashboard.router)
|
| 221 |
+
app.include_router(admin.router)
|
| 222 |
app.include_router(anthropic.router)
|
| 223 |
|
| 224 |
@app.get("/health/live", tags=["Health"])
|
|
|
|
| 239 |
@app.get("/v1/models", tags=["OpenAI compatible"])
|
| 240 |
async def list_models(request: Request, principal: GatewayPrincipal) -> dict:
|
| 241 |
async with request.app.state.db() as db:
|
| 242 |
+
options = await site_options(db, request.app.state.settings)
|
| 243 |
+
shared = (
|
| 244 |
+
set(await db.scalars(published_ids())) if options.shared_models_enabled else set()
|
| 245 |
+
)
|
| 246 |
rows = (
|
| 247 |
await db.execute(
|
| 248 |
select(Provider, RegistryModel)
|
| 249 |
.join(RegistryModel)
|
| 250 |
.where(
|
| 251 |
+
or_(Provider.user_id == principal.user_id, RegistryModel.id.in_(shared)),
|
| 252 |
Provider.enabled.is_(True),
|
| 253 |
RegistryModel.available.is_(True),
|
| 254 |
)
|
|
|
|
| 266 |
"provider": p.kind,
|
| 267 |
"capabilities": m.capabilities,
|
| 268 |
"context_window": m.context_window,
|
| 269 |
+
"shared": m.id in shared,
|
| 270 |
}
|
| 271 |
for p, m in rows
|
| 272 |
],
|
|
|
|
| 320 |
|
| 321 |
@app.post("/api/playground", tags=["Dashboard"])
|
| 322 |
async def playground(body: ChatRequest, request: Request, user: CurrentUser) -> Response:
|
| 323 |
+
async with request.app.state.db() as db:
|
| 324 |
+
options = await site_options(db, request.app.state.settings)
|
| 325 |
await request.app.state.shared.rate_limit(
|
| 326 |
+
"user:" + user.id,
|
| 327 |
+
min(options.requests_per_minute, request.app.state.settings.requests_per_minute),
|
| 328 |
)
|
| 329 |
return await Execution(request, Principal(user.id), body, "chat/completions").run()
|
| 330 |
|
|
|
|
| 362 |
elif path.startswith("/api/") and path not in (
|
| 363 |
"/api/auth/login",
|
| 364 |
"/api/auth/register",
|
| 365 |
+
"/api/site",
|
| 366 |
):
|
| 367 |
operation["security"] = [{"DashboardSession": []}]
|
| 368 |
if method != "get":
|
backend/app/models.py
CHANGED
|
@@ -2,7 +2,17 @@ import time
|
|
| 2 |
import uuid
|
| 3 |
from typing import Any
|
| 4 |
|
| 5 |
-
from sqlalchemy import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
from sqlalchemy.orm import Mapped, mapped_column
|
| 7 |
|
| 8 |
from .db import Base
|
|
@@ -19,6 +29,25 @@ class User(Base):
|
|
| 19 |
password_hash: Mapped[str] = mapped_column(Text)
|
| 20 |
name: Mapped[str] = mapped_column(String(80))
|
| 21 |
created_at: Mapped[float] = mapped_column(Float, default=time.time)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
|
| 24 |
class Session(Base):
|
|
@@ -94,6 +123,7 @@ class RequestLog(Base):
|
|
| 94 |
input_tokens: Mapped[int | None] = mapped_column(Integer)
|
| 95 |
output_tokens: Mapped[int | None] = mapped_column(Integer)
|
| 96 |
estimated_cost: Mapped[float | None] = mapped_column(Float)
|
|
|
|
| 97 |
attempts: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list)
|
| 98 |
error_code: Mapped[str | None] = mapped_column(String(80))
|
| 99 |
created_at: Mapped[float] = mapped_column(Float, default=time.time, index=True)
|
|
|
|
| 2 |
import uuid
|
| 3 |
from typing import Any
|
| 4 |
|
| 5 |
+
from sqlalchemy import (
|
| 6 |
+
JSON,
|
| 7 |
+
Boolean,
|
| 8 |
+
Float,
|
| 9 |
+
ForeignKey,
|
| 10 |
+
Integer,
|
| 11 |
+
String,
|
| 12 |
+
Text,
|
| 13 |
+
UniqueConstraint,
|
| 14 |
+
false,
|
| 15 |
+
)
|
| 16 |
from sqlalchemy.orm import Mapped, mapped_column
|
| 17 |
|
| 18 |
from .db import Base
|
|
|
|
| 29 |
password_hash: Mapped[str] = mapped_column(Text)
|
| 30 |
name: Mapped[str] = mapped_column(String(80))
|
| 31 |
created_at: Mapped[float] = mapped_column(Float, default=time.time)
|
| 32 |
+
is_admin: Mapped[bool] = mapped_column(Boolean, default=False, server_default=false())
|
| 33 |
+
disabled: Mapped[bool] = mapped_column(Boolean, default=False, server_default=false())
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class SiteConfiguration(Base):
|
| 37 |
+
__tablename__ = "site_configuration"
|
| 38 |
+
id: Mapped[str] = mapped_column(String(16), primary_key=True, default="global")
|
| 39 |
+
settings: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class PublishedModel(Base):
|
| 43 |
+
__tablename__ = "published_models"
|
| 44 |
+
model_id: Mapped[str] = mapped_column(
|
| 45 |
+
ForeignKey("registry_models.id", ondelete="CASCADE"), primary_key=True
|
| 46 |
+
)
|
| 47 |
+
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
| 48 |
+
requests_per_minute: Mapped[int] = mapped_column(Integer, default=5)
|
| 49 |
+
max_output_tokens: Mapped[int] = mapped_column(Integer, default=1024)
|
| 50 |
+
created_at: Mapped[float] = mapped_column(Float, default=time.time)
|
| 51 |
|
| 52 |
|
| 53 |
class Session(Base):
|
|
|
|
| 123 |
input_tokens: Mapped[int | None] = mapped_column(Integer)
|
| 124 |
output_tokens: Mapped[int | None] = mapped_column(Integer)
|
| 125 |
estimated_cost: Mapped[float | None] = mapped_column(Float)
|
| 126 |
+
sponsored_cost: Mapped[float | None] = mapped_column(Float)
|
| 127 |
attempts: Mapped[list[dict[str, Any]]] = mapped_column(JSON, default=list)
|
| 128 |
error_code: Mapped[str | None] = mapped_column(String(80))
|
| 129 |
created_at: Mapped[float] = mapped_column(Float, default=time.time, index=True)
|
backend/app/routing.py
CHANGED
|
@@ -8,15 +8,16 @@ from typing import Any
|
|
| 8 |
from urllib.parse import quote
|
| 9 |
|
| 10 |
import httpx
|
| 11 |
-
from fastapi import Request
|
| 12 |
from fastapi.responses import JSONResponse, Response, StreamingResponse
|
| 13 |
-
from sqlalchemy import select
|
| 14 |
|
| 15 |
from .auth import Principal
|
| 16 |
from .discovery import adapter_for
|
| 17 |
from .errors import UpstreamError, fail
|
| 18 |
-
from .models import Provider, RegistryModel, RequestLog
|
| 19 |
from .schemas import GatewayRequest
|
|
|
|
| 20 |
|
| 21 |
RETRYABLE = {401, 403, 404, 429, 500, 502, 503, 504}
|
| 22 |
ENDPOINT_CAPABILITY = {
|
|
@@ -120,12 +121,14 @@ async def candidates(
|
|
| 120 |
) from None
|
| 121 |
required = requirements(endpoint, body.model_dump(), mode)
|
| 122 |
async with state.db() as db:
|
|
|
|
|
|
|
| 123 |
rows = (
|
| 124 |
await db.execute(
|
| 125 |
select(Provider, RegistryModel)
|
| 126 |
.join(RegistryModel, Provider.id == RegistryModel.provider_id)
|
| 127 |
.where(
|
| 128 |
-
Provider.user_id == principal.user_id,
|
| 129 |
Provider.enabled.is_(True),
|
| 130 |
RegistryModel.available.is_(True),
|
| 131 |
)
|
|
@@ -133,6 +136,8 @@ async def candidates(
|
|
| 133 |
).all()
|
| 134 |
matches: list[tuple[int, Candidate]] = []
|
| 135 |
for provider, model in rows:
|
|
|
|
|
|
|
| 136 |
if pin and pin not in (provider.kind, provider.id):
|
| 137 |
continue
|
| 138 |
if endpoint not in adapter_for(state, provider).endpoints:
|
|
@@ -163,6 +168,8 @@ async def candidates(
|
|
| 163 |
if c.model.input_price is not None and c.model.output_price is not None
|
| 164 |
else float("inf")
|
| 165 |
)
|
|
|
|
|
|
|
| 166 |
base = (exact, not c.provider.pinned)
|
| 167 |
if mode == "fastest":
|
| 168 |
return base + (c.latency, c.provider.priority, c.model.model_id)
|
|
@@ -269,7 +276,13 @@ class Execution:
|
|
| 269 |
user_id=self.principal.user_id,
|
| 270 |
key_id=self.principal.key_id,
|
| 271 |
provider_id=c.provider.id if c else None,
|
| 272 |
-
provider_name=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 273 |
requested_model=self.body.model,
|
| 274 |
resolved_model=c.model.model_id if c else None,
|
| 275 |
endpoint=self.endpoint,
|
|
@@ -277,7 +290,12 @@ class Execution:
|
|
| 277 |
latency_ms=(time.monotonic() - self.started) * 1000,
|
| 278 |
input_tokens=self.input_tokens,
|
| 279 |
output_tokens=self.output_tokens,
|
| 280 |
-
estimated_cost=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 281 |
attempts=self.attempts,
|
| 282 |
error_code=error,
|
| 283 |
)
|
|
@@ -337,13 +355,80 @@ class Execution:
|
|
| 337 |
if (await self.state.shared.health(c.provider.id)).get("status") == "cooldown":
|
| 338 |
continue
|
| 339 |
self.current = c
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 340 |
adapter = adapter_for(self.state, c.provider)
|
| 341 |
payload["model"] = c.model.model_id
|
| 342 |
started = time.monotonic()
|
| 343 |
response: httpx.Response | None = None
|
| 344 |
attempt: dict[str, Any] = {
|
| 345 |
"provider": c.provider.kind,
|
| 346 |
-
"provider_name":
|
|
|
|
|
|
|
| 347 |
"model": c.model.model_id,
|
| 348 |
"status": 0,
|
| 349 |
"latency_ms": 0.0,
|
|
@@ -448,9 +533,19 @@ class Execution:
|
|
| 448 |
else (last_error.status if last_error.status in (400, 404, 422, 504) else 502)
|
| 449 |
)
|
| 450 |
await self.record(status, last_error.code)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 451 |
raise fail(
|
| 452 |
status,
|
| 453 |
-
|
|
|
|
| 454 |
last_error.code,
|
| 455 |
)
|
| 456 |
|
|
|
|
| 8 |
from urllib.parse import quote
|
| 9 |
|
| 10 |
import httpx
|
| 11 |
+
from fastapi import HTTPException, Request
|
| 12 |
from fastapi.responses import JSONResponse, Response, StreamingResponse
|
| 13 |
+
from sqlalchemy import or_, select
|
| 14 |
|
| 15 |
from .auth import Principal
|
| 16 |
from .discovery import adapter_for
|
| 17 |
from .errors import UpstreamError, fail
|
| 18 |
+
from .models import Provider, PublishedModel, RegistryModel, RequestLog
|
| 19 |
from .schemas import GatewayRequest
|
| 20 |
+
from .site_config import published_ids, site_options
|
| 21 |
|
| 22 |
RETRYABLE = {401, 403, 404, 429, 500, 502, 503, 504}
|
| 23 |
ENDPOINT_CAPABILITY = {
|
|
|
|
| 121 |
) from None
|
| 122 |
required = requirements(endpoint, body.model_dump(), mode)
|
| 123 |
async with state.db() as db:
|
| 124 |
+
options = await site_options(db, state.settings)
|
| 125 |
+
shared = set(await db.scalars(published_ids())) if options.shared_models_enabled else set()
|
| 126 |
rows = (
|
| 127 |
await db.execute(
|
| 128 |
select(Provider, RegistryModel)
|
| 129 |
.join(RegistryModel, Provider.id == RegistryModel.provider_id)
|
| 130 |
.where(
|
| 131 |
+
or_(Provider.user_id == principal.user_id, RegistryModel.id.in_(shared)),
|
| 132 |
Provider.enabled.is_(True),
|
| 133 |
RegistryModel.available.is_(True),
|
| 134 |
)
|
|
|
|
| 136 |
).all()
|
| 137 |
matches: list[tuple[int, Candidate]] = []
|
| 138 |
for provider, model in rows:
|
| 139 |
+
if provider.user_id != principal.user_id and endpoint != "chat/completions":
|
| 140 |
+
continue
|
| 141 |
if pin and pin not in (provider.kind, provider.id):
|
| 142 |
continue
|
| 143 |
if endpoint not in adapter_for(state, provider).endpoints:
|
|
|
|
| 168 |
if c.model.input_price is not None and c.model.output_price is not None
|
| 169 |
else float("inf")
|
| 170 |
)
|
| 171 |
+
if c.provider.user_id != principal.user_id:
|
| 172 |
+
price = 0
|
| 173 |
base = (exact, not c.provider.pinned)
|
| 174 |
if mode == "fastest":
|
| 175 |
return base + (c.latency, c.provider.priority, c.model.model_id)
|
|
|
|
| 276 |
user_id=self.principal.user_id,
|
| 277 |
key_id=self.principal.key_id,
|
| 278 |
provider_id=c.provider.id if c else None,
|
| 279 |
+
provider_name=(
|
| 280 |
+
"Community"
|
| 281 |
+
if c.provider.user_id != self.principal.user_id
|
| 282 |
+
else c.provider.name
|
| 283 |
+
)
|
| 284 |
+
if c
|
| 285 |
+
else None,
|
| 286 |
requested_model=self.body.model,
|
| 287 |
resolved_model=c.model.model_id if c else None,
|
| 288 |
endpoint=self.endpoint,
|
|
|
|
| 290 |
latency_ms=(time.monotonic() - self.started) * 1000,
|
| 291 |
input_tokens=self.input_tokens,
|
| 292 |
output_tokens=self.output_tokens,
|
| 293 |
+
estimated_cost=0
|
| 294 |
+
if c and c.provider.user_id != self.principal.user_id
|
| 295 |
+
else estimated,
|
| 296 |
+
sponsored_cost=estimated
|
| 297 |
+
if c and c.provider.user_id != self.principal.user_id
|
| 298 |
+
else None,
|
| 299 |
attempts=self.attempts,
|
| 300 |
error_code=error,
|
| 301 |
)
|
|
|
|
| 355 |
if (await self.state.shared.health(c.provider.id)).get("status") == "cooldown":
|
| 356 |
continue
|
| 357 |
self.current = c
|
| 358 |
+
if c.provider.user_id != self.principal.user_id:
|
| 359 |
+
# Re-check publication immediately before a shared upstream attempt.
|
| 360 |
+
async with self.state.db() as db:
|
| 361 |
+
options = await site_options(db, self.state.settings)
|
| 362 |
+
publication = await db.get(PublishedModel, c.model.id)
|
| 363 |
+
active = await db.scalar(
|
| 364 |
+
published_ids().where(PublishedModel.model_id == c.model.id)
|
| 365 |
+
)
|
| 366 |
+
if not options.shared_models_enabled or not publication or not active:
|
| 367 |
+
continue
|
| 368 |
+
if len(json.dumps(payload)) > 64000:
|
| 369 |
+
await self.record(413, "shared_input_limit")
|
| 370 |
+
raise fail(
|
| 371 |
+
413,
|
| 372 |
+
"Community model requests are limited to 64,000 characters.",
|
| 373 |
+
"shared_input_limit",
|
| 374 |
+
)
|
| 375 |
+
try:
|
| 376 |
+
await self.state.shared.rate_limit(
|
| 377 |
+
"community:" + self.principal.user_id + ":" + c.model.id,
|
| 378 |
+
publication.requests_per_minute,
|
| 379 |
+
)
|
| 380 |
+
await self.state.shared.rate_limit(
|
| 381 |
+
"community:global", options.shared_requests_per_minute
|
| 382 |
+
)
|
| 383 |
+
except HTTPException:
|
| 384 |
+
await self.record(429, "shared_rate_limit")
|
| 385 |
+
raise
|
| 386 |
+
# Provider-specific extensions may override generation limits or
|
| 387 |
+
# request billable audio/storage. Shared routes accept standard chat fields.
|
| 388 |
+
shared_fields = {
|
| 389 |
+
"messages",
|
| 390 |
+
"stream",
|
| 391 |
+
"stream_options",
|
| 392 |
+
"temperature",
|
| 393 |
+
"top_p",
|
| 394 |
+
"frequency_penalty",
|
| 395 |
+
"presence_penalty",
|
| 396 |
+
"seed",
|
| 397 |
+
"stop",
|
| 398 |
+
"tools",
|
| 399 |
+
"tool_choice",
|
| 400 |
+
"parallel_tool_calls",
|
| 401 |
+
"response_format",
|
| 402 |
+
"functions",
|
| 403 |
+
"function_call",
|
| 404 |
+
"max_tokens",
|
| 405 |
+
"max_completion_tokens",
|
| 406 |
+
"n",
|
| 407 |
+
"reasoning_effort",
|
| 408 |
+
"logprobs",
|
| 409 |
+
"top_logprobs",
|
| 410 |
+
"logit_bias",
|
| 411 |
+
}
|
| 412 |
+
payload = {key: value for key, value in payload.items() if key in shared_fields}
|
| 413 |
+
payload["n"] = 1
|
| 414 |
+
token_field = (
|
| 415 |
+
"max_completion_tokens" if "max_completion_tokens" in payload else "max_tokens"
|
| 416 |
+
)
|
| 417 |
+
payload[token_field] = min(
|
| 418 |
+
payload.get(token_field) or publication.max_output_tokens,
|
| 419 |
+
publication.max_output_tokens,
|
| 420 |
+
)
|
| 421 |
+
if token_field == "max_completion_tokens":
|
| 422 |
+
payload.pop("max_tokens", None)
|
| 423 |
adapter = adapter_for(self.state, c.provider)
|
| 424 |
payload["model"] = c.model.model_id
|
| 425 |
started = time.monotonic()
|
| 426 |
response: httpx.Response | None = None
|
| 427 |
attempt: dict[str, Any] = {
|
| 428 |
"provider": c.provider.kind,
|
| 429 |
+
"provider_name": "Community"
|
| 430 |
+
if c.provider.user_id != self.principal.user_id
|
| 431 |
+
else c.provider.name,
|
| 432 |
"model": c.model.model_id,
|
| 433 |
"status": 0,
|
| 434 |
"latency_ms": 0.0,
|
|
|
|
| 533 |
else (last_error.status if last_error.status in (400, 404, 422, 504) else 502)
|
| 534 |
)
|
| 535 |
await self.record(status, last_error.code)
|
| 536 |
+
guidance = {
|
| 537 |
+
401: "The provider rejected its credential (HTTP 401). Update the provider connection.",
|
| 538 |
+
403: "The provider denied this model (HTTP 403). Check model access and permissions.",
|
| 539 |
+
402: "The provider requires credit or payment (HTTP 402). Check billing and model access.",
|
| 540 |
+
429: "The provider rate limit was reached (HTTP 429). Wait or select another model.",
|
| 541 |
+
504: "The provider timed out. Retry later or select a faster model.",
|
| 542 |
+
}.get(last_error.status)
|
| 543 |
+
if guidance and self.current and self.current.provider.user_id != self.principal.user_id:
|
| 544 |
+
guidance += " For a community model, contact the gateway administrator."
|
| 545 |
raise fail(
|
| 546 |
status,
|
| 547 |
+
guidance
|
| 548 |
+
or "Upstream request failed. Open request details to inspect the provider attempts.",
|
| 549 |
last_error.code,
|
| 550 |
)
|
| 551 |
|
backend/app/site_config.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Any, Literal
|
| 2 |
+
|
| 3 |
+
from pydantic import BaseModel, ConfigDict, Field
|
| 4 |
+
from sqlalchemy import select
|
| 5 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 6 |
+
|
| 7 |
+
from .config import Settings
|
| 8 |
+
from .models import Provider, PublishedModel, RegistryModel, SiteConfiguration, User
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class SiteOptions(BaseModel):
|
| 12 |
+
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
|
| 13 |
+
site_name: str = Field(default="Nexus", min_length=1, max_length=40)
|
| 14 |
+
tagline: str = Field(default="Universal AI Gateway", min_length=1, max_length=160)
|
| 15 |
+
welcome_text: str = Field(
|
| 16 |
+
default="Your models. Your keys. One connection.", min_length=1, max_length=180
|
| 17 |
+
)
|
| 18 |
+
accent: Literal["purple", "blue", "green"] = "purple"
|
| 19 |
+
registration_open: bool = True
|
| 20 |
+
requests_per_minute: int = Field(default=60, ge=1, le=10000)
|
| 21 |
+
shared_models_enabled: bool = True
|
| 22 |
+
shared_requests_per_minute: int = Field(default=30, ge=1, le=10000)
|
| 23 |
+
default_mode: Literal["auto", "fastest", "cheapest", "reasoning", "coding", "vision"] = "auto"
|
| 24 |
+
default_stream: bool = True
|
| 25 |
+
default_retries: int = Field(default=2, ge=0, le=5)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
async def site_options(db: AsyncSession, config: Settings) -> SiteOptions:
|
| 29 |
+
row = await db.get(SiteConfiguration, "global")
|
| 30 |
+
defaults: dict[str, Any] = {
|
| 31 |
+
"registration_open": config.allow_registration,
|
| 32 |
+
"requests_per_minute": min(config.requests_per_minute, 10000),
|
| 33 |
+
"default_retries": config.max_retries,
|
| 34 |
+
}
|
| 35 |
+
options = SiteOptions.model_validate(defaults | (row.settings if row else {}))
|
| 36 |
+
options.registration_open = options.registration_open and config.allow_registration
|
| 37 |
+
options.requests_per_minute = min(options.requests_per_minute, config.requests_per_minute)
|
| 38 |
+
options.default_retries = min(options.default_retries, config.max_retries)
|
| 39 |
+
return options
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def published_ids() -> Any:
|
| 43 |
+
"""Only explicitly published models belonging to active administrators are shared."""
|
| 44 |
+
return (
|
| 45 |
+
select(PublishedModel.model_id)
|
| 46 |
+
.join(RegistryModel, RegistryModel.id == PublishedModel.model_id)
|
| 47 |
+
.join(Provider, Provider.id == RegistryModel.provider_id)
|
| 48 |
+
.join(User, User.id == Provider.user_id)
|
| 49 |
+
.where(
|
| 50 |
+
PublishedModel.enabled.is_(True),
|
| 51 |
+
Provider.enabled.is_(True),
|
| 52 |
+
RegistryModel.available.is_(True),
|
| 53 |
+
User.is_admin.is_(True),
|
| 54 |
+
User.disabled.is_(False),
|
| 55 |
+
)
|
| 56 |
+
)
|
backend/tests/test_admin.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
from contextlib import aclosing
|
| 3 |
+
|
| 4 |
+
import httpx
|
| 5 |
+
import pytest
|
| 6 |
+
from conftest import connect, register
|
| 7 |
+
from sqlalchemy import select
|
| 8 |
+
|
| 9 |
+
from app.models import RegistryModel, RequestLog, User
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
async def promote(app, uid):
|
| 13 |
+
async with app.state.db() as db:
|
| 14 |
+
user = await db.get(User, uid)
|
| 15 |
+
user.is_admin = True
|
| 16 |
+
await db.commit()
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
async def community_setup(environment, quota=5):
|
| 20 |
+
app, client, upstream = environment
|
| 21 |
+
_, admin_id = await register(client)
|
| 22 |
+
await promote(app, admin_id)
|
| 23 |
+
pid = await connect(
|
| 24 |
+
client,
|
| 25 |
+
models=[
|
| 26 |
+
{
|
| 27 |
+
"model_id": "private-only",
|
| 28 |
+
"capabilities": {"chat": True},
|
| 29 |
+
}
|
| 30 |
+
],
|
| 31 |
+
)
|
| 32 |
+
catalog = (await client.get("/api/models")).json()["data"]
|
| 33 |
+
model = next(m for m in catalog if m["model_id"] == "model-a")
|
| 34 |
+
async with app.state.db() as db:
|
| 35 |
+
row = await db.get(RegistryModel, model["id"])
|
| 36 |
+
row.input_price = 1
|
| 37 |
+
row.output_price = 2
|
| 38 |
+
await db.commit()
|
| 39 |
+
response = await client.put(
|
| 40 |
+
f"/api/admin/shared-models/{model['id']}",
|
| 41 |
+
json={
|
| 42 |
+
"requests_per_minute": quota,
|
| 43 |
+
"max_output_tokens": 256,
|
| 44 |
+
},
|
| 45 |
+
)
|
| 46 |
+
assert response.status_code == 200, response.text
|
| 47 |
+
member = httpx.AsyncClient(
|
| 48 |
+
transport=httpx.ASGITransport(app=app),
|
| 49 |
+
base_url="http://test",
|
| 50 |
+
headers={"Origin": "http://test"},
|
| 51 |
+
)
|
| 52 |
+
key, member_id = await register(member, "member@example.com")
|
| 53 |
+
return client, member, key, member_id, model, pid
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
async def test_admin_requires_role_session_and_csrf(environment):
|
| 57 |
+
app, client, _ = environment
|
| 58 |
+
assert (await client.get("/api/admin/settings")).status_code == 401
|
| 59 |
+
response = await client.post(
|
| 60 |
+
"/api/auth/register",
|
| 61 |
+
json={
|
| 62 |
+
"email": "member@example.com",
|
| 63 |
+
"name": "Member",
|
| 64 |
+
"password": "correct-horse-battery-123",
|
| 65 |
+
"is_admin": True,
|
| 66 |
+
},
|
| 67 |
+
)
|
| 68 |
+
uid = response.json()["user"]["id"]
|
| 69 |
+
assert response.json()["user"]["is_admin"] is False
|
| 70 |
+
client.headers["X-CSRF-Token"] = client.cookies.get("gw_csrf", "")
|
| 71 |
+
for path in ("settings", "summary", "users", "shared-models"):
|
| 72 |
+
assert (await client.get(f"/api/admin/{path}")).status_code == 403
|
| 73 |
+
assert (await client.put("/api/admin/settings", json={})).status_code == 403
|
| 74 |
+
assert (
|
| 75 |
+
await client.patch(f"/api/admin/users/{uid}", json={"disabled": True})
|
| 76 |
+
).status_code == 403
|
| 77 |
+
assert (await client.put("/api/admin/shared-models/fake", json={})).status_code == 403
|
| 78 |
+
await promote(app, uid)
|
| 79 |
+
config = (await client.get("/api/admin/settings")).json()["settings"]
|
| 80 |
+
assert (
|
| 81 |
+
await client.put("/api/admin/settings", json=config, headers={"X-CSRF-Token": "wrong"})
|
| 82 |
+
).status_code == 403
|
| 83 |
+
assert (
|
| 84 |
+
await client.put(
|
| 85 |
+
"/api/admin/settings", json=config, headers={"Origin": "https://attacker.example"}
|
| 86 |
+
)
|
| 87 |
+
).status_code == 403
|
| 88 |
+
assert (
|
| 89 |
+
await client.patch(f"/api/admin/users/{uid}", json={"disabled": True})
|
| 90 |
+
).status_code == 403
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
async def test_settings_are_persistent_and_public_subset_only(environment):
|
| 94 |
+
app, client, _ = environment
|
| 95 |
+
_, uid = await register(client)
|
| 96 |
+
await promote(app, uid)
|
| 97 |
+
settings = (await client.get("/api/admin/settings")).json()["settings"]
|
| 98 |
+
settings.update(
|
| 99 |
+
site_name="My Gateway", accent="blue", registration_open=False, default_mode="cheapest"
|
| 100 |
+
)
|
| 101 |
+
assert (await client.put("/api/admin/settings", json=settings)).status_code == 200
|
| 102 |
+
public = (await client.get("/api/site")).json()
|
| 103 |
+
assert public["site_name"] == "My Gateway"
|
| 104 |
+
assert public["accent"] == "blue"
|
| 105 |
+
assert set(public) == {
|
| 106 |
+
"site_name",
|
| 107 |
+
"tagline",
|
| 108 |
+
"welcome_text",
|
| 109 |
+
"accent",
|
| 110 |
+
"default_mode",
|
| 111 |
+
"default_stream",
|
| 112 |
+
"default_retries",
|
| 113 |
+
"shared_models_enabled",
|
| 114 |
+
}
|
| 115 |
+
assert (
|
| 116 |
+
await client.post(
|
| 117 |
+
"/api/auth/register",
|
| 118 |
+
json={
|
| 119 |
+
"email": "new@example.com",
|
| 120 |
+
"name": "New",
|
| 121 |
+
"password": "correct-horse-battery-123",
|
| 122 |
+
},
|
| 123 |
+
)
|
| 124 |
+
).status_code == 403
|
| 125 |
+
assert (
|
| 126 |
+
await client.put("/api/admin/settings", json=settings | {"requests_per_minute": 1001})
|
| 127 |
+
).status_code == 422
|
| 128 |
+
assert (
|
| 129 |
+
await client.put("/api/admin/settings", json=settings | {"extra": "unsafe"})
|
| 130 |
+
).status_code == 422
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
async def test_published_model_is_free_isolated_and_token_limited(environment):
|
| 134 |
+
app, _, upstream = environment
|
| 135 |
+
admin, member, key, _, model, pid = await community_setup(environment)
|
| 136 |
+
async with aclosing(member):
|
| 137 |
+
assert (await member.get("/api/providers")).json() == []
|
| 138 |
+
catalog = (await member.get("/api/models?free_only=true")).json()
|
| 139 |
+
assert catalog["total"] == 1
|
| 140 |
+
public = catalog["data"][0]
|
| 141 |
+
assert public["owned"] is False and public["shared"] is True
|
| 142 |
+
assert public["input_price"] == public["output_price"] == 0
|
| 143 |
+
assert public["provider_name"] == "Community"
|
| 144 |
+
assert "upstream-provider-key" not in json.dumps(public)
|
| 145 |
+
assert (await member.get("/api/models?owned_only=true")).json()["total"] == 0
|
| 146 |
+
assert (
|
| 147 |
+
await member.put(f"/api/providers/{pid}/models", json={"model_id": "model-a"})
|
| 148 |
+
).status_code == 404
|
| 149 |
+
assert (
|
| 150 |
+
await member.put(f"/api/admin/shared-models/{model['id']}", json={})
|
| 151 |
+
).status_code == 403
|
| 152 |
+
headers = {"Authorization": f"Bearer {key}"}
|
| 153 |
+
listed = (await member.get("/v1/models", headers=headers)).json()["data"]
|
| 154 |
+
assert [m["native_id"] for m in listed] == ["model-a"]
|
| 155 |
+
payload = {
|
| 156 |
+
"model": public["route_id"],
|
| 157 |
+
"messages": [{"role": "user", "content": "Hello"}],
|
| 158 |
+
"n": 16,
|
| 159 |
+
"max_completion_tokens": 100000,
|
| 160 |
+
"max_tokens": 9999,
|
| 161 |
+
}
|
| 162 |
+
response = await member.post("/v1/chat/completions", headers=headers, json=payload)
|
| 163 |
+
assert response.status_code == 200, response.text
|
| 164 |
+
sent = json.loads(upstream.calls[-1].content)
|
| 165 |
+
assert sent["max_completion_tokens"] == 256 and sent["n"] == 1
|
| 166 |
+
assert "max_tokens" not in sent
|
| 167 |
+
assert upstream.calls[-1].headers["authorization"] == "Bearer upstream-provider-key"
|
| 168 |
+
log = (await member.get("/api/logs")).json()["data"][0]
|
| 169 |
+
assert log["estimated_cost"] == 0 and log["provider_name"] == "Community"
|
| 170 |
+
async with app.state.db() as db:
|
| 171 |
+
record = await db.scalar(select(RequestLog).where(RequestLog.id == log["id"]))
|
| 172 |
+
assert record.sponsored_cost == pytest.approx(0.000014)
|
| 173 |
+
for endpoint, body in [
|
| 174 |
+
("embeddings", {"input": "hello"}),
|
| 175 |
+
("responses", {"input": "hello"}),
|
| 176 |
+
]:
|
| 177 |
+
assert (
|
| 178 |
+
await member.post(
|
| 179 |
+
f"/v1/{endpoint}", headers=headers, json={"model": public["route_id"], **body}
|
| 180 |
+
)
|
| 181 |
+
).status_code == 404
|
| 182 |
+
assert (
|
| 183 |
+
await member.post("/api/playground", json=payload | {"model": f"{pid}::private-only"})
|
| 184 |
+
).status_code == 404
|
| 185 |
+
await admin.put(f"/api/admin/shared-models/{model['id']}", json={"enabled": False})
|
| 186 |
+
assert (await member.get("/api/models")).json()["total"] == 0
|
| 187 |
+
assert (await member.post("/api/playground", json=payload)).status_code == 404
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
async def test_community_limits_and_site_switch(environment):
|
| 191 |
+
_, _, upstream = environment
|
| 192 |
+
admin, member, _, _, model, _ = await community_setup(environment, quota=1)
|
| 193 |
+
async with aclosing(member):
|
| 194 |
+
payload = {"model": model["route_id"], "messages": [{"role": "user", "content": "Hi"}]}
|
| 195 |
+
assert (
|
| 196 |
+
await member.post(
|
| 197 |
+
"/api/playground",
|
| 198 |
+
json=payload | {"messages": [{"role": "user", "content": "x" * 64001}]},
|
| 199 |
+
)
|
| 200 |
+
).status_code == 413
|
| 201 |
+
assert not upstream.calls
|
| 202 |
+
assert (await member.post("/api/playground", json=payload)).status_code == 200
|
| 203 |
+
assert (await member.post("/api/playground", json=payload)).status_code == 429
|
| 204 |
+
assert len(upstream.calls) == 1
|
| 205 |
+
settings = (await admin.get("/api/admin/settings")).json()["settings"]
|
| 206 |
+
await admin.put("/api/admin/settings", json=settings | {"shared_models_enabled": False})
|
| 207 |
+
assert (await member.get("/api/models")).json()["total"] == 0
|
| 208 |
+
assert (await member.post("/api/playground", json=payload)).status_code == 404
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
async def test_suspension_ends_sessions_and_blocks_keys(environment):
|
| 212 |
+
admin, member, key, uid, _, _ = await community_setup(environment)
|
| 213 |
+
async with aclosing(member):
|
| 214 |
+
response = await admin.patch(f"/api/admin/users/{uid}", json={"disabled": True})
|
| 215 |
+
assert response.status_code == 200
|
| 216 |
+
assert (await member.get("/api/auth/me")).status_code == 401
|
| 217 |
+
assert (
|
| 218 |
+
await member.get("/v1/models", headers={"Authorization": f"Bearer {key}"})
|
| 219 |
+
).status_code == 403
|
| 220 |
+
assert (
|
| 221 |
+
await member.post(
|
| 222 |
+
"/api/auth/login",
|
| 223 |
+
json={"email": "member@example.com", "password": "correct-horse-battery-123"},
|
| 224 |
+
)
|
| 225 |
+
).status_code == 403
|
| 226 |
+
assert (
|
| 227 |
+
await admin.patch(f"/api/admin/users/{uid}", json={"disabled": False, "is_admin": True})
|
| 228 |
+
).status_code == 422
|
| 229 |
+
await admin.patch(f"/api/admin/users/{uid}", json={"disabled": False})
|
| 230 |
+
assert (
|
| 231 |
+
await member.get("/v1/models", headers={"Authorization": f"Bearer {key}"})
|
| 232 |
+
).status_code == 200
|
| 233 |
+
|
| 234 |
+
|
| 235 |
+
async def test_full_catalog_search_includes_last_page_and_free_models(environment):
|
| 236 |
+
app, client, _ = environment
|
| 237 |
+
await register(client)
|
| 238 |
+
pid = await connect(client)
|
| 239 |
+
async with app.state.db() as db:
|
| 240 |
+
db.add_all(
|
| 241 |
+
[
|
| 242 |
+
RegistryModel(
|
| 243 |
+
provider_id=pid,
|
| 244 |
+
model_id=f"catalog-{i:04d}",
|
| 245 |
+
name=f"Model {i}",
|
| 246 |
+
capabilities={"chat": True},
|
| 247 |
+
available=True,
|
| 248 |
+
input_price=0 if i == 1204 else 1,
|
| 249 |
+
output_price=0 if i == 1204 else 2,
|
| 250 |
+
)
|
| 251 |
+
for i in range(1205)
|
| 252 |
+
]
|
| 253 |
+
)
|
| 254 |
+
await db.commit()
|
| 255 |
+
first = (await client.get("/api/models?limit=1000")).json()
|
| 256 |
+
last = (await client.get("/api/models?limit=1000&offset=1000")).json()
|
| 257 |
+
assert first["total"] == 1206 and len(first["data"]) == 1000
|
| 258 |
+
assert len(last["data"]) == 206
|
| 259 |
+
assert len({m["id"] for m in first["data"] + last["data"]}) == 1206
|
| 260 |
+
result = (await client.get("/api/models?search=Model%201204&free_only=true")).json()
|
| 261 |
+
assert result["total"] == 1 and result["data"][0]["model_id"] == "catalog-1204"
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
async def test_upstream_credit_error_is_actionable_without_leaking_details(environment):
|
| 265 |
+
_, client, upstream = environment
|
| 266 |
+
await register(client)
|
| 267 |
+
await connect(client)
|
| 268 |
+
upstream.statuses["first.example.com"] = 402
|
| 269 |
+
response = await client.post(
|
| 270 |
+
"/api/playground",
|
| 271 |
+
json={"model": "model-a", "messages": [{"role": "user", "content": "hello"}]},
|
| 272 |
+
)
|
| 273 |
+
assert response.status_code == 502
|
| 274 |
+
assert "HTTP 402" in response.json()["error"]["message"]
|
| 275 |
+
assert "billing" in response.json()["error"]["message"]
|
| 276 |
+
assert "LEAK" not in response.text and "upstream-provider-key" not in response.text
|
backend/tests/test_admin_bootstrap.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import pytest
|
| 2 |
+
from sqlalchemy import select
|
| 3 |
+
|
| 4 |
+
from app.admin_bootstrap import provision_admin
|
| 5 |
+
from app.models import User
|
| 6 |
+
from app.security import hash_password
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
async def test_bootstrap_requires_explicit_valid_seed(environment):
|
| 10 |
+
app, _, _ = environment
|
| 11 |
+
async with app.state.db() as db:
|
| 12 |
+
for action, seed in [("grant", None), ("bootstrap", None), ("bootstrap", "not-a-hash")]:
|
| 13 |
+
with pytest.raises(ValueError):
|
| 14 |
+
await provision_admin(db, "owner@example.com", action, seed)
|
| 15 |
+
assert await db.scalar(select(User)) is None
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
async def test_fresh_owner_can_sign_in_and_restarts_preserve_password(environment):
|
| 19 |
+
app, client, _ = environment
|
| 20 |
+
seed = await hash_password("bootstrap-owner-password-123")
|
| 21 |
+
async with app.state.db() as db:
|
| 22 |
+
owner = await provision_admin(db, "owner@example.com", "bootstrap", seed)
|
| 23 |
+
uid = owner.id
|
| 24 |
+
assert owner.is_admin
|
| 25 |
+
login = await client.post(
|
| 26 |
+
"/api/auth/login",
|
| 27 |
+
json={"email": "owner@example.com", "password": "bootstrap-owner-password-123"},
|
| 28 |
+
)
|
| 29 |
+
assert login.status_code == 200 and login.json()["user"]["is_admin"]
|
| 30 |
+
assert (await client.get("/api/admin/settings")).status_code == 200
|
| 31 |
+
async with app.state.db() as db:
|
| 32 |
+
owner = await provision_admin(
|
| 33 |
+
db, "owner@example.com", "bootstrap", await hash_password("a-different-password-123")
|
| 34 |
+
)
|
| 35 |
+
assert owner.id == uid and owner.password_hash == seed
|
| 36 |
+
await provision_admin(db, "owner@example.com", "revoke")
|
| 37 |
+
assert (await client.get("/api/admin/settings")).status_code == 403
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
async def test_bootstrap_does_not_unsuspend_accounts(environment):
|
| 41 |
+
app, _, _ = environment
|
| 42 |
+
async with app.state.db() as db:
|
| 43 |
+
owner = await provision_admin(
|
| 44 |
+
db,
|
| 45 |
+
"owner@example.com",
|
| 46 |
+
"bootstrap",
|
| 47 |
+
await hash_password("bootstrap-owner-password-123"),
|
| 48 |
+
)
|
| 49 |
+
owner.disabled = True
|
| 50 |
+
await db.commit()
|
| 51 |
+
with pytest.raises(ValueError, match="Restore"):
|
| 52 |
+
await provision_admin(db, "owner@example.com", "bootstrap")
|
| 53 |
+
assert owner.disabled
|
deploy/start.py
CHANGED
|
@@ -163,6 +163,22 @@ try:
|
|
| 163 |
],
|
| 164 |
check=True,
|
| 165 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
api = subprocess.Popen(
|
| 167 |
[
|
| 168 |
sys.executable,
|
|
|
|
| 163 |
],
|
| 164 |
check=True,
|
| 165 |
)
|
| 166 |
+
# Server-only provisioning runs before the public application accepts requests.
|
| 167 |
+
# A password-hash seed can recreate the owner after an explicitly accepted data reset.
|
| 168 |
+
if os.environ.get("ADMIN_BOOTSTRAP_EMAIL"):
|
| 169 |
+
subprocess.run(
|
| 170 |
+
[
|
| 171 |
+
sys.executable,
|
| 172 |
+
"scripts/admin.py",
|
| 173 |
+
"bootstrap",
|
| 174 |
+
"--email",
|
| 175 |
+
os.environ["ADMIN_BOOTSTRAP_EMAIL"],
|
| 176 |
+
],
|
| 177 |
+
check=True,
|
| 178 |
+
)
|
| 179 |
+
# The web worker does not need access to the operator's provisioning secrets.
|
| 180 |
+
os.environ.pop("ADMIN_BOOTSTRAP_PASSWORD_HASH", None)
|
| 181 |
+
os.environ.pop("ADMIN_BOOTSTRAP_EMAIL", None)
|
| 182 |
api = subprocess.Popen(
|
| 183 |
[
|
| 184 |
sys.executable,
|
docs/administration.md
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Administration and community models
|
| 2 |
+
|
| 3 |
+
Open `https://YOUR-GATEWAY/#admin` and sign in with an existing administrator account. There is no separate admin password, default account, or public role-grant endpoint. Ordinary members receive HTTP 403 from every administration API, even if they open the URL directly. Session mutations also require the same-origin CSRF token.
|
| 4 |
+
|
| 5 |
+
## Activate an administrator
|
| 6 |
+
|
| 7 |
+
Deploy this version first. The container runs the database migration automatically before starting the application. Keep the existing encryption keys and database; changing encryption keys without rotation makes existing provider credentials unreadable.
|
| 8 |
+
|
| 9 |
+
From the running gateway container:
|
| 10 |
+
|
| 11 |
+
```sh
|
| 12 |
+
docker exec YOUR_CONTAINER python scripts/admin.py grant --email owner@example.com
|
| 13 |
+
docker exec YOUR_CONTAINER python scripts/admin.py list
|
| 14 |
+
```
|
| 15 |
+
|
| 16 |
+
For a non-container installation, run `python scripts/admin.py grant --email owner@example.com` from the repository root using the application's Python environment and database configuration, after `python -m alembic -c backend/alembic.ini upgrade head`.
|
| 17 |
+
|
| 18 |
+
For Hugging Face Spaces without a container terminal, add a Space **Secret** named `ADMIN_BOOTSTRAP_EMAIL` containing the exact email of an **already registered** account, then restart the updated Space. The startup supervisor grants that account's role after migrating. With persistent database storage, remove this secret after the first successful activation; the role persists in the database. Leaving it set would grant the role again on later restarts. A suspended account is never restored automatically. An unknown account requires the explicit password-hash seed described below; otherwise startup stops without creating it. Remove the secret to recover from a typo.
|
| 19 |
+
|
| 20 |
+
### Fresh database after an approved reset
|
| 21 |
+
|
| 22 |
+
Set two private Space secrets before starting the updated application:
|
| 23 |
+
|
| 24 |
+
- `ADMIN_BOOTSTRAP_EMAIL`: the exact administrator email.
|
| 25 |
+
- `ADMIN_BOOTSTRAP_PASSWORD_HASH`: an Argon2id hash of your chosen password, generated locally. No plaintext password or default password is placed in the repository.
|
| 26 |
+
|
| 27 |
+
Generate a hash in your gateway Python environment with:
|
| 28 |
+
|
| 29 |
+
```sh
|
| 30 |
+
python -c "from argon2 import PasswordHasher; from getpass import getpass; print(PasswordHasher(time_cost=3, memory_cost=65536, parallelism=2).hash(getpass('Admin password: ')))"
|
| 31 |
+
```
|
| 32 |
+
|
| 33 |
+
Copy only the generated hash into the private secret. On an empty database, startup creates that one administrator before accepting public requests. On an existing database, it keeps the account's password and data. The web worker does not inherit these bootstrap secrets. The equivalent server command is `python scripts/admin.py bootstrap --email owner@example.com` with the hash environment variable set.
|
| 34 |
+
|
| 35 |
+
On ephemeral storage, keep both secrets if you want this admin login recreated after later resets. This does **not** preserve provider connections, other users, site settings, published models, or request history. Use durable PostgreSQL to retain those. Once durable storage is configured, remove both bootstrap secrets so later role revocation cannot be undone by a restart.
|
| 36 |
+
|
| 37 |
+
Refresh the gateway after activation. **Administration** appears in the sidebar. Use the account's existing password. Public signup ignores client-supplied role fields and never promotes users.
|
| 38 |
+
|
| 39 |
+
To revoke access, first remove any bootstrap secret, then run:
|
| 40 |
+
|
| 41 |
+
```sh
|
| 42 |
+
docker exec YOUR_CONTAINER python scripts/admin.py revoke --email owner@example.com
|
| 43 |
+
```
|
| 44 |
+
|
| 45 |
+
Role checks read the database on every request. Revoking an administrator also makes their published models unavailable to other users. Restore another administrator through the server command if needed; administrator accounts cannot be suspended from the web panel.
|
| 46 |
+
|
| 47 |
+
## What the panel controls
|
| 48 |
+
|
| 49 |
+
- Site name, tagline, welcome text and purple/blue/green accent.
|
| 50 |
+
- Default Playground routing, streaming and retry budget. Existing Playground sessions retain their settings; reopening Playground uses updated defaults.
|
| 51 |
+
- New registrations, account request rate, community request rate across the site, and a community-model enable switch. Server environment limits remain the upper bounds.
|
| 52 |
+
- Search accounts, suspend members and restore access. Suspension deletes active sessions and blocks existing API keys. Restoring an account re-enables unexpired, unrevoked keys.
|
| 53 |
+
- Publish selected models from the administrator's own provider connections; enable or disable each publication. Change its quota by selecting that model, entering new limits, and publishing again.
|
| 54 |
+
|
| 55 |
+
Private provider credentials, other users' keys, password hashes, prompts and responses are not exposed in the administration panel. Changes create audit records. This is service administration, not arbitrary server command execution or a source-code editor.
|
| 56 |
+
|
| 57 |
+
## Publish your own free model
|
| 58 |
+
|
| 59 |
+
1. Open **Administration → Community models → Connect provider**.
|
| 60 |
+
2. Select a compatible provider or **Custom OpenAI-compatible** and enter the model server's API base URL (usually ending in `/v1`) and credential. For Ollama/LM Studio on a private network, the server operator must explicitly allow its exact hostname with `PRIVATE_UPSTREAM_HOSTS`; public users cannot change this setting.
|
| 61 |
+
3. Let discovery finish. If your server does not list models, choose **Register your model**, select the new connection, enter the exact model ID, and confirm Chat capability plus any capabilities the server actually supports. Configure real input/output prices when known so sponsored cost estimates can be calculated.
|
| 62 |
+
4. Choose that model in the admin picker, set requests per minute per member and the maximum output tokens, then **Publish free model**.
|
| 63 |
+
5. Members can find it under **Playground → Select model → Free only** or **Model explorer → Free only**. The catalog includes every page and supports model name, ID and provider search. Published models also appear in each member's authenticated `/v1/models` list and can be used with their own Gateway API key.
|
| 64 |
+
|
| 65 |
+
The gateway keeps the provider credential encrypted on the server and injects it only when contacting that provider. Publishing one model does not publish the provider's other models or grant members permission to edit that connection. Members' published-model requests show zero estimated cost; the actual estimated upstream cost is recorded as sponsored usage for administrators. The model sponsor remains responsible for provider charges and server capacity.
|
| 66 |
+
|
| 67 |
+
Community access requires a gateway account. It supports chat completions and chat streaming, including the chat-compatible messages adapter. It does not expose shared embeddings, images, audio or stateful Responses routes. Shared requests enforce the configured per-user/per-model and site-wide rates, one completion per request, output token limits, and a 64,000-character serialized request limit. Provider rate limits still apply. Disabling the publication, its provider, the community switch, or the owner's admin role blocks new shared requests; in-flight requests may finish.
|
| 68 |
+
|
| 69 |
+
## Deployment and verification
|
| 70 |
+
|
| 71 |
+
Deploy the complete repository, including `backend/alembic`, `scripts`, `deploy` and the frontend. The Dockerfile builds the static frontend; `deploy/start.py` applies migration `a481c6e724df` and starts the app. Do not copy only the frontend because admin authorization and community routing depend on the backend changes.
|
| 72 |
+
|
| 73 |
+
Use persistent storage or managed PostgreSQL for account and role durability. After rebuilding, verify `/health/ready`, sign in, open `/#admin`, save one setting, and check a published model using a separate member account. Never store provider keys or passwords in GitHub source files.
|
| 74 |
+
|
| 75 |
+
A manual GitHub workflow, **Deploy gateway Space**, can upload the full application while preserving the Hugging Face README metadata. Configure a Space trusted publisher for GitHub repository `ArnavSingh76533/gateway`, branch `main`, workflow `deploy-space.yml`; the workflow uses short-lived credentials restricted to that Space. It runs only when explicitly dispatched with the storage acknowledgement checked. Review CI first. The current free Space had no mounted storage or external database at implementation time, so do not rebuild it until its existing data has been backed up or you have explicitly accepted resetting it. See [Hugging Face storage](https://huggingface.co/docs/hub/spaces-storage) and [trusted publishers](https://huggingface.co/docs/hub/trusted-publishers).
|
docs/openapi.json
CHANGED
|
@@ -481,6 +481,26 @@
|
|
| 481 |
"title": "Provider"
|
| 482 |
}
|
| 483 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 484 |
{
|
| 485 |
"name": "limit",
|
| 486 |
"in": "query",
|
|
@@ -1003,6 +1023,370 @@
|
|
| 1003 |
]
|
| 1004 |
}
|
| 1005 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1006 |
"/v1/messages": {
|
| 1007 |
"post": {
|
| 1008 |
"tags": [
|
|
@@ -1471,6 +1855,20 @@
|
|
| 1471 |
},
|
| 1472 |
"components": {
|
| 1473 |
"schemas": {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1474 |
"Capabilities": {
|
| 1475 |
"properties": {
|
| 1476 |
"chat": {
|
|
@@ -2359,6 +2757,32 @@
|
|
| 2359 |
"type": "object",
|
| 2360 |
"title": "ProviderPatch"
|
| 2361 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2362 |
"Register": {
|
| 2363 |
"properties": {
|
| 2364 |
"email": {
|
|
@@ -2475,6 +2899,93 @@
|
|
| 2475 |
],
|
| 2476 |
"title": "ResponseRequest"
|
| 2477 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2478 |
"SpeechRequest": {
|
| 2479 |
"properties": {
|
| 2480 |
"model": {
|
|
@@ -2613,4 +3124,4 @@
|
|
| 2613 |
}
|
| 2614 |
}
|
| 2615 |
}
|
| 2616 |
-
}
|
|
|
|
| 481 |
"title": "Provider"
|
| 482 |
}
|
| 483 |
},
|
| 484 |
+
{
|
| 485 |
+
"name": "free_only",
|
| 486 |
+
"in": "query",
|
| 487 |
+
"required": false,
|
| 488 |
+
"schema": {
|
| 489 |
+
"type": "boolean",
|
| 490 |
+
"default": false,
|
| 491 |
+
"title": "Free Only"
|
| 492 |
+
}
|
| 493 |
+
},
|
| 494 |
+
{
|
| 495 |
+
"name": "owned_only",
|
| 496 |
+
"in": "query",
|
| 497 |
+
"required": false,
|
| 498 |
+
"schema": {
|
| 499 |
+
"type": "boolean",
|
| 500 |
+
"default": false,
|
| 501 |
+
"title": "Owned Only"
|
| 502 |
+
}
|
| 503 |
+
},
|
| 504 |
{
|
| 505 |
"name": "limit",
|
| 506 |
"in": "query",
|
|
|
|
| 1023 |
]
|
| 1024 |
}
|
| 1025 |
},
|
| 1026 |
+
"/api/site": {
|
| 1027 |
+
"get": {
|
| 1028 |
+
"tags": [
|
| 1029 |
+
"Administration"
|
| 1030 |
+
],
|
| 1031 |
+
"summary": "Public Settings",
|
| 1032 |
+
"operationId": "public_settings_api_site_get",
|
| 1033 |
+
"responses": {
|
| 1034 |
+
"200": {
|
| 1035 |
+
"description": "Successful Response",
|
| 1036 |
+
"content": {
|
| 1037 |
+
"application/json": {
|
| 1038 |
+
"schema": {
|
| 1039 |
+
"additionalProperties": true,
|
| 1040 |
+
"type": "object",
|
| 1041 |
+
"title": "Response Public Settings Api Site Get"
|
| 1042 |
+
}
|
| 1043 |
+
}
|
| 1044 |
+
}
|
| 1045 |
+
}
|
| 1046 |
+
}
|
| 1047 |
+
}
|
| 1048 |
+
},
|
| 1049 |
+
"/api/admin/settings": {
|
| 1050 |
+
"get": {
|
| 1051 |
+
"tags": [
|
| 1052 |
+
"Administration"
|
| 1053 |
+
],
|
| 1054 |
+
"summary": "Get Settings",
|
| 1055 |
+
"operationId": "get_settings_api_admin_settings_get",
|
| 1056 |
+
"responses": {
|
| 1057 |
+
"200": {
|
| 1058 |
+
"description": "Successful Response",
|
| 1059 |
+
"content": {
|
| 1060 |
+
"application/json": {
|
| 1061 |
+
"schema": {
|
| 1062 |
+
"additionalProperties": true,
|
| 1063 |
+
"type": "object",
|
| 1064 |
+
"title": "Response Get Settings Api Admin Settings Get"
|
| 1065 |
+
}
|
| 1066 |
+
}
|
| 1067 |
+
}
|
| 1068 |
+
}
|
| 1069 |
+
},
|
| 1070 |
+
"security": [
|
| 1071 |
+
{
|
| 1072 |
+
"DashboardSession": []
|
| 1073 |
+
}
|
| 1074 |
+
]
|
| 1075 |
+
},
|
| 1076 |
+
"put": {
|
| 1077 |
+
"tags": [
|
| 1078 |
+
"Administration"
|
| 1079 |
+
],
|
| 1080 |
+
"summary": "Save Settings",
|
| 1081 |
+
"operationId": "save_settings_api_admin_settings_put",
|
| 1082 |
+
"requestBody": {
|
| 1083 |
+
"content": {
|
| 1084 |
+
"application/json": {
|
| 1085 |
+
"schema": {
|
| 1086 |
+
"$ref": "#/components/schemas/SiteOptions"
|
| 1087 |
+
}
|
| 1088 |
+
}
|
| 1089 |
+
},
|
| 1090 |
+
"required": true
|
| 1091 |
+
},
|
| 1092 |
+
"responses": {
|
| 1093 |
+
"200": {
|
| 1094 |
+
"description": "Successful Response",
|
| 1095 |
+
"content": {
|
| 1096 |
+
"application/json": {
|
| 1097 |
+
"schema": {
|
| 1098 |
+
"additionalProperties": true,
|
| 1099 |
+
"type": "object",
|
| 1100 |
+
"title": "Response Save Settings Api Admin Settings Put"
|
| 1101 |
+
}
|
| 1102 |
+
}
|
| 1103 |
+
}
|
| 1104 |
+
},
|
| 1105 |
+
"422": {
|
| 1106 |
+
"description": "Validation Error",
|
| 1107 |
+
"content": {
|
| 1108 |
+
"application/json": {
|
| 1109 |
+
"schema": {
|
| 1110 |
+
"$ref": "#/components/schemas/HTTPValidationError"
|
| 1111 |
+
}
|
| 1112 |
+
}
|
| 1113 |
+
}
|
| 1114 |
+
}
|
| 1115 |
+
},
|
| 1116 |
+
"security": [
|
| 1117 |
+
{
|
| 1118 |
+
"DashboardSession": []
|
| 1119 |
+
}
|
| 1120 |
+
],
|
| 1121 |
+
"parameters": [
|
| 1122 |
+
{
|
| 1123 |
+
"name": "X-CSRF-Token",
|
| 1124 |
+
"in": "header",
|
| 1125 |
+
"required": true,
|
| 1126 |
+
"schema": {
|
| 1127 |
+
"type": "string"
|
| 1128 |
+
},
|
| 1129 |
+
"description": "Value of the gw_csrf cookie. Same-origin dashboard mutations only."
|
| 1130 |
+
}
|
| 1131 |
+
]
|
| 1132 |
+
}
|
| 1133 |
+
},
|
| 1134 |
+
"/api/admin/summary": {
|
| 1135 |
+
"get": {
|
| 1136 |
+
"tags": [
|
| 1137 |
+
"Administration"
|
| 1138 |
+
],
|
| 1139 |
+
"summary": "Summary",
|
| 1140 |
+
"operationId": "summary_api_admin_summary_get",
|
| 1141 |
+
"responses": {
|
| 1142 |
+
"200": {
|
| 1143 |
+
"description": "Successful Response",
|
| 1144 |
+
"content": {
|
| 1145 |
+
"application/json": {
|
| 1146 |
+
"schema": {
|
| 1147 |
+
"additionalProperties": true,
|
| 1148 |
+
"type": "object",
|
| 1149 |
+
"title": "Response Summary Api Admin Summary Get"
|
| 1150 |
+
}
|
| 1151 |
+
}
|
| 1152 |
+
}
|
| 1153 |
+
}
|
| 1154 |
+
},
|
| 1155 |
+
"security": [
|
| 1156 |
+
{
|
| 1157 |
+
"DashboardSession": []
|
| 1158 |
+
}
|
| 1159 |
+
]
|
| 1160 |
+
}
|
| 1161 |
+
},
|
| 1162 |
+
"/api/admin/users": {
|
| 1163 |
+
"get": {
|
| 1164 |
+
"tags": [
|
| 1165 |
+
"Administration"
|
| 1166 |
+
],
|
| 1167 |
+
"summary": "Users",
|
| 1168 |
+
"operationId": "users_api_admin_users_get",
|
| 1169 |
+
"parameters": [
|
| 1170 |
+
{
|
| 1171 |
+
"name": "search",
|
| 1172 |
+
"in": "query",
|
| 1173 |
+
"required": false,
|
| 1174 |
+
"schema": {
|
| 1175 |
+
"type": "string",
|
| 1176 |
+
"default": "",
|
| 1177 |
+
"title": "Search"
|
| 1178 |
+
}
|
| 1179 |
+
},
|
| 1180 |
+
{
|
| 1181 |
+
"name": "offset",
|
| 1182 |
+
"in": "query",
|
| 1183 |
+
"required": false,
|
| 1184 |
+
"schema": {
|
| 1185 |
+
"type": "integer",
|
| 1186 |
+
"minimum": 0,
|
| 1187 |
+
"default": 0,
|
| 1188 |
+
"title": "Offset"
|
| 1189 |
+
}
|
| 1190 |
+
}
|
| 1191 |
+
],
|
| 1192 |
+
"responses": {
|
| 1193 |
+
"200": {
|
| 1194 |
+
"description": "Successful Response",
|
| 1195 |
+
"content": {
|
| 1196 |
+
"application/json": {
|
| 1197 |
+
"schema": {
|
| 1198 |
+
"type": "object",
|
| 1199 |
+
"additionalProperties": true,
|
| 1200 |
+
"title": "Response Users Api Admin Users Get"
|
| 1201 |
+
}
|
| 1202 |
+
}
|
| 1203 |
+
}
|
| 1204 |
+
},
|
| 1205 |
+
"422": {
|
| 1206 |
+
"description": "Validation Error",
|
| 1207 |
+
"content": {
|
| 1208 |
+
"application/json": {
|
| 1209 |
+
"schema": {
|
| 1210 |
+
"$ref": "#/components/schemas/HTTPValidationError"
|
| 1211 |
+
}
|
| 1212 |
+
}
|
| 1213 |
+
}
|
| 1214 |
+
}
|
| 1215 |
+
},
|
| 1216 |
+
"security": [
|
| 1217 |
+
{
|
| 1218 |
+
"DashboardSession": []
|
| 1219 |
+
}
|
| 1220 |
+
]
|
| 1221 |
+
}
|
| 1222 |
+
},
|
| 1223 |
+
"/api/admin/users/{uid}": {
|
| 1224 |
+
"patch": {
|
| 1225 |
+
"tags": [
|
| 1226 |
+
"Administration"
|
| 1227 |
+
],
|
| 1228 |
+
"summary": "Account Status",
|
| 1229 |
+
"operationId": "account_status_api_admin_users__uid__patch",
|
| 1230 |
+
"parameters": [
|
| 1231 |
+
{
|
| 1232 |
+
"name": "uid",
|
| 1233 |
+
"in": "path",
|
| 1234 |
+
"required": true,
|
| 1235 |
+
"schema": {
|
| 1236 |
+
"type": "string",
|
| 1237 |
+
"title": "Uid"
|
| 1238 |
+
}
|
| 1239 |
+
},
|
| 1240 |
+
{
|
| 1241 |
+
"name": "X-CSRF-Token",
|
| 1242 |
+
"in": "header",
|
| 1243 |
+
"required": true,
|
| 1244 |
+
"schema": {
|
| 1245 |
+
"type": "string"
|
| 1246 |
+
},
|
| 1247 |
+
"description": "Value of the gw_csrf cookie. Same-origin dashboard mutations only."
|
| 1248 |
+
}
|
| 1249 |
+
],
|
| 1250 |
+
"requestBody": {
|
| 1251 |
+
"required": true,
|
| 1252 |
+
"content": {
|
| 1253 |
+
"application/json": {
|
| 1254 |
+
"schema": {
|
| 1255 |
+
"$ref": "#/components/schemas/AccountStatus"
|
| 1256 |
+
}
|
| 1257 |
+
}
|
| 1258 |
+
}
|
| 1259 |
+
},
|
| 1260 |
+
"responses": {
|
| 1261 |
+
"200": {
|
| 1262 |
+
"description": "Successful Response",
|
| 1263 |
+
"content": {
|
| 1264 |
+
"application/json": {
|
| 1265 |
+
"schema": {
|
| 1266 |
+
"type": "object",
|
| 1267 |
+
"additionalProperties": true,
|
| 1268 |
+
"title": "Response Account Status Api Admin Users Uid Patch"
|
| 1269 |
+
}
|
| 1270 |
+
}
|
| 1271 |
+
}
|
| 1272 |
+
},
|
| 1273 |
+
"422": {
|
| 1274 |
+
"description": "Validation Error",
|
| 1275 |
+
"content": {
|
| 1276 |
+
"application/json": {
|
| 1277 |
+
"schema": {
|
| 1278 |
+
"$ref": "#/components/schemas/HTTPValidationError"
|
| 1279 |
+
}
|
| 1280 |
+
}
|
| 1281 |
+
}
|
| 1282 |
+
}
|
| 1283 |
+
},
|
| 1284 |
+
"security": [
|
| 1285 |
+
{
|
| 1286 |
+
"DashboardSession": []
|
| 1287 |
+
}
|
| 1288 |
+
]
|
| 1289 |
+
}
|
| 1290 |
+
},
|
| 1291 |
+
"/api/admin/shared-models": {
|
| 1292 |
+
"get": {
|
| 1293 |
+
"tags": [
|
| 1294 |
+
"Administration"
|
| 1295 |
+
],
|
| 1296 |
+
"summary": "Shared Models",
|
| 1297 |
+
"operationId": "shared_models_api_admin_shared_models_get",
|
| 1298 |
+
"responses": {
|
| 1299 |
+
"200": {
|
| 1300 |
+
"description": "Successful Response",
|
| 1301 |
+
"content": {
|
| 1302 |
+
"application/json": {
|
| 1303 |
+
"schema": {
|
| 1304 |
+
"items": {
|
| 1305 |
+
"additionalProperties": true,
|
| 1306 |
+
"type": "object"
|
| 1307 |
+
},
|
| 1308 |
+
"type": "array",
|
| 1309 |
+
"title": "Response Shared Models Api Admin Shared Models Get"
|
| 1310 |
+
}
|
| 1311 |
+
}
|
| 1312 |
+
}
|
| 1313 |
+
}
|
| 1314 |
+
},
|
| 1315 |
+
"security": [
|
| 1316 |
+
{
|
| 1317 |
+
"DashboardSession": []
|
| 1318 |
+
}
|
| 1319 |
+
]
|
| 1320 |
+
}
|
| 1321 |
+
},
|
| 1322 |
+
"/api/admin/shared-models/{mid}": {
|
| 1323 |
+
"put": {
|
| 1324 |
+
"tags": [
|
| 1325 |
+
"Administration"
|
| 1326 |
+
],
|
| 1327 |
+
"summary": "Publish",
|
| 1328 |
+
"operationId": "publish_api_admin_shared_models__mid__put",
|
| 1329 |
+
"parameters": [
|
| 1330 |
+
{
|
| 1331 |
+
"name": "mid",
|
| 1332 |
+
"in": "path",
|
| 1333 |
+
"required": true,
|
| 1334 |
+
"schema": {
|
| 1335 |
+
"type": "string",
|
| 1336 |
+
"title": "Mid"
|
| 1337 |
+
}
|
| 1338 |
+
},
|
| 1339 |
+
{
|
| 1340 |
+
"name": "X-CSRF-Token",
|
| 1341 |
+
"in": "header",
|
| 1342 |
+
"required": true,
|
| 1343 |
+
"schema": {
|
| 1344 |
+
"type": "string"
|
| 1345 |
+
},
|
| 1346 |
+
"description": "Value of the gw_csrf cookie. Same-origin dashboard mutations only."
|
| 1347 |
+
}
|
| 1348 |
+
],
|
| 1349 |
+
"requestBody": {
|
| 1350 |
+
"required": true,
|
| 1351 |
+
"content": {
|
| 1352 |
+
"application/json": {
|
| 1353 |
+
"schema": {
|
| 1354 |
+
"$ref": "#/components/schemas/PublishOptions"
|
| 1355 |
+
}
|
| 1356 |
+
}
|
| 1357 |
+
}
|
| 1358 |
+
},
|
| 1359 |
+
"responses": {
|
| 1360 |
+
"200": {
|
| 1361 |
+
"description": "Successful Response",
|
| 1362 |
+
"content": {
|
| 1363 |
+
"application/json": {
|
| 1364 |
+
"schema": {
|
| 1365 |
+
"type": "object",
|
| 1366 |
+
"additionalProperties": true,
|
| 1367 |
+
"title": "Response Publish Api Admin Shared Models Mid Put"
|
| 1368 |
+
}
|
| 1369 |
+
}
|
| 1370 |
+
}
|
| 1371 |
+
},
|
| 1372 |
+
"422": {
|
| 1373 |
+
"description": "Validation Error",
|
| 1374 |
+
"content": {
|
| 1375 |
+
"application/json": {
|
| 1376 |
+
"schema": {
|
| 1377 |
+
"$ref": "#/components/schemas/HTTPValidationError"
|
| 1378 |
+
}
|
| 1379 |
+
}
|
| 1380 |
+
}
|
| 1381 |
+
}
|
| 1382 |
+
},
|
| 1383 |
+
"security": [
|
| 1384 |
+
{
|
| 1385 |
+
"DashboardSession": []
|
| 1386 |
+
}
|
| 1387 |
+
]
|
| 1388 |
+
}
|
| 1389 |
+
},
|
| 1390 |
"/v1/messages": {
|
| 1391 |
"post": {
|
| 1392 |
"tags": [
|
|
|
|
| 1855 |
},
|
| 1856 |
"components": {
|
| 1857 |
"schemas": {
|
| 1858 |
+
"AccountStatus": {
|
| 1859 |
+
"properties": {
|
| 1860 |
+
"disabled": {
|
| 1861 |
+
"type": "boolean",
|
| 1862 |
+
"title": "Disabled"
|
| 1863 |
+
}
|
| 1864 |
+
},
|
| 1865 |
+
"additionalProperties": false,
|
| 1866 |
+
"type": "object",
|
| 1867 |
+
"required": [
|
| 1868 |
+
"disabled"
|
| 1869 |
+
],
|
| 1870 |
+
"title": "AccountStatus"
|
| 1871 |
+
},
|
| 1872 |
"Capabilities": {
|
| 1873 |
"properties": {
|
| 1874 |
"chat": {
|
|
|
|
| 2757 |
"type": "object",
|
| 2758 |
"title": "ProviderPatch"
|
| 2759 |
},
|
| 2760 |
+
"PublishOptions": {
|
| 2761 |
+
"properties": {
|
| 2762 |
+
"enabled": {
|
| 2763 |
+
"type": "boolean",
|
| 2764 |
+
"title": "Enabled",
|
| 2765 |
+
"default": true
|
| 2766 |
+
},
|
| 2767 |
+
"requests_per_minute": {
|
| 2768 |
+
"type": "integer",
|
| 2769 |
+
"maximum": 120.0,
|
| 2770 |
+
"minimum": 1.0,
|
| 2771 |
+
"title": "Requests Per Minute",
|
| 2772 |
+
"default": 5
|
| 2773 |
+
},
|
| 2774 |
+
"max_output_tokens": {
|
| 2775 |
+
"type": "integer",
|
| 2776 |
+
"maximum": 8192.0,
|
| 2777 |
+
"minimum": 128.0,
|
| 2778 |
+
"title": "Max Output Tokens",
|
| 2779 |
+
"default": 1024
|
| 2780 |
+
}
|
| 2781 |
+
},
|
| 2782 |
+
"additionalProperties": false,
|
| 2783 |
+
"type": "object",
|
| 2784 |
+
"title": "PublishOptions"
|
| 2785 |
+
},
|
| 2786 |
"Register": {
|
| 2787 |
"properties": {
|
| 2788 |
"email": {
|
|
|
|
| 2899 |
],
|
| 2900 |
"title": "ResponseRequest"
|
| 2901 |
},
|
| 2902 |
+
"SiteOptions": {
|
| 2903 |
+
"properties": {
|
| 2904 |
+
"site_name": {
|
| 2905 |
+
"type": "string",
|
| 2906 |
+
"maxLength": 40,
|
| 2907 |
+
"minLength": 1,
|
| 2908 |
+
"title": "Site Name",
|
| 2909 |
+
"default": "Nexus"
|
| 2910 |
+
},
|
| 2911 |
+
"tagline": {
|
| 2912 |
+
"type": "string",
|
| 2913 |
+
"maxLength": 160,
|
| 2914 |
+
"minLength": 1,
|
| 2915 |
+
"title": "Tagline",
|
| 2916 |
+
"default": "Universal AI Gateway"
|
| 2917 |
+
},
|
| 2918 |
+
"welcome_text": {
|
| 2919 |
+
"type": "string",
|
| 2920 |
+
"maxLength": 180,
|
| 2921 |
+
"minLength": 1,
|
| 2922 |
+
"title": "Welcome Text",
|
| 2923 |
+
"default": "Your models. Your keys. One connection."
|
| 2924 |
+
},
|
| 2925 |
+
"accent": {
|
| 2926 |
+
"type": "string",
|
| 2927 |
+
"enum": [
|
| 2928 |
+
"purple",
|
| 2929 |
+
"blue",
|
| 2930 |
+
"green"
|
| 2931 |
+
],
|
| 2932 |
+
"title": "Accent",
|
| 2933 |
+
"default": "purple"
|
| 2934 |
+
},
|
| 2935 |
+
"registration_open": {
|
| 2936 |
+
"type": "boolean",
|
| 2937 |
+
"title": "Registration Open",
|
| 2938 |
+
"default": true
|
| 2939 |
+
},
|
| 2940 |
+
"requests_per_minute": {
|
| 2941 |
+
"type": "integer",
|
| 2942 |
+
"maximum": 10000.0,
|
| 2943 |
+
"minimum": 1.0,
|
| 2944 |
+
"title": "Requests Per Minute",
|
| 2945 |
+
"default": 60
|
| 2946 |
+
},
|
| 2947 |
+
"shared_models_enabled": {
|
| 2948 |
+
"type": "boolean",
|
| 2949 |
+
"title": "Shared Models Enabled",
|
| 2950 |
+
"default": true
|
| 2951 |
+
},
|
| 2952 |
+
"shared_requests_per_minute": {
|
| 2953 |
+
"type": "integer",
|
| 2954 |
+
"maximum": 10000.0,
|
| 2955 |
+
"minimum": 1.0,
|
| 2956 |
+
"title": "Shared Requests Per Minute",
|
| 2957 |
+
"default": 30
|
| 2958 |
+
},
|
| 2959 |
+
"default_mode": {
|
| 2960 |
+
"type": "string",
|
| 2961 |
+
"enum": [
|
| 2962 |
+
"auto",
|
| 2963 |
+
"fastest",
|
| 2964 |
+
"cheapest",
|
| 2965 |
+
"reasoning",
|
| 2966 |
+
"coding",
|
| 2967 |
+
"vision"
|
| 2968 |
+
],
|
| 2969 |
+
"title": "Default Mode",
|
| 2970 |
+
"default": "auto"
|
| 2971 |
+
},
|
| 2972 |
+
"default_stream": {
|
| 2973 |
+
"type": "boolean",
|
| 2974 |
+
"title": "Default Stream",
|
| 2975 |
+
"default": true
|
| 2976 |
+
},
|
| 2977 |
+
"default_retries": {
|
| 2978 |
+
"type": "integer",
|
| 2979 |
+
"maximum": 5.0,
|
| 2980 |
+
"minimum": 0.0,
|
| 2981 |
+
"title": "Default Retries",
|
| 2982 |
+
"default": 2
|
| 2983 |
+
}
|
| 2984 |
+
},
|
| 2985 |
+
"additionalProperties": false,
|
| 2986 |
+
"type": "object",
|
| 2987 |
+
"title": "SiteOptions"
|
| 2988 |
+
},
|
| 2989 |
"SpeechRequest": {
|
| 2990 |
"properties": {
|
| 2991 |
"model": {
|
|
|
|
| 3124 |
}
|
| 3125 |
}
|
| 3126 |
}
|
| 3127 |
+
}
|
frontend/app/admin-panel.tsx
ADDED
|
@@ -0,0 +1,620 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
import { FormEvent, useCallback, useEffect, useState } from "react";
|
| 3 |
+
import {
|
| 4 |
+
ShieldCheck,
|
| 5 |
+
RefreshCw,
|
| 6 |
+
Plus,
|
| 7 |
+
Users,
|
| 8 |
+
Globe,
|
| 9 |
+
Settings2,
|
| 10 |
+
} from "lucide-react";
|
| 11 |
+
import { api, loadAllModels, Model, money, User } from "@/lib/api";
|
| 12 |
+
import { SiteOptions } from "@/lib/site";
|
| 13 |
+
import { Badge, Banner, Modal, PageHeading } from "./ui";
|
| 14 |
+
import ModelPicker from "./model-picker";
|
| 15 |
+
|
| 16 |
+
type AdminSettings = SiteOptions & {
|
| 17 |
+
registration_open: boolean;
|
| 18 |
+
requests_per_minute: number;
|
| 19 |
+
shared_requests_per_minute: number;
|
| 20 |
+
};
|
| 21 |
+
type Published = {
|
| 22 |
+
id: string;
|
| 23 |
+
name: string;
|
| 24 |
+
model_id: string;
|
| 25 |
+
provider_name: string;
|
| 26 |
+
enabled: boolean;
|
| 27 |
+
requests_per_minute: number;
|
| 28 |
+
max_output_tokens: number;
|
| 29 |
+
owned: boolean;
|
| 30 |
+
};
|
| 31 |
+
type Account = User & { disabled: boolean; created_at: number };
|
| 32 |
+
type Summary = {
|
| 33 |
+
users: number;
|
| 34 |
+
suspended_users: number;
|
| 35 |
+
requests: number;
|
| 36 |
+
published_models: number;
|
| 37 |
+
sponsored_cost: number | null;
|
| 38 |
+
};
|
| 39 |
+
|
| 40 |
+
export default function AdminPanel({
|
| 41 |
+
user,
|
| 42 |
+
onSettings,
|
| 43 |
+
onConnect,
|
| 44 |
+
onRegisterModel,
|
| 45 |
+
notify,
|
| 46 |
+
revision = 0,
|
| 47 |
+
}: {
|
| 48 |
+
user: User;
|
| 49 |
+
onSettings: (settings: SiteOptions) => void;
|
| 50 |
+
onConnect: () => void;
|
| 51 |
+
onRegisterModel: () => void;
|
| 52 |
+
notify: (message: string) => void;
|
| 53 |
+
revision?: number;
|
| 54 |
+
}) {
|
| 55 |
+
const [settings, setSettings] = useState<AdminSettings | null>(null);
|
| 56 |
+
const [limits, setLimits] = useState({
|
| 57 |
+
requests_per_minute: 60,
|
| 58 |
+
max_retries: 2,
|
| 59 |
+
registration_allowed: true,
|
| 60 |
+
});
|
| 61 |
+
const [summary, setSummary] = useState<Summary | null>(null);
|
| 62 |
+
const [models, setModels] = useState<Model[]>([]);
|
| 63 |
+
const [published, setPublished] = useState<Published[]>([]);
|
| 64 |
+
const [selected, setSelected] = useState<Model | null>(null);
|
| 65 |
+
const [quota, setQuota] = useState(5);
|
| 66 |
+
const [maxTokens, setMaxTokens] = useState(1024);
|
| 67 |
+
const [loading, setLoading] = useState(true);
|
| 68 |
+
const [busy, setBusy] = useState(false);
|
| 69 |
+
const [error, setError] = useState("");
|
| 70 |
+
const [users, setUsers] = useState<Account[]>([]);
|
| 71 |
+
const [totalUsers, setTotalUsers] = useState(0);
|
| 72 |
+
const [search, setSearch] = useState("");
|
| 73 |
+
const [offset, setOffset] = useState(0);
|
| 74 |
+
const [userRevision, setUserRevision] = useState(0);
|
| 75 |
+
const [target, setTarget] = useState<Account | null>(null);
|
| 76 |
+
const [confirmError, setConfirmError] = useState("");
|
| 77 |
+
const load = useCallback(async () => {
|
| 78 |
+
setLoading(true);
|
| 79 |
+
setError("");
|
| 80 |
+
try {
|
| 81 |
+
const [config, stats, shared, catalog] = await Promise.all([
|
| 82 |
+
api<{ settings: AdminSettings; limits: typeof limits }>(
|
| 83 |
+
"/admin/settings",
|
| 84 |
+
),
|
| 85 |
+
api<Summary>("/admin/summary"),
|
| 86 |
+
api<Published[]>("/admin/shared-models"),
|
| 87 |
+
loadAllModels(undefined, true),
|
| 88 |
+
]);
|
| 89 |
+
setSettings(config.settings);
|
| 90 |
+
setLimits(config.limits);
|
| 91 |
+
setSummary(stats);
|
| 92 |
+
setPublished(shared);
|
| 93 |
+
setModels(catalog);
|
| 94 |
+
} catch (e) {
|
| 95 |
+
setError((e as Error).message);
|
| 96 |
+
} finally {
|
| 97 |
+
setLoading(false);
|
| 98 |
+
}
|
| 99 |
+
}, []);
|
| 100 |
+
useEffect(() => {
|
| 101 |
+
void load();
|
| 102 |
+
}, [load, revision]);
|
| 103 |
+
useEffect(() => {
|
| 104 |
+
const pending = new AbortController();
|
| 105 |
+
const timer = setTimeout(() => {
|
| 106 |
+
void api<{ data: Account[]; total: number }>(
|
| 107 |
+
`/admin/users?search=${encodeURIComponent(search)}&offset=${offset}`,
|
| 108 |
+
{ signal: pending.signal },
|
| 109 |
+
)
|
| 110 |
+
.then((page) => {
|
| 111 |
+
if (!pending.signal.aborted) {
|
| 112 |
+
setUsers(page.data);
|
| 113 |
+
setTotalUsers(page.total);
|
| 114 |
+
}
|
| 115 |
+
})
|
| 116 |
+
.catch((e: Error) => {
|
| 117 |
+
if (!pending.signal.aborted) setError(e.message);
|
| 118 |
+
});
|
| 119 |
+
}, 200);
|
| 120 |
+
return () => {
|
| 121 |
+
clearTimeout(timer);
|
| 122 |
+
pending.abort();
|
| 123 |
+
};
|
| 124 |
+
}, [search, offset, userRevision]);
|
| 125 |
+
async function saveSettings(e: FormEvent) {
|
| 126 |
+
e.preventDefault();
|
| 127 |
+
if (!settings) return;
|
| 128 |
+
setBusy(true);
|
| 129 |
+
setError("");
|
| 130 |
+
try {
|
| 131 |
+
const saved = await api<AdminSettings>("/admin/settings", {
|
| 132 |
+
method: "PUT",
|
| 133 |
+
body: JSON.stringify(settings),
|
| 134 |
+
});
|
| 135 |
+
onSettings(saved);
|
| 136 |
+
notify("Site settings saved");
|
| 137 |
+
} catch (e) {
|
| 138 |
+
setError((e as Error).message);
|
| 139 |
+
} finally {
|
| 140 |
+
setBusy(false);
|
| 141 |
+
}
|
| 142 |
+
}
|
| 143 |
+
async function publish(
|
| 144 |
+
id: string,
|
| 145 |
+
enabled: boolean,
|
| 146 |
+
rpm = quota,
|
| 147 |
+
tokens = maxTokens,
|
| 148 |
+
) {
|
| 149 |
+
setBusy(true);
|
| 150 |
+
setError("");
|
| 151 |
+
try {
|
| 152 |
+
await api(`/admin/shared-models/${id}`, {
|
| 153 |
+
method: "PUT",
|
| 154 |
+
body: JSON.stringify({
|
| 155 |
+
enabled,
|
| 156 |
+
requests_per_minute: rpm,
|
| 157 |
+
max_output_tokens: tokens,
|
| 158 |
+
}),
|
| 159 |
+
});
|
| 160 |
+
notify(
|
| 161 |
+
enabled
|
| 162 |
+
? "Model published for all signed-in users"
|
| 163 |
+
: "Community model disabled",
|
| 164 |
+
);
|
| 165 |
+
await load();
|
| 166 |
+
} catch (e) {
|
| 167 |
+
setError((e as Error).message);
|
| 168 |
+
} finally {
|
| 169 |
+
setBusy(false);
|
| 170 |
+
}
|
| 171 |
+
}
|
| 172 |
+
async function changeAccount() {
|
| 173 |
+
if (!target) return;
|
| 174 |
+
setBusy(true);
|
| 175 |
+
setConfirmError("");
|
| 176 |
+
try {
|
| 177 |
+
await api(`/admin/users/${target.id}`, {
|
| 178 |
+
method: "PATCH",
|
| 179 |
+
body: JSON.stringify({ disabled: !target.disabled }),
|
| 180 |
+
});
|
| 181 |
+
setTarget(null);
|
| 182 |
+
setUserRevision((v) => v + 1);
|
| 183 |
+
notify(target.disabled ? "Account restored" : "Account suspended");
|
| 184 |
+
setSummary(await api<Summary>("/admin/summary"));
|
| 185 |
+
} catch (e) {
|
| 186 |
+
setConfirmError((e as Error).message);
|
| 187 |
+
} finally {
|
| 188 |
+
setBusy(false);
|
| 189 |
+
}
|
| 190 |
+
}
|
| 191 |
+
function update<K extends keyof AdminSettings>(
|
| 192 |
+
key: K,
|
| 193 |
+
value: AdminSettings[K],
|
| 194 |
+
) {
|
| 195 |
+
setSettings((current) =>
|
| 196 |
+
current ? { ...current, [key]: value } : current,
|
| 197 |
+
);
|
| 198 |
+
}
|
| 199 |
+
return (
|
| 200 |
+
<>
|
| 201 |
+
<PageHeading
|
| 202 |
+
title="Administration"
|
| 203 |
+
subtitle="Manage your site, users, and community models."
|
| 204 |
+
action={
|
| 205 |
+
<Badge tone="purple">
|
| 206 |
+
<ShieldCheck size={14} />
|
| 207 |
+
Administrator
|
| 208 |
+
</Badge>
|
| 209 |
+
}
|
| 210 |
+
/>
|
| 211 |
+
<div className="admin-access-note">
|
| 212 |
+
<ShieldCheck size={17} />
|
| 213 |
+
<span>
|
| 214 |
+
Signed in as {user.email}. Administrator roles are managed on the
|
| 215 |
+
server.
|
| 216 |
+
</span>
|
| 217 |
+
</div>
|
| 218 |
+
{error && <Banner tone="error">{error}</Banner>}
|
| 219 |
+
{loading && <p aria-live="polite">Loading administration…</p>}
|
| 220 |
+
{summary && (
|
| 221 |
+
<div className="admin-stats">
|
| 222 |
+
<div className="panel">
|
| 223 |
+
<span>Accounts</span>
|
| 224 |
+
<strong>{summary.users}</strong>
|
| 225 |
+
<small>{summary.suspended_users} suspended</small>
|
| 226 |
+
</div>
|
| 227 |
+
<div className="panel">
|
| 228 |
+
<span>Gateway requests</span>
|
| 229 |
+
<strong>{summary.requests}</strong>
|
| 230 |
+
<small>Across all accounts</small>
|
| 231 |
+
</div>
|
| 232 |
+
<div className="panel">
|
| 233 |
+
<span>Published models</span>
|
| 234 |
+
<strong>{summary.published_models}</strong>
|
| 235 |
+
<small>Only enabled publications</small>
|
| 236 |
+
</div>
|
| 237 |
+
<div className="panel">
|
| 238 |
+
<span>Sponsored usage</span>
|
| 239 |
+
<strong>{money(summary.sponsored_cost)}</strong>
|
| 240 |
+
<small>Estimated upstream token cost</small>
|
| 241 |
+
</div>
|
| 242 |
+
</div>
|
| 243 |
+
)}
|
| 244 |
+
<div className="admin-grid">
|
| 245 |
+
{settings && (
|
| 246 |
+
<section className="panel admin-section">
|
| 247 |
+
<div className="panel-heading">
|
| 248 |
+
<h2>
|
| 249 |
+
<Settings2 size={18} />
|
| 250 |
+
Site and service settings
|
| 251 |
+
</h2>
|
| 252 |
+
</div>
|
| 253 |
+
<form className="form admin-form" onSubmit={saveSettings}>
|
| 254 |
+
<label>
|
| 255 |
+
Site name
|
| 256 |
+
<input
|
| 257 |
+
maxLength={40}
|
| 258 |
+
required
|
| 259 |
+
value={settings.site_name}
|
| 260 |
+
onChange={(e) => update("site_name", e.target.value)}
|
| 261 |
+
/>
|
| 262 |
+
</label>
|
| 263 |
+
<label>
|
| 264 |
+
Tagline
|
| 265 |
+
<input
|
| 266 |
+
maxLength={160}
|
| 267 |
+
required
|
| 268 |
+
value={settings.tagline}
|
| 269 |
+
onChange={(e) => update("tagline", e.target.value)}
|
| 270 |
+
/>
|
| 271 |
+
</label>
|
| 272 |
+
<label>
|
| 273 |
+
Welcome text
|
| 274 |
+
<textarea
|
| 275 |
+
maxLength={180}
|
| 276 |
+
required
|
| 277 |
+
value={settings.welcome_text}
|
| 278 |
+
onChange={(e) => update("welcome_text", e.target.value)}
|
| 279 |
+
/>
|
| 280 |
+
</label>
|
| 281 |
+
<label>
|
| 282 |
+
Accent color
|
| 283 |
+
<select
|
| 284 |
+
value={settings.accent}
|
| 285 |
+
onChange={(e) =>
|
| 286 |
+
update("accent", e.target.value as SiteOptions["accent"])
|
| 287 |
+
}
|
| 288 |
+
>
|
| 289 |
+
<option value="purple">Purple</option>
|
| 290 |
+
<option value="blue">Blue</option>
|
| 291 |
+
<option value="green">Green</option>
|
| 292 |
+
</select>
|
| 293 |
+
</label>
|
| 294 |
+
<label>
|
| 295 |
+
Default routing
|
| 296 |
+
<select
|
| 297 |
+
value={settings.default_mode}
|
| 298 |
+
onChange={(e) => update("default_mode", e.target.value)}
|
| 299 |
+
>
|
| 300 |
+
{[
|
| 301 |
+
"auto",
|
| 302 |
+
"fastest",
|
| 303 |
+
"cheapest",
|
| 304 |
+
"reasoning",
|
| 305 |
+
"coding",
|
| 306 |
+
"vision",
|
| 307 |
+
].map((mode) => (
|
| 308 |
+
<option key={mode} value={mode}>
|
| 309 |
+
{mode}
|
| 310 |
+
</option>
|
| 311 |
+
))}
|
| 312 |
+
</select>
|
| 313 |
+
</label>
|
| 314 |
+
<label>
|
| 315 |
+
Default retry budget
|
| 316 |
+
<input
|
| 317 |
+
type="number"
|
| 318 |
+
min={0}
|
| 319 |
+
max={limits.max_retries}
|
| 320 |
+
required
|
| 321 |
+
value={settings.default_retries}
|
| 322 |
+
onChange={(e) =>
|
| 323 |
+
update("default_retries", Number(e.target.value))
|
| 324 |
+
}
|
| 325 |
+
/>
|
| 326 |
+
</label>
|
| 327 |
+
<label className="check-label">
|
| 328 |
+
<input
|
| 329 |
+
type="checkbox"
|
| 330 |
+
checked={settings.default_stream}
|
| 331 |
+
onChange={(e) => update("default_stream", e.target.checked)}
|
| 332 |
+
/>
|
| 333 |
+
Stream responses by default
|
| 334 |
+
</label>
|
| 335 |
+
<label>
|
| 336 |
+
Requests per minute per account
|
| 337 |
+
<input
|
| 338 |
+
type="number"
|
| 339 |
+
min={1}
|
| 340 |
+
max={limits.requests_per_minute}
|
| 341 |
+
required
|
| 342 |
+
value={settings.requests_per_minute}
|
| 343 |
+
onChange={(e) =>
|
| 344 |
+
update("requests_per_minute", Number(e.target.value))
|
| 345 |
+
}
|
| 346 |
+
/>
|
| 347 |
+
</label>
|
| 348 |
+
<small className="form-note">
|
| 349 |
+
Server maximum: {limits.requests_per_minute} requests/minute and{" "}
|
| 350 |
+
{limits.max_retries} retries.
|
| 351 |
+
</small>
|
| 352 |
+
<label className="check-label">
|
| 353 |
+
<input
|
| 354 |
+
type="checkbox"
|
| 355 |
+
disabled={!limits.registration_allowed}
|
| 356 |
+
checked={settings.registration_open}
|
| 357 |
+
onChange={(e) =>
|
| 358 |
+
update("registration_open", e.target.checked)
|
| 359 |
+
}
|
| 360 |
+
/>
|
| 361 |
+
Allow new registrations
|
| 362 |
+
</label>
|
| 363 |
+
{!limits.registration_allowed && (
|
| 364 |
+
<small>
|
| 365 |
+
Registration is closed in the server configuration.
|
| 366 |
+
</small>
|
| 367 |
+
)}
|
| 368 |
+
<label className="check-label">
|
| 369 |
+
<input
|
| 370 |
+
type="checkbox"
|
| 371 |
+
checked={settings.shared_models_enabled}
|
| 372 |
+
onChange={(e) =>
|
| 373 |
+
update("shared_models_enabled", e.target.checked)
|
| 374 |
+
}
|
| 375 |
+
/>
|
| 376 |
+
Enable community models
|
| 377 |
+
</label>
|
| 378 |
+
<label>
|
| 379 |
+
Community requests per minute across the whole site
|
| 380 |
+
<input
|
| 381 |
+
type="number"
|
| 382 |
+
min={1}
|
| 383 |
+
max={10000}
|
| 384 |
+
required
|
| 385 |
+
value={settings.shared_requests_per_minute}
|
| 386 |
+
onChange={(e) =>
|
| 387 |
+
update("shared_requests_per_minute", Number(e.target.value))
|
| 388 |
+
}
|
| 389 |
+
/>
|
| 390 |
+
</label>
|
| 391 |
+
<button className="button primary" disabled={busy || loading}>
|
| 392 |
+
Save site settings
|
| 393 |
+
</button>
|
| 394 |
+
</form>
|
| 395 |
+
</section>
|
| 396 |
+
)}
|
| 397 |
+
<section className="panel admin-section">
|
| 398 |
+
<div className="panel-heading">
|
| 399 |
+
<h2>
|
| 400 |
+
<Globe size={18} />
|
| 401 |
+
Community models
|
| 402 |
+
</h2>
|
| 403 |
+
</div>
|
| 404 |
+
<div className="form admin-form">
|
| 405 |
+
<p>
|
| 406 |
+
Publish a model from your own provider connection. Every signed-in
|
| 407 |
+
user can use it free of charge through the Playground or their
|
| 408 |
+
Gateway API key. You cover the upstream provider cost.
|
| 409 |
+
</p>
|
| 410 |
+
<div className="admin-setup-actions">
|
| 411 |
+
<button className="button" onClick={onConnect}>
|
| 412 |
+
<Plus size={15} />
|
| 413 |
+
Connect provider
|
| 414 |
+
</button>
|
| 415 |
+
<button className="button" onClick={onRegisterModel}>
|
| 416 |
+
Register your model
|
| 417 |
+
</button>
|
| 418 |
+
</div>
|
| 419 |
+
<ModelPicker
|
| 420 |
+
label="Community model"
|
| 421 |
+
models={models}
|
| 422 |
+
selected={selected}
|
| 423 |
+
onSelect={setSelected}
|
| 424 |
+
allowAuto={false}
|
| 425 |
+
loading={loading}
|
| 426 |
+
onRefresh={() => void load()}
|
| 427 |
+
disabled={busy}
|
| 428 |
+
/>
|
| 429 |
+
<label>
|
| 430 |
+
Requests per minute per user
|
| 431 |
+
<input
|
| 432 |
+
type="number"
|
| 433 |
+
min={1}
|
| 434 |
+
max={120}
|
| 435 |
+
value={quota}
|
| 436 |
+
onChange={(e) => setQuota(Number(e.target.value))}
|
| 437 |
+
/>
|
| 438 |
+
</label>
|
| 439 |
+
<label>
|
| 440 |
+
Maximum output tokens per request
|
| 441 |
+
<input
|
| 442 |
+
type="number"
|
| 443 |
+
min={128}
|
| 444 |
+
max={8192}
|
| 445 |
+
value={maxTokens}
|
| 446 |
+
onChange={(e) => setMaxTokens(Number(e.target.value))}
|
| 447 |
+
/>
|
| 448 |
+
</label>
|
| 449 |
+
<button
|
| 450 |
+
className="button primary"
|
| 451 |
+
disabled={
|
| 452 |
+
!selected ||
|
| 453 |
+
busy ||
|
| 454 |
+
quota < 1 ||
|
| 455 |
+
quota > 120 ||
|
| 456 |
+
maxTokens < 128 ||
|
| 457 |
+
maxTokens > 8192
|
| 458 |
+
}
|
| 459 |
+
onClick={() => selected && void publish(selected.id, true)}
|
| 460 |
+
>
|
| 461 |
+
Publish free model
|
| 462 |
+
</button>
|
| 463 |
+
<p className="form-note">
|
| 464 |
+
Only the selected model is shared. Provider credentials remain
|
| 465 |
+
encrypted. Community routes support chat, enforce token and
|
| 466 |
+
request limits, and stop accepting new requests when disabled.
|
| 467 |
+
</p>
|
| 468 |
+
<div className="published-models">
|
| 469 |
+
{published.map((model) => (
|
| 470 |
+
<div className="published-model" key={model.id}>
|
| 471 |
+
<div>
|
| 472 |
+
<strong>{model.name}</strong>
|
| 473 |
+
<small className="mono">{model.model_id}</small>
|
| 474 |
+
<small>
|
| 475 |
+
{model.requests_per_minute} requests/min/user ·{" "}
|
| 476 |
+
{model.max_output_tokens} output tokens
|
| 477 |
+
</small>
|
| 478 |
+
</div>
|
| 479 |
+
<Badge tone={model.enabled ? "green" : "neutral"}>
|
| 480 |
+
{model.enabled ? "Published" : "Disabled"}
|
| 481 |
+
</Badge>
|
| 482 |
+
<button
|
| 483 |
+
className="button"
|
| 484 |
+
disabled={busy || !model.owned}
|
| 485 |
+
onClick={() =>
|
| 486 |
+
void publish(
|
| 487 |
+
model.id,
|
| 488 |
+
!model.enabled,
|
| 489 |
+
model.requests_per_minute,
|
| 490 |
+
model.max_output_tokens,
|
| 491 |
+
)
|
| 492 |
+
}
|
| 493 |
+
>
|
| 494 |
+
{model.enabled ? "Disable" : "Enable"}
|
| 495 |
+
</button>
|
| 496 |
+
</div>
|
| 497 |
+
))}
|
| 498 |
+
</div>
|
| 499 |
+
{!published.length && <p>No community models published yet.</p>}
|
| 500 |
+
</div>
|
| 501 |
+
</section>
|
| 502 |
+
</div>
|
| 503 |
+
<section className="panel admin-section admin-users">
|
| 504 |
+
<div className="panel-heading">
|
| 505 |
+
<h2>
|
| 506 |
+
<Users size={18} />
|
| 507 |
+
Accounts
|
| 508 |
+
</h2>
|
| 509 |
+
<button
|
| 510 |
+
className="text-button"
|
| 511 |
+
onClick={() => setUserRevision((v) => v + 1)}
|
| 512 |
+
>
|
| 513 |
+
<RefreshCw size={14} />
|
| 514 |
+
Refresh accounts
|
| 515 |
+
</button>
|
| 516 |
+
</div>
|
| 517 |
+
<div className="admin-form">
|
| 518 |
+
<label className="search-field">
|
| 519 |
+
<input
|
| 520 |
+
aria-label="Search accounts by email"
|
| 521 |
+
placeholder="Search accounts by email…"
|
| 522 |
+
value={search}
|
| 523 |
+
onChange={(e) => {
|
| 524 |
+
setSearch(e.target.value);
|
| 525 |
+
setOffset(0);
|
| 526 |
+
}}
|
| 527 |
+
/>
|
| 528 |
+
</label>
|
| 529 |
+
</div>
|
| 530 |
+
<div className="table-scroll">
|
| 531 |
+
<table>
|
| 532 |
+
<thead>
|
| 533 |
+
<tr>
|
| 534 |
+
<th>Account</th>
|
| 535 |
+
<th>Role</th>
|
| 536 |
+
<th>Status</th>
|
| 537 |
+
<th>Access</th>
|
| 538 |
+
</tr>
|
| 539 |
+
</thead>
|
| 540 |
+
<tbody>
|
| 541 |
+
{users.map((account) => (
|
| 542 |
+
<tr key={account.id}>
|
| 543 |
+
<td>
|
| 544 |
+
<strong>{account.name}</strong>
|
| 545 |
+
<br />
|
| 546 |
+
<small>{account.email}</small>
|
| 547 |
+
</td>
|
| 548 |
+
<td>{account.is_admin ? "Administrator" : "Member"}</td>
|
| 549 |
+
<td>{account.disabled ? "Suspended" : "Active"}</td>
|
| 550 |
+
<td>
|
| 551 |
+
{account.is_admin || account.id === user.id ? (
|
| 552 |
+
<span className="muted">Managed on server</span>
|
| 553 |
+
) : (
|
| 554 |
+
<button
|
| 555 |
+
className="text-button"
|
| 556 |
+
disabled={busy}
|
| 557 |
+
onClick={() => {
|
| 558 |
+
setTarget(account);
|
| 559 |
+
setConfirmError("");
|
| 560 |
+
}}
|
| 561 |
+
>
|
| 562 |
+
{account.disabled
|
| 563 |
+
? "Restore access"
|
| 564 |
+
: "Suspend account"}
|
| 565 |
+
</button>
|
| 566 |
+
)}
|
| 567 |
+
</td>
|
| 568 |
+
</tr>
|
| 569 |
+
))}
|
| 570 |
+
</tbody>
|
| 571 |
+
</table>
|
| 572 |
+
</div>
|
| 573 |
+
<div className="table-footer">
|
| 574 |
+
<span>{totalUsers} accounts</span>
|
| 575 |
+
<button
|
| 576 |
+
className="button"
|
| 577 |
+
disabled={offset === 0}
|
| 578 |
+
onClick={() => setOffset((v) => Math.max(0, v - 50))}
|
| 579 |
+
>
|
| 580 |
+
Previous
|
| 581 |
+
</button>
|
| 582 |
+
<button
|
| 583 |
+
className="button"
|
| 584 |
+
disabled={offset + 50 >= totalUsers}
|
| 585 |
+
onClick={() => setOffset((v) => v + 50)}
|
| 586 |
+
>
|
| 587 |
+
Next
|
| 588 |
+
</button>
|
| 589 |
+
</div>
|
| 590 |
+
</section>
|
| 591 |
+
{target && (
|
| 592 |
+
<Modal
|
| 593 |
+
title={
|
| 594 |
+
target.disabled ? "Restore account access?" : "Suspend account?"
|
| 595 |
+
}
|
| 596 |
+
onClose={() => {
|
| 597 |
+
if (!busy) setTarget(null);
|
| 598 |
+
}}
|
| 599 |
+
>
|
| 600 |
+
<div className="modal-body form">
|
| 601 |
+
<p>{target.email}</p>
|
| 602 |
+
<p>
|
| 603 |
+
{target.disabled
|
| 604 |
+
? "This member will be able to sign in and use their existing API keys again."
|
| 605 |
+
: "This ends the member's sessions and blocks new requests from their API keys. Their data is retained."}
|
| 606 |
+
</p>
|
| 607 |
+
{confirmError && <Banner tone="error">{confirmError}</Banner>}
|
| 608 |
+
<button
|
| 609 |
+
className="button primary"
|
| 610 |
+
disabled={busy}
|
| 611 |
+
onClick={() => void changeAccount()}
|
| 612 |
+
>
|
| 613 |
+
{target.disabled ? "Restore account" : "Confirm suspension"}
|
| 614 |
+
</button>
|
| 615 |
+
</div>
|
| 616 |
+
</Modal>
|
| 617 |
+
)}
|
| 618 |
+
</>
|
| 619 |
+
);
|
| 620 |
+
}
|
frontend/app/forms.tsx
CHANGED
|
@@ -9,13 +9,16 @@ import {
|
|
| 9 |
} from "lucide-react";
|
| 10 |
import { api, User, Provider, Model, providerInfo } from "@/lib/api";
|
| 11 |
import { Banner, CheckRow, Modal } from "./ui";
|
|
|
|
| 12 |
|
| 13 |
export function AuthForm({
|
| 14 |
onClose,
|
| 15 |
onSuccess,
|
|
|
|
| 16 |
}: {
|
| 17 |
onClose: () => void;
|
| 18 |
onSuccess: (user: User, key?: string) => void;
|
|
|
|
| 19 |
}) {
|
| 20 |
const [register, setRegister] = useState(false),
|
| 21 |
[busy, setBusy] = useState(false),
|
|
@@ -46,14 +49,10 @@ export function AuthForm({
|
|
| 46 |
>
|
| 47 |
<div className="auth-layout">
|
| 48 |
<div className="auth-aside">
|
| 49 |
-
<div className="brand-mark large">
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
Your keys.
|
| 54 |
-
<br />
|
| 55 |
-
<span>One connection.</span>
|
| 56 |
-
</h2>
|
| 57 |
<p>Bring your providers together in a workspace you control.</p>
|
| 58 |
<div className="auth-checks">
|
| 59 |
<CheckRow>Encrypted provider credentials</CheckRow>
|
|
|
|
| 9 |
} from "lucide-react";
|
| 10 |
import { api, User, Provider, Model, providerInfo } from "@/lib/api";
|
| 11 |
import { Banner, CheckRow, Modal } from "./ui";
|
| 12 |
+
import { defaultSite, SiteOptions } from "@/lib/site";
|
| 13 |
|
| 14 |
export function AuthForm({
|
| 15 |
onClose,
|
| 16 |
onSuccess,
|
| 17 |
+
site = defaultSite,
|
| 18 |
}: {
|
| 19 |
onClose: () => void;
|
| 20 |
onSuccess: (user: User, key?: string) => void;
|
| 21 |
+
site?: SiteOptions;
|
| 22 |
}) {
|
| 23 |
const [register, setRegister] = useState(false),
|
| 24 |
[busy, setBusy] = useState(false),
|
|
|
|
| 49 |
>
|
| 50 |
<div className="auth-layout">
|
| 51 |
<div className="auth-aside">
|
| 52 |
+
<div className="brand-mark large">
|
| 53 |
+
{site.site_name.slice(0, 1).toUpperCase()}
|
| 54 |
+
</div>
|
| 55 |
+
<h2>{site.welcome_text}</h2>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
<p>Bring your providers together in a workspace you control.</p>
|
| 57 |
<div className="auth-checks">
|
| 58 |
<CheckRow>Encrypted provider credentials</CheckRow>
|
frontend/app/globals.css
CHANGED
|
@@ -2,6 +2,23 @@
|
|
| 2 |
@import "./tokens.css";
|
| 3 |
@source not "../tests";
|
| 4 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
/* Foundations */
|
| 6 |
* {
|
| 7 |
box-sizing: border-box;
|
|
@@ -2742,3 +2759,453 @@ tbody tr:hover {
|
|
| 2742 |
scroll-behavior: auto !important;
|
| 2743 |
}
|
| 2744 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
@import "./tokens.css";
|
| 3 |
@source not "../tests";
|
| 4 |
|
| 5 |
+
.app-shell[data-accent="blue"] {
|
| 6 |
+
--accent: #89baff;
|
| 7 |
+
--accent-hover: #b6d4ff;
|
| 8 |
+
--accent-pressed: #77a9ef;
|
| 9 |
+
--accent-tint: rgb(137 186 255 / 12%);
|
| 10 |
+
--accent-border: rgb(137 186 255 / 28%);
|
| 11 |
+
--ring: 0 0 0 3px rgb(137 186 255 / 30%);
|
| 12 |
+
}
|
| 13 |
+
.app-shell[data-accent="green"] {
|
| 14 |
+
--accent: #76dbb0;
|
| 15 |
+
--accent-hover: #a0edcc;
|
| 16 |
+
--accent-pressed: #62c99d;
|
| 17 |
+
--accent-tint: rgb(118 219 176 / 12%);
|
| 18 |
+
--accent-border: rgb(118 219 176 / 28%);
|
| 19 |
+
--ring: 0 0 0 3px rgb(118 219 176 / 30%);
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
/* Foundations */
|
| 23 |
* {
|
| 24 |
box-sizing: border-box;
|
|
|
|
| 2759 |
scroll-behavior: auto !important;
|
| 2760 |
}
|
| 2761 |
}
|
| 2762 |
+
|
| 2763 |
+
/* Full catalog, readable responses, and restricted administration. */
|
| 2764 |
+
.initial-loading {
|
| 2765 |
+
max-width: 900px;
|
| 2766 |
+
margin: 12vh auto;
|
| 2767 |
+
padding: 24px;
|
| 2768 |
+
}
|
| 2769 |
+
.workspace-notice,
|
| 2770 |
+
.onboarding {
|
| 2771 |
+
display: flex;
|
| 2772 |
+
align-items: center;
|
| 2773 |
+
gap: 16px;
|
| 2774 |
+
padding: 18px 22px;
|
| 2775 |
+
margin-bottom: 24px;
|
| 2776 |
+
flex-wrap: wrap;
|
| 2777 |
+
}
|
| 2778 |
+
.workspace-notice {
|
| 2779 |
+
background: var(--accent-tint);
|
| 2780 |
+
border: 1px solid var(--accent-border);
|
| 2781 |
+
border-radius: 12px;
|
| 2782 |
+
color: var(--text-2);
|
| 2783 |
+
}
|
| 2784 |
+
.workspace-notice > span,
|
| 2785 |
+
.onboarding > div {
|
| 2786 |
+
flex: 1;
|
| 2787 |
+
min-width: 200px;
|
| 2788 |
+
}
|
| 2789 |
+
.onboarding h2 {
|
| 2790 |
+
font-size: 17px;
|
| 2791 |
+
margin: 0 0 4px;
|
| 2792 |
+
}
|
| 2793 |
+
.onboarding p {
|
| 2794 |
+
margin: 0;
|
| 2795 |
+
color: var(--text-2);
|
| 2796 |
+
}
|
| 2797 |
+
.health-explanation {
|
| 2798 |
+
color: var(--text-2);
|
| 2799 |
+
margin: 12px 0 20px;
|
| 2800 |
+
font-size: 13px;
|
| 2801 |
+
}
|
| 2802 |
+
.model-picker {
|
| 2803 |
+
min-width: 0;
|
| 2804 |
+
display: flex;
|
| 2805 |
+
flex-direction: column;
|
| 2806 |
+
gap: 7px;
|
| 2807 |
+
}
|
| 2808 |
+
.field-label {
|
| 2809 |
+
font-weight: 500;
|
| 2810 |
+
font-size: 13px;
|
| 2811 |
+
}
|
| 2812 |
+
.model-picker-trigger {
|
| 2813 |
+
display: flex;
|
| 2814 |
+
align-items: center;
|
| 2815 |
+
justify-content: space-between;
|
| 2816 |
+
gap: 12px;
|
| 2817 |
+
text-align: left;
|
| 2818 |
+
width: 100%;
|
| 2819 |
+
padding: 12px;
|
| 2820 |
+
border: 1px solid var(--control-border);
|
| 2821 |
+
border-radius: 8px;
|
| 2822 |
+
background: var(--bg);
|
| 2823 |
+
}
|
| 2824 |
+
.model-picker-trigger > span {
|
| 2825 |
+
min-width: 0;
|
| 2826 |
+
}
|
| 2827 |
+
.model-picker-trigger strong,
|
| 2828 |
+
.model-picker-trigger small {
|
| 2829 |
+
display: block;
|
| 2830 |
+
overflow: hidden;
|
| 2831 |
+
text-overflow: ellipsis;
|
| 2832 |
+
white-space: nowrap;
|
| 2833 |
+
}
|
| 2834 |
+
.model-picker-trigger small,
|
| 2835 |
+
.selected-route {
|
| 2836 |
+
color: var(--text-2);
|
| 2837 |
+
font-size: 11px;
|
| 2838 |
+
overflow-wrap: anywhere;
|
| 2839 |
+
}
|
| 2840 |
+
.model-picker-trigger:hover {
|
| 2841 |
+
border-color: var(--accent);
|
| 2842 |
+
}
|
| 2843 |
+
.model-picker-body {
|
| 2844 |
+
padding: 20px 24px;
|
| 2845 |
+
}
|
| 2846 |
+
.model-picker-body .search-field {
|
| 2847 |
+
width: 100%;
|
| 2848 |
+
}
|
| 2849 |
+
.model-picker-toolbar {
|
| 2850 |
+
display: flex;
|
| 2851 |
+
justify-content: space-between;
|
| 2852 |
+
align-items: center;
|
| 2853 |
+
gap: 12px;
|
| 2854 |
+
padding: 14px 0 6px;
|
| 2855 |
+
}
|
| 2856 |
+
.check-label {
|
| 2857 |
+
display: flex !important;
|
| 2858 |
+
flex-direction: row !important;
|
| 2859 |
+
align-items: center;
|
| 2860 |
+
gap: 9px !important;
|
| 2861 |
+
font-size: 13px;
|
| 2862 |
+
}
|
| 2863 |
+
.check-label input {
|
| 2864 |
+
width: 16px;
|
| 2865 |
+
height: 16px;
|
| 2866 |
+
min-height: 16px;
|
| 2867 |
+
padding: 0;
|
| 2868 |
+
accent-color: var(--accent);
|
| 2869 |
+
flex: none;
|
| 2870 |
+
}
|
| 2871 |
+
.model-picker-results {
|
| 2872 |
+
max-height: min(52dvh, 480px);
|
| 2873 |
+
overflow-y: auto;
|
| 2874 |
+
overscroll-behavior: contain;
|
| 2875 |
+
border: 1px solid var(--border-strong);
|
| 2876 |
+
border-radius: 10px;
|
| 2877 |
+
margin-top: 14px;
|
| 2878 |
+
}
|
| 2879 |
+
.model-option {
|
| 2880 |
+
width: 100%;
|
| 2881 |
+
display: flex;
|
| 2882 |
+
align-items: center;
|
| 2883 |
+
justify-content: space-between;
|
| 2884 |
+
gap: 16px;
|
| 2885 |
+
padding: 14px 16px;
|
| 2886 |
+
text-align: left;
|
| 2887 |
+
border-bottom: 1px solid var(--border);
|
| 2888 |
+
}
|
| 2889 |
+
.model-option:last-child {
|
| 2890 |
+
border-bottom: 0;
|
| 2891 |
+
}
|
| 2892 |
+
.model-option > span:first-child {
|
| 2893 |
+
min-width: 0;
|
| 2894 |
+
}
|
| 2895 |
+
.model-option strong,
|
| 2896 |
+
.model-option small {
|
| 2897 |
+
display: block;
|
| 2898 |
+
overflow-wrap: anywhere;
|
| 2899 |
+
}
|
| 2900 |
+
.model-option small {
|
| 2901 |
+
color: var(--text-2);
|
| 2902 |
+
font-size: 11px;
|
| 2903 |
+
margin-top: 3px;
|
| 2904 |
+
}
|
| 2905 |
+
.model-option strong {
|
| 2906 |
+
font-size: 14px;
|
| 2907 |
+
}
|
| 2908 |
+
.model-option:hover,
|
| 2909 |
+
.model-option.selected {
|
| 2910 |
+
background: var(--accent-tint);
|
| 2911 |
+
}
|
| 2912 |
+
.model-option.selected {
|
| 2913 |
+
box-shadow: inset 3px 0 var(--accent);
|
| 2914 |
+
}
|
| 2915 |
+
.model-option-price {
|
| 2916 |
+
display: flex;
|
| 2917 |
+
gap: 8px;
|
| 2918 |
+
align-items: center;
|
| 2919 |
+
text-align: right;
|
| 2920 |
+
flex: none;
|
| 2921 |
+
}
|
| 2922 |
+
.model-option-price .badge {
|
| 2923 |
+
white-space: nowrap;
|
| 2924 |
+
}
|
| 2925 |
+
.empty-catalog {
|
| 2926 |
+
padding: 24px;
|
| 2927 |
+
color: var(--text-2);
|
| 2928 |
+
}
|
| 2929 |
+
.response-format {
|
| 2930 |
+
display: flex;
|
| 2931 |
+
gap: 4px;
|
| 2932 |
+
border: 1px solid var(--border);
|
| 2933 |
+
padding: 3px;
|
| 2934 |
+
border-radius: 7px;
|
| 2935 |
+
margin-left: auto;
|
| 2936 |
+
}
|
| 2937 |
+
.response-format button {
|
| 2938 |
+
padding: 3px 9px;
|
| 2939 |
+
font-size: 12px;
|
| 2940 |
+
color: var(--text-2);
|
| 2941 |
+
border-radius: 4px;
|
| 2942 |
+
}
|
| 2943 |
+
.response-format button[aria-pressed="true"] {
|
| 2944 |
+
color: var(--accent);
|
| 2945 |
+
background: var(--accent-tint);
|
| 2946 |
+
}
|
| 2947 |
+
.markdown-output {
|
| 2948 |
+
padding: 22px;
|
| 2949 |
+
font-size: 14px;
|
| 2950 |
+
line-height: 1.8;
|
| 2951 |
+
overflow-wrap: anywhere;
|
| 2952 |
+
}
|
| 2953 |
+
.markdown-output > :first-child {
|
| 2954 |
+
margin-top: 0;
|
| 2955 |
+
}
|
| 2956 |
+
.markdown-output > :last-child {
|
| 2957 |
+
margin-bottom: 0;
|
| 2958 |
+
}
|
| 2959 |
+
.markdown-output h1,
|
| 2960 |
+
.markdown-output h2,
|
| 2961 |
+
.markdown-output h3 {
|
| 2962 |
+
margin: 1.2em 0 0.5em;
|
| 2963 |
+
font-weight: 650;
|
| 2964 |
+
line-height: 1.4;
|
| 2965 |
+
}
|
| 2966 |
+
.markdown-output h1 {
|
| 2967 |
+
font-size: 23px;
|
| 2968 |
+
}
|
| 2969 |
+
.markdown-output h2 {
|
| 2970 |
+
font-size: 20px;
|
| 2971 |
+
}
|
| 2972 |
+
.markdown-output h3 {
|
| 2973 |
+
font-size: 17px;
|
| 2974 |
+
}
|
| 2975 |
+
.markdown-output p {
|
| 2976 |
+
margin: 0.75em 0;
|
| 2977 |
+
}
|
| 2978 |
+
.markdown-output ul,
|
| 2979 |
+
.markdown-output ol {
|
| 2980 |
+
padding-left: 24px;
|
| 2981 |
+
list-style: revert;
|
| 2982 |
+
margin: 0.75em 0;
|
| 2983 |
+
}
|
| 2984 |
+
.markdown-output li {
|
| 2985 |
+
margin: 0.3em 0;
|
| 2986 |
+
}
|
| 2987 |
+
.markdown-output a {
|
| 2988 |
+
color: var(--accent);
|
| 2989 |
+
text-decoration: underline;
|
| 2990 |
+
}
|
| 2991 |
+
.markdown-output blockquote {
|
| 2992 |
+
margin: 16px 0;
|
| 2993 |
+
padding: 2px 16px;
|
| 2994 |
+
border-left: 3px solid var(--accent);
|
| 2995 |
+
color: var(--text-2);
|
| 2996 |
+
}
|
| 2997 |
+
.markdown-output :not(pre) > code {
|
| 2998 |
+
font-family: var(--font-mono);
|
| 2999 |
+
background: var(--raised);
|
| 3000 |
+
padding: 2px 5px;
|
| 3001 |
+
border-radius: 4px;
|
| 3002 |
+
font-size: 0.9em;
|
| 3003 |
+
}
|
| 3004 |
+
.markdown-output table {
|
| 3005 |
+
display: block;
|
| 3006 |
+
max-width: 100%;
|
| 3007 |
+
overflow: auto;
|
| 3008 |
+
border: 1px solid var(--border);
|
| 3009 |
+
}
|
| 3010 |
+
.markdown-output th,
|
| 3011 |
+
.markdown-output td {
|
| 3012 |
+
padding: 10px 14px;
|
| 3013 |
+
}
|
| 3014 |
+
.code-block {
|
| 3015 |
+
border: 1px solid var(--border-strong);
|
| 3016 |
+
border-radius: 10px;
|
| 3017 |
+
overflow: hidden;
|
| 3018 |
+
margin: 16px 0;
|
| 3019 |
+
}
|
| 3020 |
+
.code-toolbar {
|
| 3021 |
+
display: flex;
|
| 3022 |
+
align-items: center;
|
| 3023 |
+
justify-content: space-between;
|
| 3024 |
+
padding: 7px 12px;
|
| 3025 |
+
background: var(--raised);
|
| 3026 |
+
color: var(--text-2);
|
| 3027 |
+
font-size: 12px;
|
| 3028 |
+
}
|
| 3029 |
+
.code-block pre {
|
| 3030 |
+
margin: 0;
|
| 3031 |
+
padding: 16px;
|
| 3032 |
+
white-space: pre;
|
| 3033 |
+
overflow: auto;
|
| 3034 |
+
background: var(--bg);
|
| 3035 |
+
font: 12px/1.7 var(--font-mono);
|
| 3036 |
+
}
|
| 3037 |
+
.raw-output {
|
| 3038 |
+
font-family: var(--font-mono) !important;
|
| 3039 |
+
}
|
| 3040 |
+
.hljs-keyword,
|
| 3041 |
+
.hljs-selector-tag,
|
| 3042 |
+
.hljs-literal {
|
| 3043 |
+
color: #c4b5fd;
|
| 3044 |
+
}
|
| 3045 |
+
.hljs-string,
|
| 3046 |
+
.hljs-attr {
|
| 3047 |
+
color: #8eddbc;
|
| 3048 |
+
}
|
| 3049 |
+
.hljs-title,
|
| 3050 |
+
.hljs-number {
|
| 3051 |
+
color: #f0bc80;
|
| 3052 |
+
}
|
| 3053 |
+
.hljs-comment {
|
| 3054 |
+
color: #a0a7bc;
|
| 3055 |
+
}
|
| 3056 |
+
.admin-access-note {
|
| 3057 |
+
display: flex;
|
| 3058 |
+
gap: 10px;
|
| 3059 |
+
color: var(--text-2);
|
| 3060 |
+
margin: -8px 0 24px;
|
| 3061 |
+
}
|
| 3062 |
+
.admin-stats {
|
| 3063 |
+
display: grid;
|
| 3064 |
+
grid-template-columns: repeat(4, minmax(0, 1fr));
|
| 3065 |
+
gap: 16px;
|
| 3066 |
+
margin: 24px 0;
|
| 3067 |
+
}
|
| 3068 |
+
.admin-stats .panel {
|
| 3069 |
+
padding: 20px;
|
| 3070 |
+
display: flex;
|
| 3071 |
+
flex-direction: column;
|
| 3072 |
+
gap: 7px;
|
| 3073 |
+
}
|
| 3074 |
+
.admin-stats strong {
|
| 3075 |
+
font-size: 26px;
|
| 3076 |
+
}
|
| 3077 |
+
.admin-stats span,
|
| 3078 |
+
.admin-stats small {
|
| 3079 |
+
color: var(--text-2);
|
| 3080 |
+
}
|
| 3081 |
+
.admin-grid {
|
| 3082 |
+
display: grid;
|
| 3083 |
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
| 3084 |
+
align-items: start;
|
| 3085 |
+
gap: 24px;
|
| 3086 |
+
}
|
| 3087 |
+
.admin-section {
|
| 3088 |
+
min-width: 0;
|
| 3089 |
+
}
|
| 3090 |
+
.admin-section .panel-heading h2 {
|
| 3091 |
+
display: flex;
|
| 3092 |
+
align-items: center;
|
| 3093 |
+
gap: 10px;
|
| 3094 |
+
}
|
| 3095 |
+
.admin-form {
|
| 3096 |
+
padding: 22px;
|
| 3097 |
+
}
|
| 3098 |
+
.admin-form p {
|
| 3099 |
+
margin: 0;
|
| 3100 |
+
color: var(--text-2);
|
| 3101 |
+
}
|
| 3102 |
+
.admin-form label {
|
| 3103 |
+
min-width: 0;
|
| 3104 |
+
}
|
| 3105 |
+
.admin-setup-actions {
|
| 3106 |
+
display: flex;
|
| 3107 |
+
gap: 8px;
|
| 3108 |
+
flex-wrap: wrap;
|
| 3109 |
+
}
|
| 3110 |
+
.admin-users {
|
| 3111 |
+
margin-top: 24px;
|
| 3112 |
+
}
|
| 3113 |
+
.admin-users .table-footer {
|
| 3114 |
+
display: flex;
|
| 3115 |
+
align-items: center;
|
| 3116 |
+
gap: 12px;
|
| 3117 |
+
padding: 16px 22px;
|
| 3118 |
+
}
|
| 3119 |
+
.admin-users .table-footer > span {
|
| 3120 |
+
margin-right: auto;
|
| 3121 |
+
color: var(--text-2);
|
| 3122 |
+
}
|
| 3123 |
+
.published-models {
|
| 3124 |
+
display: grid;
|
| 3125 |
+
gap: 10px;
|
| 3126 |
+
}
|
| 3127 |
+
.published-model {
|
| 3128 |
+
padding: 14px 0;
|
| 3129 |
+
border-top: 1px solid var(--border);
|
| 3130 |
+
display: flex;
|
| 3131 |
+
flex-wrap: wrap;
|
| 3132 |
+
align-items: center;
|
| 3133 |
+
gap: 10px;
|
| 3134 |
+
}
|
| 3135 |
+
.published-model > div {
|
| 3136 |
+
flex: 1;
|
| 3137 |
+
min-width: 140px;
|
| 3138 |
+
}
|
| 3139 |
+
.published-model small {
|
| 3140 |
+
display: block;
|
| 3141 |
+
overflow-wrap: anywhere;
|
| 3142 |
+
font-size: 11px;
|
| 3143 |
+
color: var(--text-2);
|
| 3144 |
+
}
|
| 3145 |
+
@media (min-width: 1024px) {
|
| 3146 |
+
.icon-button.menu-toggle,
|
| 3147 |
+
.icon-button.drawer-close {
|
| 3148 |
+
display: none;
|
| 3149 |
+
}
|
| 3150 |
+
}
|
| 3151 |
+
@media (max-width: 1100px) {
|
| 3152 |
+
.admin-grid {
|
| 3153 |
+
grid-template-columns: 1fr;
|
| 3154 |
+
}
|
| 3155 |
+
}
|
| 3156 |
+
@media (max-width: 600px) {
|
| 3157 |
+
.admin-stats {
|
| 3158 |
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
| 3159 |
+
gap: 10px;
|
| 3160 |
+
}
|
| 3161 |
+
.admin-stats .panel,
|
| 3162 |
+
.admin-form {
|
| 3163 |
+
padding: 16px;
|
| 3164 |
+
}
|
| 3165 |
+
.model-picker-body {
|
| 3166 |
+
padding: 16px;
|
| 3167 |
+
}
|
| 3168 |
+
.model-option {
|
| 3169 |
+
gap: 8px;
|
| 3170 |
+
padding: 12px;
|
| 3171 |
+
}
|
| 3172 |
+
.model-option-price {
|
| 3173 |
+
max-width: 110px;
|
| 3174 |
+
}
|
| 3175 |
+
.model-option-price .badge {
|
| 3176 |
+
white-space: normal;
|
| 3177 |
+
text-align: center;
|
| 3178 |
+
}
|
| 3179 |
+
.model-picker-results {
|
| 3180 |
+
max-height: 45dvh;
|
| 3181 |
+
}
|
| 3182 |
+
.response-heading {
|
| 3183 |
+
flex-wrap: wrap;
|
| 3184 |
+
gap: 10px;
|
| 3185 |
+
}
|
| 3186 |
+
.markdown-output {
|
| 3187 |
+
padding: 16px;
|
| 3188 |
+
}
|
| 3189 |
+
.admin-users .table-footer {
|
| 3190 |
+
flex-wrap: wrap;
|
| 3191 |
+
}
|
| 3192 |
+
}
|
| 3193 |
+
.response-actions {
|
| 3194 |
+
display: flex;
|
| 3195 |
+
align-items: center;
|
| 3196 |
+
gap: 10px;
|
| 3197 |
+
flex-wrap: wrap;
|
| 3198 |
+
}
|
| 3199 |
+
.model-picker-body .search-field {
|
| 3200 |
+
display: flex;
|
| 3201 |
+
flex-direction: row;
|
| 3202 |
+
align-items: center;
|
| 3203 |
+
gap: 10px;
|
| 3204 |
+
}
|
| 3205 |
+
.model-picker-body .search-field input {
|
| 3206 |
+
min-width: 0;
|
| 3207 |
+
flex: 1;
|
| 3208 |
+
}
|
| 3209 |
+
.markdown-output {
|
| 3210 |
+
padding: 0;
|
| 3211 |
+
}
|
frontend/app/markdown-output.tsx
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
import { Children, isValidElement, ReactNode } from "react";
|
| 3 |
+
import Markdown from "react-markdown";
|
| 4 |
+
import remarkGfm from "remark-gfm";
|
| 5 |
+
import rehypeHighlight from "rehype-highlight";
|
| 6 |
+
import { CopyButton } from "./ui";
|
| 7 |
+
|
| 8 |
+
function plainText(children: ReactNode): string {
|
| 9 |
+
return Children.toArray(children)
|
| 10 |
+
.map((child): string =>
|
| 11 |
+
typeof child === "string" || typeof child === "number"
|
| 12 |
+
? String(child)
|
| 13 |
+
: isValidElement<{ children?: ReactNode }>(child)
|
| 14 |
+
? plainText(child.props.children)
|
| 15 |
+
: "",
|
| 16 |
+
)
|
| 17 |
+
.join("");
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
export default function MarkdownOutput({
|
| 21 |
+
text,
|
| 22 |
+
notify,
|
| 23 |
+
}: {
|
| 24 |
+
text: string;
|
| 25 |
+
notify: (message: string) => void;
|
| 26 |
+
}) {
|
| 27 |
+
return (
|
| 28 |
+
<div className="markdown-output">
|
| 29 |
+
<Markdown
|
| 30 |
+
remarkPlugins={[remarkGfm]}
|
| 31 |
+
rehypePlugins={[rehypeHighlight]}
|
| 32 |
+
skipHtml
|
| 33 |
+
disallowedElements={["img"]}
|
| 34 |
+
components={{
|
| 35 |
+
a: ({ node, ...props }) => (
|
| 36 |
+
<a {...props} target="_blank" rel="noopener noreferrer" />
|
| 37 |
+
),
|
| 38 |
+
pre: ({ children }) => (
|
| 39 |
+
<div className="code-block">
|
| 40 |
+
<div className="code-toolbar">
|
| 41 |
+
<span>Code</span>
|
| 42 |
+
<CopyButton
|
| 43 |
+
value={plainText(children).replace(/\n$/, "")}
|
| 44 |
+
label="Copy code"
|
| 45 |
+
onCopy={notify}
|
| 46 |
+
/>
|
| 47 |
+
</div>
|
| 48 |
+
<pre>{children}</pre>
|
| 49 |
+
</div>
|
| 50 |
+
),
|
| 51 |
+
}}
|
| 52 |
+
>
|
| 53 |
+
{text}
|
| 54 |
+
</Markdown>
|
| 55 |
+
</div>
|
| 56 |
+
);
|
| 57 |
+
}
|
frontend/app/model-picker.tsx
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
import { useState } from "react";
|
| 3 |
+
import { ChevronDown, Search, RefreshCw, Check } from "lucide-react";
|
| 4 |
+
import { Model, money, num } from "@/lib/api";
|
| 5 |
+
import { Banner, Badge, Modal } from "./ui";
|
| 6 |
+
|
| 7 |
+
export default function ModelPicker({
|
| 8 |
+
models,
|
| 9 |
+
selected,
|
| 10 |
+
onSelect,
|
| 11 |
+
provider = "",
|
| 12 |
+
manual = false,
|
| 13 |
+
loading = false,
|
| 14 |
+
error = "",
|
| 15 |
+
onRefresh,
|
| 16 |
+
disabled = false,
|
| 17 |
+
label = "Model",
|
| 18 |
+
allowAuto = true,
|
| 19 |
+
}: {
|
| 20 |
+
models: Model[];
|
| 21 |
+
selected: Model | null;
|
| 22 |
+
onSelect: (model: Model | null) => void;
|
| 23 |
+
provider?: string;
|
| 24 |
+
manual?: boolean;
|
| 25 |
+
loading?: boolean;
|
| 26 |
+
error?: string;
|
| 27 |
+
onRefresh?: () => void;
|
| 28 |
+
disabled?: boolean;
|
| 29 |
+
label?: string;
|
| 30 |
+
allowAuto?: boolean;
|
| 31 |
+
}) {
|
| 32 |
+
const [open, setOpen] = useState(false);
|
| 33 |
+
const [search, setSearch] = useState("");
|
| 34 |
+
const [free, setFree] = useState(false);
|
| 35 |
+
const eligible = models.filter(
|
| 36 |
+
(m) => !provider || m.provider_id === provider,
|
| 37 |
+
);
|
| 38 |
+
const results = eligible.filter(
|
| 39 |
+
(m) =>
|
| 40 |
+
(!free || m.shared || (m.input_price === 0 && m.output_price === 0)) &&
|
| 41 |
+
`${m.name} ${m.model_id} ${m.provider_name}`
|
| 42 |
+
.toLowerCase()
|
| 43 |
+
.includes(search.trim().toLowerCase()),
|
| 44 |
+
);
|
| 45 |
+
function choose(model: Model | null) {
|
| 46 |
+
onSelect(model);
|
| 47 |
+
setOpen(false);
|
| 48 |
+
}
|
| 49 |
+
return (
|
| 50 |
+
<div className="model-picker">
|
| 51 |
+
<span className="field-label">{label}</span>
|
| 52 |
+
<button
|
| 53 |
+
type="button"
|
| 54 |
+
className="model-picker-trigger"
|
| 55 |
+
aria-label={`Select ${label.toLowerCase()}`}
|
| 56 |
+
aria-haspopup="dialog"
|
| 57 |
+
aria-expanded={open}
|
| 58 |
+
disabled={disabled}
|
| 59 |
+
onClick={() => setOpen(true)}
|
| 60 |
+
>
|
| 61 |
+
<span>
|
| 62 |
+
<strong>
|
| 63 |
+
{selected?.name ||
|
| 64 |
+
(manual || !allowAuto ? "Choose a model" : "Auto-select a model")}
|
| 65 |
+
</strong>
|
| 66 |
+
<small>
|
| 67 |
+
{selected
|
| 68 |
+
? selected.model_id
|
| 69 |
+
: loading
|
| 70 |
+
? "Loading the full catalog…"
|
| 71 |
+
: "Search the full catalog · find free models"}
|
| 72 |
+
</small>
|
| 73 |
+
</span>
|
| 74 |
+
<ChevronDown size={16} />
|
| 75 |
+
</button>
|
| 76 |
+
{selected && (
|
| 77 |
+
<small className="selected-route">
|
| 78 |
+
{selected.shared ? "Community · free to use" : selected.provider_name}{" "}
|
| 79 |
+
· {num(selected.context_window)} context
|
| 80 |
+
</small>
|
| 81 |
+
)}
|
| 82 |
+
{open && (
|
| 83 |
+
<Modal title="Choose a model" onClose={() => setOpen(false)} wide>
|
| 84 |
+
<div className="modal-body model-picker-body">
|
| 85 |
+
<label className="search-field">
|
| 86 |
+
<Search size={17} />
|
| 87 |
+
<input
|
| 88 |
+
autoFocus
|
| 89 |
+
aria-label="Search all models"
|
| 90 |
+
placeholder="Search by name, ID, or provider…"
|
| 91 |
+
value={search}
|
| 92 |
+
onChange={(e) => setSearch(e.target.value)}
|
| 93 |
+
/>
|
| 94 |
+
</label>
|
| 95 |
+
<div className="model-picker-toolbar">
|
| 96 |
+
<label className="check-label">
|
| 97 |
+
<input
|
| 98 |
+
type="checkbox"
|
| 99 |
+
checked={free}
|
| 100 |
+
onChange={(e) => setFree(e.target.checked)}
|
| 101 |
+
/>
|
| 102 |
+
Free only
|
| 103 |
+
</label>
|
| 104 |
+
{onRefresh && (
|
| 105 |
+
<button
|
| 106 |
+
type="button"
|
| 107 |
+
className="text-button"
|
| 108 |
+
disabled={loading}
|
| 109 |
+
onClick={onRefresh}
|
| 110 |
+
>
|
| 111 |
+
<RefreshCw size={14} />
|
| 112 |
+
Refresh models
|
| 113 |
+
</button>
|
| 114 |
+
)}
|
| 115 |
+
</div>
|
| 116 |
+
<p className="form-note" aria-live="polite">
|
| 117 |
+
{loading
|
| 118 |
+
? "Loading every page of the model catalog…"
|
| 119 |
+
: `${results.length} matching models · ${eligible.length} in this catalog`}
|
| 120 |
+
</p>
|
| 121 |
+
{error && <Banner tone="error">{error}</Banner>}
|
| 122 |
+
<div className="model-picker-results">
|
| 123 |
+
{allowAuto && !manual && !search && !free && (
|
| 124 |
+
<button className="model-option" onClick={() => choose(null)}>
|
| 125 |
+
<span>
|
| 126 |
+
<strong>Auto-select a model</strong>
|
| 127 |
+
<small>Let the routing strategy choose</small>
|
| 128 |
+
</span>
|
| 129 |
+
{!selected && <Check size={17} />}
|
| 130 |
+
</button>
|
| 131 |
+
)}
|
| 132 |
+
{results.map((model) => {
|
| 133 |
+
const unavailable =
|
| 134 |
+
!model.enabled ||
|
| 135 |
+
!model.available ||
|
| 136 |
+
model.capabilities.chat === false;
|
| 137 |
+
return (
|
| 138 |
+
<button
|
| 139 |
+
type="button"
|
| 140 |
+
className={`model-option ${selected?.id === model.id ? "selected" : ""}`}
|
| 141 |
+
key={model.id}
|
| 142 |
+
aria-label={`Choose ${model.name} · ${model.provider_name}`}
|
| 143 |
+
disabled={unavailable}
|
| 144 |
+
onClick={() => choose(model)}
|
| 145 |
+
>
|
| 146 |
+
<span>
|
| 147 |
+
<strong>{model.name}</strong>
|
| 148 |
+
<small className="mono">{model.model_id}</small>
|
| 149 |
+
<small>
|
| 150 |
+
{model.provider_name} · {num(model.context_window)}{" "}
|
| 151 |
+
context
|
| 152 |
+
</small>
|
| 153 |
+
{unavailable && (
|
| 154 |
+
<small>
|
| 155 |
+
{model.capabilities.chat === false
|
| 156 |
+
? "Available in the catalog; this model does not support chat"
|
| 157 |
+
: "Currently unavailable"}
|
| 158 |
+
</small>
|
| 159 |
+
)}
|
| 160 |
+
</span>
|
| 161 |
+
<span className="model-option-price">
|
| 162 |
+
{model.shared ? (
|
| 163 |
+
<Badge tone="green">Community · Free</Badge>
|
| 164 |
+
) : model.input_price === 0 &&
|
| 165 |
+
model.output_price === 0 ? (
|
| 166 |
+
<Badge tone="green">Free</Badge>
|
| 167 |
+
) : (
|
| 168 |
+
<small>
|
| 169 |
+
{money(model.input_price)} /{" "}
|
| 170 |
+
{money(model.output_price)}
|
| 171 |
+
<br />
|
| 172 |
+
per 1M tokens
|
| 173 |
+
</small>
|
| 174 |
+
)}
|
| 175 |
+
{selected?.id === model.id && <Check size={17} />}
|
| 176 |
+
</span>
|
| 177 |
+
</button>
|
| 178 |
+
);
|
| 179 |
+
})}
|
| 180 |
+
{!results.length && !loading && (
|
| 181 |
+
<p className="empty-catalog">
|
| 182 |
+
No matching models. Try another search or refresh the catalog.
|
| 183 |
+
</p>
|
| 184 |
+
)}
|
| 185 |
+
</div>
|
| 186 |
+
</div>
|
| 187 |
+
</Modal>
|
| 188 |
+
)}
|
| 189 |
+
</div>
|
| 190 |
+
);
|
| 191 |
+
}
|
frontend/app/playground.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
"use client";
|
| 2 |
-
import { useEffect, useRef, useState } from "react";
|
| 3 |
import {
|
| 4 |
Terminal,
|
| 5 |
Settings2,
|
|
@@ -11,7 +11,11 @@ import {
|
|
| 11 |
LoaderCircle,
|
| 12 |
RefreshCw,
|
| 13 |
} from "lucide-react";
|
| 14 |
-
import { ApiError, csrf, Model, Provider } from "@/lib/api";
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
import { Badge, Banner, CopyButton, PageHeading, Toggle } from "./ui";
|
| 16 |
const modes = [
|
| 17 |
"auto",
|
|
@@ -29,6 +33,9 @@ export default function Playground({
|
|
| 29 |
requireAccount,
|
| 30 |
notify,
|
| 31 |
onFinish,
|
|
|
|
|
|
|
|
|
|
| 32 |
}: {
|
| 33 |
demo: boolean;
|
| 34 |
providers: Provider[];
|
|
@@ -36,6 +43,9 @@ export default function Playground({
|
|
| 36 |
requireAccount: (fn: () => void) => void;
|
| 37 |
notify: (s: string) => void;
|
| 38 |
onFinish: () => Promise<void>;
|
|
|
|
|
|
|
|
|
|
| 39 |
}) {
|
| 40 |
const [prompt, setPrompt] = useState(
|
| 41 |
"Explain how an AI gateway routes a request in three clear steps.",
|
|
@@ -43,19 +53,76 @@ export default function Playground({
|
|
| 43 |
[system, setSystem] = useState(
|
| 44 |
"You are a helpful assistant. Be concise and accurate.",
|
| 45 |
),
|
| 46 |
-
[mode, setMode] = useState("
|
| 47 |
[provider, setProvider] = useState(""),
|
| 48 |
-
[
|
| 49 |
-
[stream, setStream] = useState(
|
| 50 |
[output, setOutput] = useState(""),
|
| 51 |
[running, setRunning] = useState(false),
|
| 52 |
[info, setInfo] = useState(""),
|
| 53 |
[alternatives, setAlternatives] = useState(false),
|
| 54 |
-
[retries, setRetries] = useState(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
const [settingsOpen, setSettingsOpen] = useState(false);
|
| 56 |
const [requestError, setRequestError] = useState<{
|
| 57 |
message: string;
|
| 58 |
status?: number;
|
|
|
|
| 59 |
} | null>(null);
|
| 60 |
const controller = useRef<AbortController | null>(null);
|
| 61 |
useEffect(() => {
|
|
@@ -67,12 +134,16 @@ export default function Playground({
|
|
| 67 |
}, []);
|
| 68 |
useEffect(() => () => controller.current?.abort(), []);
|
| 69 |
async function run() {
|
|
|
|
| 70 |
setRunning(true);
|
| 71 |
setOutput("");
|
| 72 |
setRequestError(null);
|
| 73 |
setInfo("Connecting to your gateway…");
|
| 74 |
controller.current = new AbortController();
|
| 75 |
const started = performance.now();
|
|
|
|
|
|
|
|
|
|
| 76 |
try {
|
| 77 |
const body = {
|
| 78 |
model:
|
|
@@ -103,6 +174,7 @@ export default function Playground({
|
|
| 103 |
throw new ApiError(
|
| 104 |
e?.error?.message || "The gateway could not complete this request.",
|
| 105 |
response.status,
|
|
|
|
| 106 |
);
|
| 107 |
}
|
| 108 |
setInfo(
|
|
@@ -133,7 +205,12 @@ export default function Playground({
|
|
| 133 |
const data = JSON.parse(raw);
|
| 134 |
if (data.error) throw new Error(data.error.message);
|
| 135 |
const delta = data.choices?.[0]?.delta;
|
| 136 |
-
if (delta?.content)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
if (delta?.tool_calls)
|
| 138 |
setOutput(
|
| 139 |
(prev) =>
|
|
@@ -159,10 +236,12 @@ export default function Playground({
|
|
| 159 |
setRequestError({
|
| 160 |
message: (err as Error).message,
|
| 161 |
status: err instanceof ApiError ? err.status : undefined,
|
|
|
|
| 162 |
});
|
| 163 |
}
|
| 164 |
} finally {
|
| 165 |
setRunning(false);
|
|
|
|
| 166 |
void onFinish();
|
| 167 |
}
|
| 168 |
}
|
|
@@ -209,7 +288,11 @@ export default function Playground({
|
|
| 209 |
>
|
| 210 |
<label>
|
| 211 |
Routing strategy
|
| 212 |
-
<select
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
{modes
|
| 214 |
.filter((x) => !["image", "embedding"].includes(x))
|
| 215 |
.map((x) => (
|
|
@@ -222,10 +305,11 @@ export default function Playground({
|
|
| 222 |
<label>
|
| 223 |
Provider
|
| 224 |
<select
|
|
|
|
| 225 |
value={provider}
|
| 226 |
onChange={(e) => {
|
| 227 |
setProvider(e.target.value);
|
| 228 |
-
|
| 229 |
}}
|
| 230 |
>
|
| 231 |
<option value="">All enabled providers</option>
|
|
@@ -238,29 +322,30 @@ export default function Playground({
|
|
| 238 |
))}
|
| 239 |
</select>
|
| 240 |
</label>
|
| 241 |
-
<
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
</
|
| 260 |
-
|
| 261 |
<label>
|
| 262 |
Retry budget
|
| 263 |
<select
|
|
|
|
| 264 |
value={retries}
|
| 265 |
onChange={(e) => setRetries(Number(e.target.value))}
|
| 266 |
>
|
|
@@ -274,16 +359,20 @@ export default function Playground({
|
|
| 274 |
<Toggle
|
| 275 |
label="Stream response"
|
| 276 |
checked={stream}
|
| 277 |
-
onChange={
|
|
|
|
|
|
|
| 278 |
/>
|
| 279 |
<Toggle
|
| 280 |
label="Allow alternative models"
|
| 281 |
checked={alternatives}
|
| 282 |
-
onChange={
|
|
|
|
|
|
|
| 283 |
/>
|
| 284 |
<p className="form-note">
|
| 285 |
-
|
| 286 |
-
|
| 287 |
</p>
|
| 288 |
</div>
|
| 289 |
</section>
|
|
@@ -300,6 +389,7 @@ export default function Playground({
|
|
| 300 |
<label>
|
| 301 |
<span className="sr-only">System instruction</span>
|
| 302 |
<textarea
|
|
|
|
| 303 |
rows={2}
|
| 304 |
value={system}
|
| 305 |
onChange={(e) => setSystem(e.target.value)}
|
|
@@ -309,6 +399,7 @@ export default function Playground({
|
|
| 309 |
<label>
|
| 310 |
Your message
|
| 311 |
<textarea
|
|
|
|
| 312 |
rows={5}
|
| 313 |
value={prompt}
|
| 314 |
onChange={(e) => setPrompt(e.target.value)}
|
|
@@ -316,7 +407,7 @@ export default function Playground({
|
|
| 316 |
if (
|
| 317 |
(e.ctrlKey || e.metaKey) &&
|
| 318 |
e.key === "Enter" &&
|
| 319 |
-
|
| 320 |
!running
|
| 321 |
) {
|
| 322 |
e.preventDefault();
|
|
@@ -345,7 +436,7 @@ export default function Playground({
|
|
| 345 |
) : (
|
| 346 |
<button
|
| 347 |
className="button primary"
|
| 348 |
-
disabled={!
|
| 349 |
onClick={() => requireAccount(() => void run())}
|
| 350 |
>
|
| 351 |
<ArrowUp size={17} />
|
|
@@ -359,18 +450,42 @@ export default function Playground({
|
|
| 359 |
<Sparkles size={16} />
|
| 360 |
<h3>Response</h3>
|
| 361 |
</div>
|
| 362 |
-
{output &&
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 363 |
</div>
|
| 364 |
<div
|
| 365 |
className={`response-output ${!output ? "no-output" : ""}`}
|
| 366 |
aria-busy={running}
|
| 367 |
>
|
| 368 |
{output ? (
|
| 369 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 370 |
) : running ? (
|
| 371 |
<span>
|
| 372 |
<LoaderCircle size={18} className="spin" />
|
| 373 |
-
Waiting for the
|
| 374 |
</span>
|
| 375 |
) : (
|
| 376 |
<span>
|
|
@@ -397,14 +512,21 @@ export default function Playground({
|
|
| 397 |
: "Request error"}
|
| 398 |
</strong>
|
| 399 |
<span>{requestError.message}</span>
|
| 400 |
-
<
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 408 |
<button
|
| 409 |
className="text-button"
|
| 410 |
onClick={() => setSettingsOpen(true)}
|
|
@@ -418,6 +540,16 @@ export default function Playground({
|
|
| 418 |
<div className="response-info mono" role="status">
|
| 419 |
{running && <LoaderCircle className="spin" size={14} />}
|
| 420 |
{info}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 421 |
</div>
|
| 422 |
)}
|
| 423 |
</section>
|
|
|
|
| 1 |
"use client";
|
| 2 |
+
import { useCallback, useEffect, useRef, useState } from "react";
|
| 3 |
import {
|
| 4 |
Terminal,
|
| 5 |
Settings2,
|
|
|
|
| 11 |
LoaderCircle,
|
| 12 |
RefreshCw,
|
| 13 |
} from "lucide-react";
|
| 14 |
+
import { ApiError, csrf, loadAllModels, Model, Provider } from "@/lib/api";
|
| 15 |
+
import { SiteOptions, defaultSite } from "@/lib/site";
|
| 16 |
+
import { errorGuidance } from "@/lib/errors";
|
| 17 |
+
import ModelPicker from "./model-picker";
|
| 18 |
+
import MarkdownOutput from "./markdown-output";
|
| 19 |
import { Badge, Banner, CopyButton, PageHeading, Toggle } from "./ui";
|
| 20 |
const modes = [
|
| 21 |
"auto",
|
|
|
|
| 33 |
requireAccount,
|
| 34 |
notify,
|
| 35 |
onFinish,
|
| 36 |
+
initialModel = null,
|
| 37 |
+
defaults = defaultSite,
|
| 38 |
+
loadCatalog = loadAllModels,
|
| 39 |
}: {
|
| 40 |
demo: boolean;
|
| 41 |
providers: Provider[];
|
|
|
|
| 43 |
requireAccount: (fn: () => void) => void;
|
| 44 |
notify: (s: string) => void;
|
| 45 |
onFinish: () => Promise<void>;
|
| 46 |
+
initialModel?: Model | null;
|
| 47 |
+
defaults?: SiteOptions;
|
| 48 |
+
loadCatalog?: (signal?: AbortSignal) => Promise<Model[]>;
|
| 49 |
}) {
|
| 50 |
const [prompt, setPrompt] = useState(
|
| 51 |
"Explain how an AI gateway routes a request in three clear steps.",
|
|
|
|
| 53 |
[system, setSystem] = useState(
|
| 54 |
"You are a helpful assistant. Be concise and accurate.",
|
| 55 |
),
|
| 56 |
+
[mode, setMode] = useState(initialModel ? "manual" : defaults.default_mode),
|
| 57 |
[provider, setProvider] = useState(""),
|
| 58 |
+
[selectedModel, setSelectedModel] = useState<Model | null>(initialModel),
|
| 59 |
+
[stream, setStream] = useState(defaults.default_stream),
|
| 60 |
[output, setOutput] = useState(""),
|
| 61 |
[running, setRunning] = useState(false),
|
| 62 |
[info, setInfo] = useState(""),
|
| 63 |
[alternatives, setAlternatives] = useState(false),
|
| 64 |
+
[retries, setRetries] = useState(defaults.default_retries);
|
| 65 |
+
const model = selectedModel?.route_id || "auto";
|
| 66 |
+
const [catalog, setCatalog] = useState(models);
|
| 67 |
+
const [catalogLoading, setCatalogLoading] = useState(!demo);
|
| 68 |
+
const [catalogError, setCatalogError] = useState("");
|
| 69 |
+
const [rawView, setRawView] = useState(false);
|
| 70 |
+
const [elapsed, setElapsed] = useState(0);
|
| 71 |
+
const [firstToken, setFirstToken] = useState<number | null>(null);
|
| 72 |
+
const startedAt = useRef(0);
|
| 73 |
+
const seedModels = useRef(models);
|
| 74 |
+
seedModels.current = models;
|
| 75 |
+
const catalogController = useRef<AbortController | null>(null);
|
| 76 |
+
const reloadCatalog = useCallback(() => {
|
| 77 |
+
catalogController.current?.abort();
|
| 78 |
+
if (demo) {
|
| 79 |
+
setCatalog(seedModels.current);
|
| 80 |
+
setCatalogLoading(false);
|
| 81 |
+
return;
|
| 82 |
+
}
|
| 83 |
+
const pending = new AbortController();
|
| 84 |
+
catalogController.current = pending;
|
| 85 |
+
setCatalogLoading(true);
|
| 86 |
+
setCatalogError("");
|
| 87 |
+
void loadCatalog(pending.signal)
|
| 88 |
+
.then((all) => {
|
| 89 |
+
if (!pending.signal.aborted) setCatalog(all);
|
| 90 |
+
})
|
| 91 |
+
.catch((error: Error) => {
|
| 92 |
+
if (!pending.signal.aborted) setCatalogError(error.message);
|
| 93 |
+
})
|
| 94 |
+
.finally(() => {
|
| 95 |
+
if (!pending.signal.aborted) setCatalogLoading(false);
|
| 96 |
+
});
|
| 97 |
+
}, [demo, loadCatalog]);
|
| 98 |
+
useEffect(() => {
|
| 99 |
+
reloadCatalog();
|
| 100 |
+
return () => catalogController.current?.abort();
|
| 101 |
+
}, [reloadCatalog]);
|
| 102 |
+
const missingManualModel = mode === "manual" && !selectedModel;
|
| 103 |
+
const currentSelection = catalog.find((m) => m.id === selectedModel?.id);
|
| 104 |
+
const unavailableSelection =
|
| 105 |
+
!!selectedModel &&
|
| 106 |
+
!catalogLoading &&
|
| 107 |
+
(!currentSelection ||
|
| 108 |
+
!currentSelection.enabled ||
|
| 109 |
+
!currentSelection.available ||
|
| 110 |
+
currentSelection.capabilities.chat === false);
|
| 111 |
+
const canSend =
|
| 112 |
+
!!prompt.trim() && !missingManualModel && !unavailableSelection;
|
| 113 |
+
useEffect(() => {
|
| 114 |
+
if (!running) return;
|
| 115 |
+
const timer = setInterval(
|
| 116 |
+
() => setElapsed((performance.now() - startedAt.current) / 1000),
|
| 117 |
+
250,
|
| 118 |
+
);
|
| 119 |
+
return () => clearInterval(timer);
|
| 120 |
+
}, [running]);
|
| 121 |
const [settingsOpen, setSettingsOpen] = useState(false);
|
| 122 |
const [requestError, setRequestError] = useState<{
|
| 123 |
message: string;
|
| 124 |
status?: number;
|
| 125 |
+
code?: string;
|
| 126 |
} | null>(null);
|
| 127 |
const controller = useRef<AbortController | null>(null);
|
| 128 |
useEffect(() => {
|
|
|
|
| 134 |
}, []);
|
| 135 |
useEffect(() => () => controller.current?.abort(), []);
|
| 136 |
async function run() {
|
| 137 |
+
if (!canSend || running) return;
|
| 138 |
setRunning(true);
|
| 139 |
setOutput("");
|
| 140 |
setRequestError(null);
|
| 141 |
setInfo("Connecting to your gateway…");
|
| 142 |
controller.current = new AbortController();
|
| 143 |
const started = performance.now();
|
| 144 |
+
startedAt.current = started;
|
| 145 |
+
setElapsed(0);
|
| 146 |
+
setFirstToken(null);
|
| 147 |
try {
|
| 148 |
const body = {
|
| 149 |
model:
|
|
|
|
| 174 |
throw new ApiError(
|
| 175 |
e?.error?.message || "The gateway could not complete this request.",
|
| 176 |
response.status,
|
| 177 |
+
e?.error?.code,
|
| 178 |
);
|
| 179 |
}
|
| 180 |
setInfo(
|
|
|
|
| 205 |
const data = JSON.parse(raw);
|
| 206 |
if (data.error) throw new Error(data.error.message);
|
| 207 |
const delta = data.choices?.[0]?.delta;
|
| 208 |
+
if (delta?.content) {
|
| 209 |
+
setFirstToken(
|
| 210 |
+
(prev) => prev ?? (performance.now() - started) / 1000,
|
| 211 |
+
);
|
| 212 |
+
setOutput((prev) => prev + delta.content);
|
| 213 |
+
}
|
| 214 |
if (delta?.tool_calls)
|
| 215 |
setOutput(
|
| 216 |
(prev) =>
|
|
|
|
| 236 |
setRequestError({
|
| 237 |
message: (err as Error).message,
|
| 238 |
status: err instanceof ApiError ? err.status : undefined,
|
| 239 |
+
code: err instanceof ApiError ? err.code : undefined,
|
| 240 |
});
|
| 241 |
}
|
| 242 |
} finally {
|
| 243 |
setRunning(false);
|
| 244 |
+
setElapsed((performance.now() - started) / 1000);
|
| 245 |
void onFinish();
|
| 246 |
}
|
| 247 |
}
|
|
|
|
| 288 |
>
|
| 289 |
<label>
|
| 290 |
Routing strategy
|
| 291 |
+
<select
|
| 292 |
+
disabled={running}
|
| 293 |
+
value={mode}
|
| 294 |
+
onChange={(e) => setMode(e.target.value)}
|
| 295 |
+
>
|
| 296 |
{modes
|
| 297 |
.filter((x) => !["image", "embedding"].includes(x))
|
| 298 |
.map((x) => (
|
|
|
|
| 305 |
<label>
|
| 306 |
Provider
|
| 307 |
<select
|
| 308 |
+
disabled={running}
|
| 309 |
value={provider}
|
| 310 |
onChange={(e) => {
|
| 311 |
setProvider(e.target.value);
|
| 312 |
+
setSelectedModel(null);
|
| 313 |
}}
|
| 314 |
>
|
| 315 |
<option value="">All enabled providers</option>
|
|
|
|
| 322 |
))}
|
| 323 |
</select>
|
| 324 |
</label>
|
| 325 |
+
<ModelPicker
|
| 326 |
+
models={catalog}
|
| 327 |
+
selected={selectedModel}
|
| 328 |
+
onSelect={setSelectedModel}
|
| 329 |
+
provider={provider}
|
| 330 |
+
manual={mode === "manual"}
|
| 331 |
+
loading={catalogLoading}
|
| 332 |
+
error={catalogError}
|
| 333 |
+
onRefresh={reloadCatalog}
|
| 334 |
+
disabled={running}
|
| 335 |
+
/>
|
| 336 |
+
{missingManualModel && (
|
| 337 |
+
<p className="field-error">Choose a model for manual routing.</p>
|
| 338 |
+
)}
|
| 339 |
+
{unavailableSelection && (
|
| 340 |
+
<p className="field-error">
|
| 341 |
+
The selected model is no longer available. Choose another model
|
| 342 |
+
or refresh the catalog.
|
| 343 |
+
</p>
|
| 344 |
+
)}
|
| 345 |
<label>
|
| 346 |
Retry budget
|
| 347 |
<select
|
| 348 |
+
disabled={running}
|
| 349 |
value={retries}
|
| 350 |
onChange={(e) => setRetries(Number(e.target.value))}
|
| 351 |
>
|
|
|
|
| 359 |
<Toggle
|
| 360 |
label="Stream response"
|
| 361 |
checked={stream}
|
| 362 |
+
onChange={(value) => {
|
| 363 |
+
if (!running) setStream(value);
|
| 364 |
+
}}
|
| 365 |
/>
|
| 366 |
<Toggle
|
| 367 |
label="Allow alternative models"
|
| 368 |
checked={alternatives}
|
| 369 |
+
onChange={(value) => {
|
| 370 |
+
if (!running) setAlternatives(value);
|
| 371 |
+
}}
|
| 372 |
/>
|
| 373 |
<p className="form-note">
|
| 374 |
+
Retry failed attempts up to this limit. Enable alternatives to
|
| 375 |
+
allow a compatible model when your first choice fails.
|
| 376 |
</p>
|
| 377 |
</div>
|
| 378 |
</section>
|
|
|
|
| 389 |
<label>
|
| 390 |
<span className="sr-only">System instruction</span>
|
| 391 |
<textarea
|
| 392 |
+
disabled={running}
|
| 393 |
rows={2}
|
| 394 |
value={system}
|
| 395 |
onChange={(e) => setSystem(e.target.value)}
|
|
|
|
| 399 |
<label>
|
| 400 |
Your message
|
| 401 |
<textarea
|
| 402 |
+
disabled={running}
|
| 403 |
rows={5}
|
| 404 |
value={prompt}
|
| 405 |
onChange={(e) => setPrompt(e.target.value)}
|
|
|
|
| 407 |
if (
|
| 408 |
(e.ctrlKey || e.metaKey) &&
|
| 409 |
e.key === "Enter" &&
|
| 410 |
+
canSend &&
|
| 411 |
!running
|
| 412 |
) {
|
| 413 |
e.preventDefault();
|
|
|
|
| 436 |
) : (
|
| 437 |
<button
|
| 438 |
className="button primary"
|
| 439 |
+
disabled={!canSend}
|
| 440 |
onClick={() => requireAccount(() => void run())}
|
| 441 |
>
|
| 442 |
<ArrowUp size={17} />
|
|
|
|
| 450 |
<Sparkles size={16} />
|
| 451 |
<h3>Response</h3>
|
| 452 |
</div>
|
| 453 |
+
{output && (
|
| 454 |
+
<div className="response-actions">
|
| 455 |
+
<div className="response-format" aria-label="Response format">
|
| 456 |
+
<button
|
| 457 |
+
aria-pressed={!rawView}
|
| 458 |
+
className={!rawView ? "active" : ""}
|
| 459 |
+
onClick={() => setRawView(false)}
|
| 460 |
+
>
|
| 461 |
+
Formatted
|
| 462 |
+
</button>
|
| 463 |
+
<button
|
| 464 |
+
aria-pressed={rawView}
|
| 465 |
+
className={rawView ? "active" : ""}
|
| 466 |
+
onClick={() => setRawView(true)}
|
| 467 |
+
>
|
| 468 |
+
Raw
|
| 469 |
+
</button>
|
| 470 |
+
</div>
|
| 471 |
+
<CopyButton value={output} onCopy={notify} />
|
| 472 |
+
</div>
|
| 473 |
+
)}
|
| 474 |
</div>
|
| 475 |
<div
|
| 476 |
className={`response-output ${!output ? "no-output" : ""}`}
|
| 477 |
aria-busy={running}
|
| 478 |
>
|
| 479 |
{output ? (
|
| 480 |
+
rawView ? (
|
| 481 |
+
<pre className="raw-output">{output}</pre>
|
| 482 |
+
) : (
|
| 483 |
+
<MarkdownOutput text={output} notify={notify} />
|
| 484 |
+
)
|
| 485 |
) : running ? (
|
| 486 |
<span>
|
| 487 |
<LoaderCircle size={18} className="spin" />
|
| 488 |
+
Waiting for the first token… {elapsed.toFixed(1)} s
|
| 489 |
</span>
|
| 490 |
) : (
|
| 491 |
<span>
|
|
|
|
| 512 |
: "Request error"}
|
| 513 |
</strong>
|
| 514 |
<span>{requestError.message}</span>
|
| 515 |
+
<span>
|
| 516 |
+
{errorGuidance(requestError.status, requestError.code)}
|
| 517 |
+
</span>
|
| 518 |
+
{(!requestError.status ||
|
| 519 |
+
requestError.status >= 500 ||
|
| 520 |
+
requestError.status === 429) && (
|
| 521 |
+
<button
|
| 522 |
+
className="text-button"
|
| 523 |
+
disabled={running}
|
| 524 |
+
onClick={() => requireAccount(() => void run())}
|
| 525 |
+
>
|
| 526 |
+
<RefreshCw size={14} />
|
| 527 |
+
Retry request
|
| 528 |
+
</button>
|
| 529 |
+
)}
|
| 530 |
<button
|
| 531 |
className="text-button"
|
| 532 |
onClick={() => setSettingsOpen(true)}
|
|
|
|
| 540 |
<div className="response-info mono" role="status">
|
| 541 |
{running && <LoaderCircle className="spin" size={14} />}
|
| 542 |
{info}
|
| 543 |
+
{running && (
|
| 544 |
+
<span>
|
| 545 |
+
{" "}
|
| 546 |
+
· {output ? "Receiving response" : "Waiting"} ·{" "}
|
| 547 |
+
{elapsed.toFixed(1)} s
|
| 548 |
+
</span>
|
| 549 |
+
)}
|
| 550 |
+
{firstToken !== null && (
|
| 551 |
+
<span> · First token {firstToken.toFixed(1)} s</span>
|
| 552 |
+
)}
|
| 553 |
</div>
|
| 554 |
)}
|
| 555 |
</section>
|
frontend/app/request-table.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
"use client";
|
| 2 |
import { Check, TriangleAlert, ChevronRight } from "lucide-react";
|
| 3 |
-
import { Log, num, stamp } from "@/lib/api";
|
| 4 |
import { Badge, Empty, Price } from "./ui";
|
| 5 |
export default function RequestTable({
|
| 6 |
logs,
|
|
@@ -63,7 +63,7 @@ export default function RequestTable({
|
|
| 63 |
</Badge>
|
| 64 |
</td>
|
| 65 |
<td className="mono" data-label="Latency" role="cell">
|
| 66 |
-
{
|
| 67 |
</td>
|
| 68 |
{!compact && (
|
| 69 |
<>
|
|
|
|
| 1 |
"use client";
|
| 2 |
import { Check, TriangleAlert, ChevronRight } from "lucide-react";
|
| 3 |
+
import { Log, num, stamp, duration } from "@/lib/api";
|
| 4 |
import { Badge, Empty, Price } from "./ui";
|
| 5 |
export default function RequestTable({
|
| 6 |
logs,
|
|
|
|
| 63 |
</Badge>
|
| 64 |
</td>
|
| 65 |
<td className="mono" data-label="Latency" role="cell">
|
| 66 |
+
{duration(log.latency_ms)}
|
| 67 |
</td>
|
| 68 |
{!compact && (
|
| 69 |
<>
|
frontend/app/ui.tsx
CHANGED
|
@@ -220,9 +220,11 @@ export function CheckRow({ children }: { children: ReactNode }) {
|
|
| 220 |
export function Topology({
|
| 221 |
providers,
|
| 222 |
demo,
|
|
|
|
| 223 |
}: {
|
| 224 |
providers: Provider[];
|
| 225 |
demo: boolean;
|
|
|
|
| 226 |
}) {
|
| 227 |
const active = providers.filter((p) => p.enabled).slice(0, 3);
|
| 228 |
return (
|
|
@@ -237,9 +239,11 @@ export function Topology({
|
|
| 237 |
</div>
|
| 238 |
<div className="route-line" />
|
| 239 |
<div className="gateway-node">
|
| 240 |
-
<span className="brand-mark mini">
|
|
|
|
|
|
|
| 241 |
<div>
|
| 242 |
-
<strong>
|
| 243 |
<small>One endpoint · your keys</small>
|
| 244 |
</div>
|
| 245 |
<span className="node-tag">/v1</span>
|
|
@@ -267,7 +271,7 @@ export function Topology({
|
|
| 267 |
<span className="dot" />
|
| 268 |
{demo
|
| 269 |
? "Example routing topology"
|
| 270 |
-
: `${providers.filter((p) => p.enabled).length} enabled connections`}
|
| 271 |
</div>
|
| 272 |
</div>
|
| 273 |
);
|
|
|
|
| 220 |
export function Topology({
|
| 221 |
providers,
|
| 222 |
demo,
|
| 223 |
+
siteName = "Nexus",
|
| 224 |
}: {
|
| 225 |
providers: Provider[];
|
| 226 |
demo: boolean;
|
| 227 |
+
siteName?: string;
|
| 228 |
}) {
|
| 229 |
const active = providers.filter((p) => p.enabled).slice(0, 3);
|
| 230 |
return (
|
|
|
|
| 239 |
</div>
|
| 240 |
<div className="route-line" />
|
| 241 |
<div className="gateway-node">
|
| 242 |
+
<span className="brand-mark mini">
|
| 243 |
+
{siteName.slice(0, 1).toUpperCase()}
|
| 244 |
+
</span>
|
| 245 |
<div>
|
| 246 |
+
<strong>{siteName} gateway</strong>
|
| 247 |
<small>One endpoint · your keys</small>
|
| 248 |
</div>
|
| 249 |
<span className="node-tag">/v1</span>
|
|
|
|
| 271 |
<span className="dot" />
|
| 272 |
{demo
|
| 273 |
? "Example routing topology"
|
| 274 |
+
: `${providers.filter((p) => p.enabled).length} enabled ${providers.filter((p) => p.enabled).length === 1 ? "connection" : "connections"}`}
|
| 275 |
</div>
|
| 276 |
</div>
|
| 277 |
);
|
frontend/app/workspace.tsx
CHANGED
|
@@ -48,7 +48,10 @@ import {
|
|
| 48 |
num,
|
| 49 |
money,
|
| 50 |
stamp,
|
|
|
|
| 51 |
} from "@/lib/api";
|
|
|
|
|
|
|
| 52 |
import {
|
| 53 |
demoProviders,
|
| 54 |
demoModels,
|
|
@@ -58,6 +61,7 @@ import {
|
|
| 58 |
} from "@/lib/demo";
|
| 59 |
import { AuthForm, ProviderForm, ModelForm } from "./forms";
|
| 60 |
import Playground from "./playground";
|
|
|
|
| 61 |
import Documentation from "./documentation";
|
| 62 |
import RequestTable from "./request-table";
|
| 63 |
import {
|
|
@@ -85,7 +89,8 @@ type View =
|
|
| 85 |
| "keys"
|
| 86 |
| "requests"
|
| 87 |
| "usage"
|
| 88 |
-
| "docs"
|
|
|
|
| 89 |
const nav: { id: View; name: string; icon: typeof Activity }[] = [
|
| 90 |
{ id: "overview", name: "Overview", icon: LayoutDashboard },
|
| 91 |
{ id: "providers", name: "Providers", icon: Waypoints },
|
|
@@ -95,6 +100,7 @@ const nav: { id: View; name: string; icon: typeof Activity }[] = [
|
|
| 95 |
{ id: "requests", name: "Request logs", icon: Activity },
|
| 96 |
{ id: "usage", name: "Usage & analytics", icon: ChartNoAxesCombined },
|
| 97 |
{ id: "docs", name: "Documentation", icon: BookOpen },
|
|
|
|
| 98 |
];
|
| 99 |
const blankUsage: Usage = {
|
| 100 |
days: 7,
|
|
@@ -112,6 +118,14 @@ const blankUsage: Usage = {
|
|
| 112 |
};
|
| 113 |
|
| 114 |
export default function Dashboard() {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 115 |
const [view, setView] = useState<View>("overview"),
|
| 116 |
[user, setUser] = useState<User | null>(null),
|
| 117 |
[demo, setDemo] = useState(true),
|
|
@@ -176,22 +190,30 @@ export default function Dashboard() {
|
|
| 176 |
};
|
| 177 |
syncHash();
|
| 178 |
window.addEventListener("hashchange", syncHash);
|
| 179 |
-
|
| 180 |
-
.
|
|
|
|
|
|
|
|
|
|
| 181 |
setUser(u);
|
| 182 |
-
setDemo(
|
| 183 |
-
|
| 184 |
-
.catch(() => {
|
| 185 |
-
setDemo(true);
|
| 186 |
})
|
| 187 |
.finally(() => setReady(true));
|
| 188 |
return () => window.removeEventListener("hashchange", syncHash);
|
| 189 |
}, []);
|
| 190 |
useEffect(() => {
|
| 191 |
-
document.title = `${nav.find((n) => n.id === view)?.name || "Overview"} ·
|
|
|
|
|
|
|
|
|
|
| 192 |
}, [view]);
|
| 193 |
useEffect(() => {
|
| 194 |
if (!mobile) return;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
const drawer = sidebarRef.current;
|
| 196 |
const previous = document.activeElement as HTMLElement | null;
|
| 197 |
const overflow = document.body.style.overflow;
|
|
@@ -257,6 +279,7 @@ export default function Dashboard() {
|
|
| 257 |
setTotalLogs(l.total);
|
| 258 |
setUsage(u);
|
| 259 |
setConnectionError("");
|
|
|
|
| 260 |
} catch (err) {
|
| 261 |
if (err instanceof ApiError && err.status === 401) {
|
| 262 |
setUser(null);
|
|
@@ -276,12 +299,12 @@ export default function Dashboard() {
|
|
| 276 |
let active = true;
|
| 277 |
const timer = setTimeout(() => {
|
| 278 |
api<Page<Model>>(
|
| 279 |
-
`/models?limit=100&offset=${modelPage * 100}&search=${encodeURIComponent(search)}&capability=${capability}&provider=${filterProvider}`,
|
| 280 |
)
|
| 281 |
.then((r) => {
|
| 282 |
if (active) {
|
| 283 |
-
|
| 284 |
-
|
| 285 |
}
|
| 286 |
})
|
| 287 |
.catch((e) => notify(e.message, "error"));
|
|
@@ -290,38 +313,56 @@ export default function Dashboard() {
|
|
| 290 |
active = false;
|
| 291 |
clearTimeout(timer);
|
| 292 |
};
|
| 293 |
-
}, [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 294 |
useEffect(() => {
|
| 295 |
if (demo || view !== "requests") return;
|
| 296 |
let active = true;
|
| 297 |
api<Page<Log>>(`/logs?offset=${logPage * 25}&errors_only=${errorsOnly}`)
|
| 298 |
.then((r) => {
|
| 299 |
if (active) {
|
| 300 |
-
|
| 301 |
-
|
| 302 |
}
|
| 303 |
})
|
| 304 |
.catch((e) => notify(e.message, "error"));
|
| 305 |
return () => {
|
| 306 |
active = false;
|
| 307 |
};
|
| 308 |
-
}, [view, demo, logPage, errorsOnly, notify]);
|
| 309 |
const p = demo ? demoProviders : providers,
|
| 310 |
m = demo ? demoModels : models,
|
| 311 |
k = demo ? demoKeys : keys,
|
| 312 |
l = demo ? demoLogs : logs,
|
| 313 |
u = demo ? demoUsage : usage;
|
| 314 |
const displayedModels = demo
|
| 315 |
-
? m
|
| 316 |
-
(
|
| 317 |
-
(
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 325 |
const s = u.summary,
|
| 326 |
successRate = s.requests
|
| 327 |
? (((s.successes || 0) / s.requests) * 100).toFixed(1)
|
|
@@ -381,8 +422,7 @@ export default function Dashboard() {
|
|
| 381 |
/>
|
| 382 |
<Metric
|
| 383 |
label="Average latency"
|
| 384 |
-
value={
|
| 385 |
-
suffix="ms"
|
| 386 |
icon={Zap}
|
| 387 |
detail="End-to-end request duration"
|
| 388 |
loading={!ready || busy}
|
|
@@ -404,8 +444,15 @@ export default function Dashboard() {
|
|
| 404 |
/>
|
| 405 |
</div>
|
| 406 |
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 407 |
return (
|
| 408 |
-
<div className="app-shell">
|
| 409 |
<a href="#main-content" className="skip-link">
|
| 410 |
Skip to content
|
| 411 |
</a>
|
|
@@ -436,9 +483,12 @@ export default function Dashboard() {
|
|
| 436 |
className="brand"
|
| 437 |
onClick={() => navigate("overview")}
|
| 438 |
>
|
| 439 |
-
<span className="brand-mark">
|
|
|
|
|
|
|
| 440 |
<span>
|
| 441 |
-
|
|
|
|
| 442 |
</span>
|
| 443 |
<Badge>GATEWAY</Badge>
|
| 444 |
</a>
|
|
@@ -472,6 +522,17 @@ export default function Dashboard() {
|
|
| 472 |
</a>
|
| 473 |
))}
|
| 474 |
</nav>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 475 |
<div className="sidebar-bottom">
|
| 476 |
<a
|
| 477 |
className={`nav-link ${view === "docs" ? "selected" : ""}`}
|
|
@@ -503,6 +564,7 @@ export default function Dashboard() {
|
|
| 503 |
setUser(null);
|
| 504 |
setDemo(true);
|
| 505 |
setSecret("");
|
|
|
|
| 506 |
notify("Signed out");
|
| 507 |
})
|
| 508 |
.catch((e) => notify(e.message, "error"));
|
|
@@ -522,7 +584,10 @@ export default function Dashboard() {
|
|
| 522 |
className="icon-button menu-toggle"
|
| 523 |
aria-controls="workspace-navigation"
|
| 524 |
aria-expanded={mobile}
|
| 525 |
-
onClick={() =>
|
|
|
|
|
|
|
|
|
|
| 526 |
aria-label="Open navigation"
|
| 527 |
>
|
| 528 |
<Menu size={22} />
|
|
@@ -610,10 +675,47 @@ export default function Dashboard() {
|
|
| 610 |
</button>
|
| 611 |
</div>
|
| 612 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 613 |
<div className="operational-strip">
|
| 614 |
<span>
|
| 615 |
<span className="dot" />
|
| 616 |
-
{p.filter((x) => x.enabled).length} enabled
|
|
|
|
|
|
|
|
|
|
| 617 |
</span>
|
| 618 |
<span>
|
| 619 |
<ShieldCheck size={14} />
|
|
@@ -670,7 +772,11 @@ export default function Dashboard() {
|
|
| 670 |
</div>
|
| 671 |
<Network size={18} className="muted" />
|
| 672 |
</div>
|
| 673 |
-
<Topology
|
|
|
|
|
|
|
|
|
|
|
|
|
| 674 |
</section>
|
| 675 |
</div>
|
| 676 |
<section className="panel providers-overview">
|
|
@@ -854,6 +960,10 @@ export default function Dashboard() {
|
|
| 854 |
{provider.discovery_error && (
|
| 855 |
<Banner tone="error">{provider.discovery_error}</Banner>
|
| 856 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 857 |
<div className="provider-card-refreshed">
|
| 858 |
Catalog refreshed {stamp(provider.discovered_at)}
|
| 859 |
</div>
|
|
@@ -983,7 +1093,7 @@ export default function Dashboard() {
|
|
| 983 |
<Search size={17} />
|
| 984 |
<input
|
| 985 |
aria-label="Search models"
|
| 986 |
-
placeholder="Search models
|
| 987 |
value={search}
|
| 988 |
onChange={(e) => {
|
| 989 |
setSearch(e.target.value);
|
|
@@ -1004,6 +1114,17 @@ export default function Dashboard() {
|
|
| 1004 |
)}
|
| 1005 |
</button>
|
| 1006 |
<div className="filter-controls" id="model-filter-controls">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1007 |
<select
|
| 1008 |
aria-label="Filter capability"
|
| 1009 |
value={capability}
|
|
@@ -1048,7 +1169,7 @@ export default function Dashboard() {
|
|
| 1048 |
</div>
|
| 1049 |
<div className="table-note">
|
| 1050 |
<span>
|
| 1051 |
-
{demo ? displayedModels.length :
|
| 1052 |
<span className="mid-dot">·</span> Prices in USD per million
|
| 1053 |
tokens
|
| 1054 |
</span>
|
|
@@ -1081,6 +1202,9 @@ export default function Dashboard() {
|
|
| 1081 |
{model.model_id}
|
| 1082 |
</small>
|
| 1083 |
<span>{model.provider_name}</span>
|
|
|
|
|
|
|
|
|
|
| 1084 |
</div>
|
| 1085 |
</div>
|
| 1086 |
</td>
|
|
@@ -1120,10 +1244,27 @@ export default function Dashboard() {
|
|
| 1120 |
role="cell"
|
| 1121 |
>
|
| 1122 |
<div className="row-actions">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1123 |
<button
|
| 1124 |
className={`icon-button ${model.favorite ? "accent" : ""}`}
|
| 1125 |
aria-label={`Favorite ${model.name}`}
|
| 1126 |
aria-pressed={model.favorite}
|
|
|
|
| 1127 |
onClick={() =>
|
| 1128 |
requireAccount(
|
| 1129 |
() =>
|
|
@@ -1144,6 +1285,7 @@ export default function Dashboard() {
|
|
| 1144 |
<button
|
| 1145 |
className="icon-button"
|
| 1146 |
aria-label={`Configure ${model.name}`}
|
|
|
|
| 1147 |
onClick={() =>
|
| 1148 |
requireAccount(() => setModelModal(model))
|
| 1149 |
}
|
|
@@ -1181,7 +1323,7 @@ export default function Dashboard() {
|
|
| 1181 |
<Pagination
|
| 1182 |
page={modelPage}
|
| 1183 |
size={100}
|
| 1184 |
-
total={demo ? displayedModels.length :
|
| 1185 |
setPage={setModelPage}
|
| 1186 |
/>
|
| 1187 |
</div>
|
|
@@ -1190,12 +1332,15 @@ export default function Dashboard() {
|
|
| 1190 |
)}
|
| 1191 |
{view === "playground" && (
|
| 1192 |
<Playground
|
|
|
|
| 1193 |
demo={demo}
|
| 1194 |
providers={p}
|
| 1195 |
models={m}
|
| 1196 |
requireAccount={requireAccount}
|
| 1197 |
notify={notify}
|
| 1198 |
onFinish={load}
|
|
|
|
|
|
|
| 1199 |
/>
|
| 1200 |
)}
|
| 1201 |
{view === "keys" && (
|
|
@@ -1218,7 +1363,12 @@ export default function Dashboard() {
|
|
| 1218 |
<span>
|
| 1219 |
Keys are shown only once. Save each new key before closing.
|
| 1220 |
</span>
|
| 1221 |
-
<Badge>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1222 |
</div>
|
| 1223 |
<section className="panel table-panel">
|
| 1224 |
<div className="table-scroll">
|
|
@@ -1360,16 +1510,20 @@ export default function Dashboard() {
|
|
| 1360 |
<section className="panel">
|
| 1361 |
<RequestTable
|
| 1362 |
logs={
|
| 1363 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1364 |
}
|
| 1365 |
onSelect={setSelectedLog}
|
| 1366 |
/>
|
| 1367 |
<div className="table-footer">
|
| 1368 |
-
<span>{demo ? l.length :
|
| 1369 |
<Pagination
|
| 1370 |
page={logPage}
|
| 1371 |
size={25}
|
| 1372 |
-
total={demo ? l.length :
|
| 1373 |
setPage={setLogPage}
|
| 1374 |
/>
|
| 1375 |
</div>
|
|
@@ -1462,10 +1616,41 @@ export default function Dashboard() {
|
|
| 1462 |
{view === "docs" && (
|
| 1463 |
<Documentation endpoint={endpoint} notify={notify} />
|
| 1464 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1465 |
<footer className="page-footer">
|
| 1466 |
<span>
|
| 1467 |
-
<span className="brand-mark tiny">
|
| 1468 |
-
|
|
|
|
|
|
|
| 1469 |
</span>
|
| 1470 |
<span>
|
| 1471 |
{demo
|
|
@@ -1499,9 +1684,11 @@ export default function Dashboard() {
|
|
| 1499 |
)}
|
| 1500 |
{authOpen && (
|
| 1501 |
<AuthForm
|
|
|
|
| 1502 |
onClose={() => setAuthOpen(false)}
|
| 1503 |
onSuccess={(newUser, key) => {
|
| 1504 |
setUser(newUser);
|
|
|
|
| 1505 |
setDemo(false);
|
| 1506 |
setAuthOpen(false);
|
| 1507 |
if (key) setSecret(key);
|
|
@@ -1616,7 +1803,7 @@ export default function Dashboard() {
|
|
| 1616 |
<div className="modal-body form">
|
| 1617 |
<div className="detail-summary">
|
| 1618 |
<Badge tone={selectedLog.status < 400 ? "green" : "amber"}>
|
| 1619 |
-
HTTP {selectedLog.status}
|
| 1620 |
</Badge>
|
| 1621 |
<code>{selectedLog.id}</code>
|
| 1622 |
<CopyButton value={selectedLog.id} onCopy={notify} />
|
|
@@ -1632,7 +1819,7 @@ export default function Dashboard() {
|
|
| 1632 |
</div>
|
| 1633 |
<div>
|
| 1634 |
<dt>Latency</dt>
|
| 1635 |
-
<dd>{
|
| 1636 |
</div>
|
| 1637 |
<div>
|
| 1638 |
<dt>Tokens · input / output</dt>
|
|
@@ -1661,9 +1848,9 @@ export default function Dashboard() {
|
|
| 1661 |
<small>{a.model}</small>
|
| 1662 |
</div>
|
| 1663 |
<Badge tone={a.status < 400 ? "green" : "amber"}>
|
| 1664 |
-
{a.status}
|
| 1665 |
</Badge>
|
| 1666 |
-
<span className="mono">{
|
| 1667 |
</div>
|
| 1668 |
))}
|
| 1669 |
{!selectedLog.attempts.length && (
|
|
@@ -1671,7 +1858,18 @@ export default function Dashboard() {
|
|
| 1671 |
)}
|
| 1672 |
</div>
|
| 1673 |
{selectedLog.error_code && (
|
| 1674 |
-
<Banner tone="error">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1675 |
)}
|
| 1676 |
</div>
|
| 1677 |
</Modal>
|
|
|
|
| 48 |
num,
|
| 49 |
money,
|
| 50 |
stamp,
|
| 51 |
+
duration,
|
| 52 |
} from "@/lib/api";
|
| 53 |
+
import { defaultSite, SiteOptions } from "@/lib/site";
|
| 54 |
+
import { errorGuidance } from "@/lib/errors";
|
| 55 |
import {
|
| 56 |
demoProviders,
|
| 57 |
demoModels,
|
|
|
|
| 61 |
} from "@/lib/demo";
|
| 62 |
import { AuthForm, ProviderForm, ModelForm } from "./forms";
|
| 63 |
import Playground from "./playground";
|
| 64 |
+
import AdminPanel from "./admin-panel";
|
| 65 |
import Documentation from "./documentation";
|
| 66 |
import RequestTable from "./request-table";
|
| 67 |
import {
|
|
|
|
| 89 |
| "keys"
|
| 90 |
| "requests"
|
| 91 |
| "usage"
|
| 92 |
+
| "docs"
|
| 93 |
+
| "admin";
|
| 94 |
const nav: { id: View; name: string; icon: typeof Activity }[] = [
|
| 95 |
{ id: "overview", name: "Overview", icon: LayoutDashboard },
|
| 96 |
{ id: "providers", name: "Providers", icon: Waypoints },
|
|
|
|
| 100 |
{ id: "requests", name: "Request logs", icon: Activity },
|
| 101 |
{ id: "usage", name: "Usage & analytics", icon: ChartNoAxesCombined },
|
| 102 |
{ id: "docs", name: "Documentation", icon: BookOpen },
|
| 103 |
+
{ id: "admin", name: "Administration", icon: ShieldCheck },
|
| 104 |
];
|
| 105 |
const blankUsage: Usage = {
|
| 106 |
days: 7,
|
|
|
|
| 118 |
};
|
| 119 |
|
| 120 |
export default function Dashboard() {
|
| 121 |
+
const [site, setSite] = useState<SiteOptions>(defaultSite);
|
| 122 |
+
const [revision, setRevision] = useState(0);
|
| 123 |
+
const [playgroundModel, setPlaygroundModel] = useState<Model | null>(null);
|
| 124 |
+
const [explorerModels, setExplorerModels] = useState<Model[]>([]);
|
| 125 |
+
const [explorerTotal, setExplorerTotal] = useState(0);
|
| 126 |
+
const [requestLogs, setRequestLogs] = useState<Log[]>([]);
|
| 127 |
+
const [requestTotal, setRequestTotal] = useState(0);
|
| 128 |
+
const [freeOnly, setFreeOnly] = useState(false);
|
| 129 |
const [view, setView] = useState<View>("overview"),
|
| 130 |
[user, setUser] = useState<User | null>(null),
|
| 131 |
[demo, setDemo] = useState(true),
|
|
|
|
| 190 |
};
|
| 191 |
syncHash();
|
| 192 |
window.addEventListener("hashchange", syncHash);
|
| 193 |
+
Promise.all([
|
| 194 |
+
api<User>("/auth/me").catch(() => null),
|
| 195 |
+
api<SiteOptions>("/site").catch(() => defaultSite),
|
| 196 |
+
])
|
| 197 |
+
.then(([u, options]) => {
|
| 198 |
setUser(u);
|
| 199 |
+
setDemo(!u);
|
| 200 |
+
setSite(options);
|
|
|
|
|
|
|
| 201 |
})
|
| 202 |
.finally(() => setReady(true));
|
| 203 |
return () => window.removeEventListener("hashchange", syncHash);
|
| 204 |
}, []);
|
| 205 |
useEffect(() => {
|
| 206 |
+
document.title = `${nav.find((n) => n.id === view)?.name || "Overview"} · ${site.site_name} AI Gateway`;
|
| 207 |
+
}, [view, site.site_name]);
|
| 208 |
+
useEffect(() => {
|
| 209 |
+
window.scrollTo(0, 0);
|
| 210 |
}, [view]);
|
| 211 |
useEffect(() => {
|
| 212 |
if (!mobile) return;
|
| 213 |
+
if (window.matchMedia("(min-width: 1024px)").matches) {
|
| 214 |
+
setMobile(false);
|
| 215 |
+
return;
|
| 216 |
+
}
|
| 217 |
const drawer = sidebarRef.current;
|
| 218 |
const previous = document.activeElement as HTMLElement | null;
|
| 219 |
const overflow = document.body.style.overflow;
|
|
|
|
| 279 |
setTotalLogs(l.total);
|
| 280 |
setUsage(u);
|
| 281 |
setConnectionError("");
|
| 282 |
+
setRevision((value) => value + 1);
|
| 283 |
} catch (err) {
|
| 284 |
if (err instanceof ApiError && err.status === 401) {
|
| 285 |
setUser(null);
|
|
|
|
| 299 |
let active = true;
|
| 300 |
const timer = setTimeout(() => {
|
| 301 |
api<Page<Model>>(
|
| 302 |
+
`/models?limit=100&offset=${modelPage * 100}&search=${encodeURIComponent(search)}&capability=${capability}&provider=${filterProvider}&free_only=${freeOnly}`,
|
| 303 |
)
|
| 304 |
.then((r) => {
|
| 305 |
if (active) {
|
| 306 |
+
setExplorerModels(r.data);
|
| 307 |
+
setExplorerTotal(r.total);
|
| 308 |
}
|
| 309 |
})
|
| 310 |
.catch((e) => notify(e.message, "error"));
|
|
|
|
| 313 |
active = false;
|
| 314 |
clearTimeout(timer);
|
| 315 |
};
|
| 316 |
+
}, [
|
| 317 |
+
view,
|
| 318 |
+
demo,
|
| 319 |
+
search,
|
| 320 |
+
capability,
|
| 321 |
+
filterProvider,
|
| 322 |
+
freeOnly,
|
| 323 |
+
modelPage,
|
| 324 |
+
notify,
|
| 325 |
+
revision,
|
| 326 |
+
]);
|
| 327 |
useEffect(() => {
|
| 328 |
if (demo || view !== "requests") return;
|
| 329 |
let active = true;
|
| 330 |
api<Page<Log>>(`/logs?offset=${logPage * 25}&errors_only=${errorsOnly}`)
|
| 331 |
.then((r) => {
|
| 332 |
if (active) {
|
| 333 |
+
setRequestLogs(r.data);
|
| 334 |
+
setRequestTotal(r.total);
|
| 335 |
}
|
| 336 |
})
|
| 337 |
.catch((e) => notify(e.message, "error"));
|
| 338 |
return () => {
|
| 339 |
active = false;
|
| 340 |
};
|
| 341 |
+
}, [view, demo, logPage, errorsOnly, notify, revision]);
|
| 342 |
const p = demo ? demoProviders : providers,
|
| 343 |
m = demo ? demoModels : models,
|
| 344 |
k = demo ? demoKeys : keys,
|
| 345 |
l = demo ? demoLogs : logs,
|
| 346 |
u = demo ? demoUsage : usage;
|
| 347 |
const displayedModels = demo
|
| 348 |
+
? m
|
| 349 |
+
.filter(
|
| 350 |
+
(x) =>
|
| 351 |
+
(!search ||
|
| 352 |
+
[x.name, x.model_id].some((v) =>
|
| 353 |
+
v.toLowerCase().includes(search.toLowerCase()),
|
| 354 |
+
)) &&
|
| 355 |
+
(!capability || x.capabilities[capability]) &&
|
| 356 |
+
(!filterProvider || x.provider_id === filterProvider),
|
| 357 |
+
// Sample filtering follows the same free-price rule as the live catalog.
|
| 358 |
+
)
|
| 359 |
+
.filter(
|
| 360 |
+
(x) =>
|
| 361 |
+
!freeOnly ||
|
| 362 |
+
x.shared ||
|
| 363 |
+
(x.input_price === 0 && x.output_price === 0),
|
| 364 |
+
)
|
| 365 |
+
: explorerModels;
|
| 366 |
const s = u.summary,
|
| 367 |
successRate = s.requests
|
| 368 |
? (((s.successes || 0) / s.requests) * 100).toFixed(1)
|
|
|
|
| 422 |
/>
|
| 423 |
<Metric
|
| 424 |
label="Average latency"
|
| 425 |
+
value={duration(s.avg_latency_ms)}
|
|
|
|
| 426 |
icon={Zap}
|
| 427 |
detail="End-to-end request duration"
|
| 428 |
loading={!ready || busy}
|
|
|
|
| 444 |
/>
|
| 445 |
</div>
|
| 446 |
);
|
| 447 |
+
if (!ready)
|
| 448 |
+
return (
|
| 449 |
+
<main className="initial-loading" aria-busy="true">
|
| 450 |
+
<h1>Loading your workspace…</h1>
|
| 451 |
+
<Skeleton className="skeleton-chart" />
|
| 452 |
+
</main>
|
| 453 |
+
);
|
| 454 |
return (
|
| 455 |
+
<div className="app-shell" data-accent={site.accent}>
|
| 456 |
<a href="#main-content" className="skip-link">
|
| 457 |
Skip to content
|
| 458 |
</a>
|
|
|
|
| 483 |
className="brand"
|
| 484 |
onClick={() => navigate("overview")}
|
| 485 |
>
|
| 486 |
+
<span className="brand-mark">
|
| 487 |
+
{site.site_name.slice(0, 1).toUpperCase()}
|
| 488 |
+
</span>
|
| 489 |
<span>
|
| 490 |
+
{site.site_name.toLowerCase()}
|
| 491 |
+
<span className="brand-period">.</span>
|
| 492 |
</span>
|
| 493 |
<Badge>GATEWAY</Badge>
|
| 494 |
</a>
|
|
|
|
| 522 |
</a>
|
| 523 |
))}
|
| 524 |
</nav>
|
| 525 |
+
{user?.is_admin && (
|
| 526 |
+
<a
|
| 527 |
+
href="#admin"
|
| 528 |
+
onClick={() => setMobile(false)}
|
| 529 |
+
className={`nav-link ${view === "admin" ? "selected" : ""}`}
|
| 530 |
+
aria-current={view === "admin" ? "page" : undefined}
|
| 531 |
+
>
|
| 532 |
+
<ShieldCheck size={18} />
|
| 533 |
+
Administration
|
| 534 |
+
</a>
|
| 535 |
+
)}
|
| 536 |
<div className="sidebar-bottom">
|
| 537 |
<a
|
| 538 |
className={`nav-link ${view === "docs" ? "selected" : ""}`}
|
|
|
|
| 564 |
setUser(null);
|
| 565 |
setDemo(true);
|
| 566 |
setSecret("");
|
| 567 |
+
setPlaygroundModel(null);
|
| 568 |
notify("Signed out");
|
| 569 |
})
|
| 570 |
.catch((e) => notify(e.message, "error"));
|
|
|
|
| 584 |
className="icon-button menu-toggle"
|
| 585 |
aria-controls="workspace-navigation"
|
| 586 |
aria-expanded={mobile}
|
| 587 |
+
onClick={() => {
|
| 588 |
+
if (!window.matchMedia("(min-width: 1024px)").matches)
|
| 589 |
+
setMobile(true);
|
| 590 |
+
}}
|
| 591 |
aria-label="Open navigation"
|
| 592 |
>
|
| 593 |
<Menu size={22} />
|
|
|
|
| 675 |
</button>
|
| 676 |
</div>
|
| 677 |
</div>
|
| 678 |
+
{demo && (
|
| 679 |
+
<div className="workspace-notice">
|
| 680 |
+
<span>
|
| 681 |
+
You're exploring sample data. Sign in to connect providers
|
| 682 |
+
or try community models.
|
| 683 |
+
</span>
|
| 684 |
+
<button
|
| 685 |
+
className="text-button"
|
| 686 |
+
onClick={() => setAuthOpen(true)}
|
| 687 |
+
>
|
| 688 |
+
Open your workspace <ArrowRight size={15} />
|
| 689 |
+
</button>
|
| 690 |
+
</div>
|
| 691 |
+
)}
|
| 692 |
+
{!demo && !busy && !s.requests && (
|
| 693 |
+
<div className="onboarding panel">
|
| 694 |
+
<div>
|
| 695 |
+
<h2>Make your first request</h2>
|
| 696 |
+
<p>
|
| 697 |
+
Choose a community model in the Playground, or connect
|
| 698 |
+
your own provider.
|
| 699 |
+
</p>
|
| 700 |
+
</div>
|
| 701 |
+
<button
|
| 702 |
+
className="button primary"
|
| 703 |
+
onClick={() => navigate("playground")}
|
| 704 |
+
>
|
| 705 |
+
Open Playground <ArrowRight size={15} />
|
| 706 |
+
</button>
|
| 707 |
+
<button className="button" onClick={() => navigate("keys")}>
|
| 708 |
+
View your API keys
|
| 709 |
+
</button>
|
| 710 |
+
</div>
|
| 711 |
+
)}
|
| 712 |
<div className="operational-strip">
|
| 713 |
<span>
|
| 714 |
<span className="dot" />
|
| 715 |
+
{p.filter((x) => x.enabled).length} enabled{" "}
|
| 716 |
+
{p.filter((x) => x.enabled).length === 1
|
| 717 |
+
? "connection"
|
| 718 |
+
: "connections"}
|
| 719 |
</span>
|
| 720 |
<span>
|
| 721 |
<ShieldCheck size={14} />
|
|
|
|
| 772 |
</div>
|
| 773 |
<Network size={18} className="muted" />
|
| 774 |
</div>
|
| 775 |
+
<Topology
|
| 776 |
+
providers={p}
|
| 777 |
+
demo={demo}
|
| 778 |
+
siteName={site.site_name}
|
| 779 |
+
/>
|
| 780 |
</section>
|
| 781 |
</div>
|
| 782 |
<section className="panel providers-overview">
|
|
|
|
| 960 |
{provider.discovery_error && (
|
| 961 |
<Banner tone="error">{provider.discovery_error}</Banner>
|
| 962 |
)}
|
| 963 |
+
<p className="form-note provider-health-note">
|
| 964 |
+
Connection health reflects recent routing state;
|
| 965 |
+
individual models may have different access or limits.
|
| 966 |
+
</p>
|
| 967 |
<div className="provider-card-refreshed">
|
| 968 |
Catalog refreshed {stamp(provider.discovered_at)}
|
| 969 |
</div>
|
|
|
|
| 1093 |
<Search size={17} />
|
| 1094 |
<input
|
| 1095 |
aria-label="Search models"
|
| 1096 |
+
placeholder="Search models…"
|
| 1097 |
value={search}
|
| 1098 |
onChange={(e) => {
|
| 1099 |
setSearch(e.target.value);
|
|
|
|
| 1114 |
)}
|
| 1115 |
</button>
|
| 1116 |
<div className="filter-controls" id="model-filter-controls">
|
| 1117 |
+
<label className="check-label">
|
| 1118 |
+
<input
|
| 1119 |
+
type="checkbox"
|
| 1120 |
+
checked={freeOnly}
|
| 1121 |
+
onChange={(e) => {
|
| 1122 |
+
setFreeOnly(e.target.checked);
|
| 1123 |
+
setModelPage(0);
|
| 1124 |
+
}}
|
| 1125 |
+
/>
|
| 1126 |
+
Free only
|
| 1127 |
+
</label>
|
| 1128 |
<select
|
| 1129 |
aria-label="Filter capability"
|
| 1130 |
value={capability}
|
|
|
|
| 1169 |
</div>
|
| 1170 |
<div className="table-note">
|
| 1171 |
<span>
|
| 1172 |
+
{demo ? displayedModels.length : explorerTotal} models{" "}
|
| 1173 |
<span className="mid-dot">·</span> Prices in USD per million
|
| 1174 |
tokens
|
| 1175 |
</span>
|
|
|
|
| 1202 |
{model.model_id}
|
| 1203 |
</small>
|
| 1204 |
<span>{model.provider_name}</span>
|
| 1205 |
+
{model.shared && (
|
| 1206 |
+
<Badge tone="green">Community · Free</Badge>
|
| 1207 |
+
)}
|
| 1208 |
</div>
|
| 1209 |
</div>
|
| 1210 |
</td>
|
|
|
|
| 1244 |
role="cell"
|
| 1245 |
>
|
| 1246 |
<div className="row-actions">
|
| 1247 |
+
<button
|
| 1248 |
+
className="icon-button"
|
| 1249 |
+
aria-label={`Try ${model.name} in Playground`}
|
| 1250 |
+
title="Try in Playground"
|
| 1251 |
+
disabled={
|
| 1252 |
+
!model.enabled ||
|
| 1253 |
+
!model.available ||
|
| 1254 |
+
model.capabilities.chat === false
|
| 1255 |
+
}
|
| 1256 |
+
onClick={() => {
|
| 1257 |
+
setPlaygroundModel(model);
|
| 1258 |
+
navigate("playground");
|
| 1259 |
+
}}
|
| 1260 |
+
>
|
| 1261 |
+
<Terminal size={16} />
|
| 1262 |
+
</button>
|
| 1263 |
<button
|
| 1264 |
className={`icon-button ${model.favorite ? "accent" : ""}`}
|
| 1265 |
aria-label={`Favorite ${model.name}`}
|
| 1266 |
aria-pressed={model.favorite}
|
| 1267 |
+
disabled={model.owned === false}
|
| 1268 |
onClick={() =>
|
| 1269 |
requireAccount(
|
| 1270 |
() =>
|
|
|
|
| 1285 |
<button
|
| 1286 |
className="icon-button"
|
| 1287 |
aria-label={`Configure ${model.name}`}
|
| 1288 |
+
disabled={model.owned === false}
|
| 1289 |
onClick={() =>
|
| 1290 |
requireAccount(() => setModelModal(model))
|
| 1291 |
}
|
|
|
|
| 1323 |
<Pagination
|
| 1324 |
page={modelPage}
|
| 1325 |
size={100}
|
| 1326 |
+
total={demo ? displayedModels.length : explorerTotal}
|
| 1327 |
setPage={setModelPage}
|
| 1328 |
/>
|
| 1329 |
</div>
|
|
|
|
| 1332 |
)}
|
| 1333 |
{view === "playground" && (
|
| 1334 |
<Playground
|
| 1335 |
+
key={user?.id || "demo"}
|
| 1336 |
demo={demo}
|
| 1337 |
providers={p}
|
| 1338 |
models={m}
|
| 1339 |
requireAccount={requireAccount}
|
| 1340 |
notify={notify}
|
| 1341 |
onFinish={load}
|
| 1342 |
+
initialModel={playgroundModel}
|
| 1343 |
+
defaults={site}
|
| 1344 |
/>
|
| 1345 |
)}
|
| 1346 |
{view === "keys" && (
|
|
|
|
| 1363 |
<span>
|
| 1364 |
Keys are shown only once. Save each new key before closing.
|
| 1365 |
</span>
|
| 1366 |
+
<Badge>
|
| 1367 |
+
{k.filter((key) => !key.revoked).length}{" "}
|
| 1368 |
+
{k.filter((key) => !key.revoked).length === 1
|
| 1369 |
+
? "key"
|
| 1370 |
+
: "keys"}
|
| 1371 |
+
</Badge>
|
| 1372 |
</div>
|
| 1373 |
<section className="panel table-panel">
|
| 1374 |
<div className="table-scroll">
|
|
|
|
| 1510 |
<section className="panel">
|
| 1511 |
<RequestTable
|
| 1512 |
logs={
|
| 1513 |
+
demo
|
| 1514 |
+
? errorsOnly
|
| 1515 |
+
? l.filter((x) => x.status >= 400)
|
| 1516 |
+
: l
|
| 1517 |
+
: requestLogs
|
| 1518 |
}
|
| 1519 |
onSelect={setSelectedLog}
|
| 1520 |
/>
|
| 1521 |
<div className="table-footer">
|
| 1522 |
+
<span>{demo ? l.length : requestTotal} requests</span>
|
| 1523 |
<Pagination
|
| 1524 |
page={logPage}
|
| 1525 |
size={25}
|
| 1526 |
+
total={demo ? l.length : requestTotal}
|
| 1527 |
setPage={setLogPage}
|
| 1528 |
/>
|
| 1529 |
</div>
|
|
|
|
| 1616 |
{view === "docs" && (
|
| 1617 |
<Documentation endpoint={endpoint} notify={notify} />
|
| 1618 |
)}
|
| 1619 |
+
{view === "admin" &&
|
| 1620 |
+
(user?.is_admin ? (
|
| 1621 |
+
<AdminPanel
|
| 1622 |
+
user={user}
|
| 1623 |
+
onSettings={setSite}
|
| 1624 |
+
notify={notify}
|
| 1625 |
+
onConnect={() => setProviderModal("new")}
|
| 1626 |
+
onRegisterModel={() => setModelModal("new")}
|
| 1627 |
+
revision={revision}
|
| 1628 |
+
/>
|
| 1629 |
+
) : (
|
| 1630 |
+
<>
|
| 1631 |
+
<PageHeading
|
| 1632 |
+
title="Administration"
|
| 1633 |
+
subtitle="Restricted to administrator accounts."
|
| 1634 |
+
/>
|
| 1635 |
+
<Empty
|
| 1636 |
+
title="Administrator access required"
|
| 1637 |
+
headingLevel={2}
|
| 1638 |
+
text={
|
| 1639 |
+
demo
|
| 1640 |
+
? "Sign in with your administrator account to manage the site."
|
| 1641 |
+
: "Your account does not have an administrator role. Roles are granted on the gateway server."
|
| 1642 |
+
}
|
| 1643 |
+
action={demo ? "Sign in" : undefined}
|
| 1644 |
+
onAction={() => setAuthOpen(true)}
|
| 1645 |
+
/>
|
| 1646 |
+
</>
|
| 1647 |
+
))}
|
| 1648 |
<footer className="page-footer">
|
| 1649 |
<span>
|
| 1650 |
+
<span className="brand-mark tiny">
|
| 1651 |
+
{site.site_name.slice(0, 1).toUpperCase()}
|
| 1652 |
+
</span>
|
| 1653 |
+
{site.site_name} · {site.tagline}
|
| 1654 |
</span>
|
| 1655 |
<span>
|
| 1656 |
{demo
|
|
|
|
| 1684 |
)}
|
| 1685 |
{authOpen && (
|
| 1686 |
<AuthForm
|
| 1687 |
+
site={site}
|
| 1688 |
onClose={() => setAuthOpen(false)}
|
| 1689 |
onSuccess={(newUser, key) => {
|
| 1690 |
setUser(newUser);
|
| 1691 |
+
setPlaygroundModel(null);
|
| 1692 |
setDemo(false);
|
| 1693 |
setAuthOpen(false);
|
| 1694 |
if (key) setSecret(key);
|
|
|
|
| 1803 |
<div className="modal-body form">
|
| 1804 |
<div className="detail-summary">
|
| 1805 |
<Badge tone={selectedLog.status < 400 ? "green" : "amber"}>
|
| 1806 |
+
Gateway HTTP {selectedLog.status}
|
| 1807 |
</Badge>
|
| 1808 |
<code>{selectedLog.id}</code>
|
| 1809 |
<CopyButton value={selectedLog.id} onCopy={notify} />
|
|
|
|
| 1819 |
</div>
|
| 1820 |
<div>
|
| 1821 |
<dt>Latency</dt>
|
| 1822 |
+
<dd>{duration(selectedLog.latency_ms)}</dd>
|
| 1823 |
</div>
|
| 1824 |
<div>
|
| 1825 |
<dt>Tokens · input / output</dt>
|
|
|
|
| 1848 |
<small>{a.model}</small>
|
| 1849 |
</div>
|
| 1850 |
<Badge tone={a.status < 400 ? "green" : "amber"}>
|
| 1851 |
+
Provider HTTP {a.status}
|
| 1852 |
</Badge>
|
| 1853 |
+
<span className="mono">{duration(a.latency_ms)}</span>
|
| 1854 |
</div>
|
| 1855 |
))}
|
| 1856 |
{!selectedLog.attempts.length && (
|
|
|
|
| 1858 |
)}
|
| 1859 |
</div>
|
| 1860 |
{selectedLog.error_code && (
|
| 1861 |
+
<Banner tone="error">
|
| 1862 |
+
<span>
|
| 1863 |
+
{errorGuidance(
|
| 1864 |
+
selectedLog.attempts.at(-1)?.status || selectedLog.status,
|
| 1865 |
+
selectedLog.error_code,
|
| 1866 |
+
)}
|
| 1867 |
+
</span>
|
| 1868 |
+
<details>
|
| 1869 |
+
<summary>Technical details</summary>
|
| 1870 |
+
<code>{selectedLog.error_code}</code>
|
| 1871 |
+
</details>
|
| 1872 |
+
</Banner>
|
| 1873 |
)}
|
| 1874 |
</div>
|
| 1875 |
</Modal>
|
frontend/lib/api.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
| 1 |
-
export type User = {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
export type Health = {
|
| 3 |
status?: string;
|
| 4 |
latency_ms?: number;
|
|
@@ -19,6 +24,8 @@ export type Provider = {
|
|
| 19 |
health: Health;
|
| 20 |
};
|
| 21 |
export type Model = {
|
|
|
|
|
|
|
| 22 |
id: string;
|
| 23 |
model_id: string;
|
| 24 |
name: string;
|
|
@@ -141,6 +148,7 @@ export class ApiError extends Error {
|
|
| 141 |
constructor(
|
| 142 |
message: string,
|
| 143 |
public status: number,
|
|
|
|
| 144 |
) {
|
| 145 |
super(message);
|
| 146 |
}
|
|
@@ -161,6 +169,7 @@ export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
|
|
| 161 |
throw new ApiError(
|
| 162 |
error?.error?.message || `Request failed (${response.status})`,
|
| 163 |
response.status,
|
|
|
|
| 164 |
);
|
| 165 |
}
|
| 166 |
return response.status === 204
|
|
@@ -176,7 +185,42 @@ export function num(value: number | null | undefined): string {
|
|
| 176 |
}).format(value);
|
| 177 |
}
|
| 178 |
export function money(value: number | null | undefined): string {
|
| 179 |
-
return value == null
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
}
|
| 181 |
export function stamp(value: number | null | undefined): string {
|
| 182 |
return value
|
|
|
|
| 1 |
+
export type User = {
|
| 2 |
+
id: string;
|
| 3 |
+
name: string;
|
| 4 |
+
email: string;
|
| 5 |
+
is_admin?: boolean;
|
| 6 |
+
};
|
| 7 |
export type Health = {
|
| 8 |
status?: string;
|
| 9 |
latency_ms?: number;
|
|
|
|
| 24 |
health: Health;
|
| 25 |
};
|
| 26 |
export type Model = {
|
| 27 |
+
shared?: boolean;
|
| 28 |
+
owned?: boolean;
|
| 29 |
id: string;
|
| 30 |
model_id: string;
|
| 31 |
name: string;
|
|
|
|
| 148 |
constructor(
|
| 149 |
message: string,
|
| 150 |
public status: number,
|
| 151 |
+
public code?: string,
|
| 152 |
) {
|
| 153 |
super(message);
|
| 154 |
}
|
|
|
|
| 169 |
throw new ApiError(
|
| 170 |
error?.error?.message || `Request failed (${response.status})`,
|
| 171 |
response.status,
|
| 172 |
+
error?.error?.code,
|
| 173 |
);
|
| 174 |
}
|
| 175 |
return response.status === 204
|
|
|
|
| 185 |
}).format(value);
|
| 186 |
}
|
| 187 |
export function money(value: number | null | undefined): string {
|
| 188 |
+
return value == null
|
| 189 |
+
? "Unknown"
|
| 190 |
+
: `$${value.toFixed(value > 0 && value < 0.01 ? 5 : 2)}`;
|
| 191 |
+
}
|
| 192 |
+
export function duration(value: number | null | undefined): string {
|
| 193 |
+
if (value == null) return "—";
|
| 194 |
+
return value >= 1000
|
| 195 |
+
? `${(value / 1000).toFixed(value >= 10000 ? 1 : 2)} s`
|
| 196 |
+
: `${Math.round(value)} ms`;
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
export async function loadAllModels(
|
| 200 |
+
signal?: AbortSignal,
|
| 201 |
+
ownedOnly = false,
|
| 202 |
+
): Promise<Model[]> {
|
| 203 |
+
const models = new Map<string, Model>();
|
| 204 |
+
let offset = 0;
|
| 205 |
+
while (true) {
|
| 206 |
+
const page = await api<Page<Model>>(
|
| 207 |
+
`/models?limit=1000&offset=${offset}${ownedOnly ? "&owned_only=true" : ""}`,
|
| 208 |
+
{ signal },
|
| 209 |
+
);
|
| 210 |
+
page.data.forEach((model) => models.set(model.id, model));
|
| 211 |
+
offset += page.data.length;
|
| 212 |
+
if (offset >= page.total) {
|
| 213 |
+
if (models.size < page.total)
|
| 214 |
+
throw new Error(
|
| 215 |
+
"The catalog changed while loading. Refresh models to try again.",
|
| 216 |
+
);
|
| 217 |
+
return [...models.values()];
|
| 218 |
+
}
|
| 219 |
+
if (!page.data.length)
|
| 220 |
+
throw new Error(
|
| 221 |
+
"The complete catalog could not be loaded. Refresh models to try again.",
|
| 222 |
+
);
|
| 223 |
+
}
|
| 224 |
}
|
| 225 |
export function stamp(value: number | null | undefined): string {
|
| 226 |
return value
|
frontend/lib/demo.ts
CHANGED
|
@@ -116,9 +116,10 @@ export const demoUsage: Usage = {
|
|
| 116 |
};
|
| 117 |
export const demoLogs: Log[] = Array.from({ length: 8 }, (_, i) => ({
|
| 118 |
id: `req_demo_${i + 1}`,
|
| 119 |
-
provider_name:
|
| 120 |
requested_model: i % 3 === 0 ? "auto/coding" : "auto",
|
| 121 |
-
resolved_model:
|
|
|
|
| 122 |
endpoint: "chat/completions",
|
| 123 |
status: i === 5 ? 429 : 200,
|
| 124 |
latency_ms: [182, 634, 820, 241, 508, 1240, 312, 466][i],
|
|
@@ -145,8 +146,9 @@ export const demoLogs: Log[] = Array.from({ length: 8 }, (_, i) => ({
|
|
| 145 |
]
|
| 146 |
: [
|
| 147 |
{
|
| 148 |
-
provider:
|
| 149 |
-
provider_name:
|
|
|
|
| 150 |
model: demoModels[i % 6].model_id,
|
| 151 |
status: i === 5 ? 429 : 200,
|
| 152 |
latency_ms: 182,
|
|
|
|
| 116 |
};
|
| 117 |
export const demoLogs: Log[] = Array.from({ length: 8 }, (_, i) => ({
|
| 118 |
id: `req_demo_${i + 1}`,
|
| 119 |
+
provider_name: i === 2 ? "OpenRouter" : demoModels[i % 6].provider_name,
|
| 120 |
requested_model: i % 3 === 0 ? "auto/coding" : "auto",
|
| 121 |
+
resolved_model:
|
| 122 |
+
i === 2 ? "deepseek/deepseek-chat-v3.1" : demoModels[i % 6].model_id,
|
| 123 |
endpoint: "chat/completions",
|
| 124 |
status: i === 5 ? 429 : 200,
|
| 125 |
latency_ms: [182, 634, 820, 241, 508, 1240, 312, 466][i],
|
|
|
|
| 146 |
]
|
| 147 |
: [
|
| 148 |
{
|
| 149 |
+
provider: demoModels[i % 6].provider,
|
| 150 |
+
provider_name:
|
| 151 |
+
i === 2 ? "OpenRouter" : demoModels[i % 6].provider_name,
|
| 152 |
model: demoModels[i % 6].model_id,
|
| 153 |
status: i === 5 ? 429 : 200,
|
| 154 |
latency_ms: 182,
|
frontend/lib/errors.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export function errorGuidance(status?: number, code?: string): string {
|
| 2 |
+
if (code === "account_suspended")
|
| 3 |
+
return "Contact the gateway administrator to restore your account.";
|
| 4 |
+
if (code === "admin_required")
|
| 5 |
+
return "Use an account with an administrator role granted on the server.";
|
| 6 |
+
if (status === 402)
|
| 7 |
+
return "The provider requires payment or available credit. Check the provider account's billing and model access.";
|
| 8 |
+
if (status === 401 || status === 403)
|
| 9 |
+
return "Check the selected provider's credentials and access permissions. For a session error, sign in again.";
|
| 10 |
+
if (status === 429)
|
| 11 |
+
return "Wait for the rate-limit window to reset, or choose another available model.";
|
| 12 |
+
if (status === 404 || code === "no_matching_model")
|
| 13 |
+
return "Choose an available model or refresh the provider catalog. Check the required capabilities.";
|
| 14 |
+
if (status === 400 || status === 422)
|
| 15 |
+
return "Review the selected model and request settings before sending again.";
|
| 16 |
+
if (status === 413) return "Shorten the prompt or reduce the request size.";
|
| 17 |
+
return "The request could not complete. Retry later or choose another model; request details show the provider attempts.";
|
| 18 |
+
}
|
frontend/lib/site.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export type SiteOptions = {
|
| 2 |
+
site_name: string;
|
| 3 |
+
tagline: string;
|
| 4 |
+
welcome_text: string;
|
| 5 |
+
accent: "purple" | "blue" | "green";
|
| 6 |
+
default_mode: string;
|
| 7 |
+
default_stream: boolean;
|
| 8 |
+
default_retries: number;
|
| 9 |
+
shared_models_enabled: boolean;
|
| 10 |
+
};
|
| 11 |
+
export const defaultSite: SiteOptions = {
|
| 12 |
+
site_name: "Nexus",
|
| 13 |
+
tagline: "Universal AI Gateway",
|
| 14 |
+
welcome_text: "Your models. Your keys. One connection.",
|
| 15 |
+
accent: "purple",
|
| 16 |
+
default_mode: "auto",
|
| 17 |
+
default_stream: true,
|
| 18 |
+
default_retries: 2,
|
| 19 |
+
shared_models_enabled: true,
|
| 20 |
+
};
|
frontend/package-lock.json
CHANGED
|
@@ -12,7 +12,10 @@
|
|
| 12 |
"next": "16.3.5",
|
| 13 |
"react": "19.3.0",
|
| 14 |
"react-dom": "19.3.0",
|
| 15 |
-
"
|
|
|
|
|
|
|
|
|
|
| 16 |
},
|
| 17 |
"devDependencies": {
|
| 18 |
"@tailwindcss/postcss": "4.3.3",
|
|
@@ -1773,6 +1776,14 @@
|
|
| 1773 |
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
| 1774 |
"license": "MIT"
|
| 1775 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1776 |
"node_modules/@types/deep-eql": {
|
| 1777 |
"version": "4.0.2",
|
| 1778 |
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
|
@@ -1784,9 +1795,37 @@
|
|
| 1784 |
"version": "1.0.9",
|
| 1785 |
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
| 1786 |
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
|
| 1787 |
-
"dev": true,
|
| 1788 |
"license": "MIT"
|
| 1789 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1790 |
"node_modules/@types/node": {
|
| 1791 |
"version": "24.13.4",
|
| 1792 |
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.4.tgz",
|
|
@@ -1801,7 +1840,6 @@
|
|
| 1801 |
"version": "19.3.0",
|
| 1802 |
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
|
| 1803 |
"integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
|
| 1804 |
-
"devOptional": true,
|
| 1805 |
"license": "MIT",
|
| 1806 |
"dependencies": {
|
| 1807 |
"csstype": "^3.2.2"
|
|
@@ -1817,12 +1855,22 @@
|
|
| 1817 |
"@types/react": "^19.3.0"
|
| 1818 |
}
|
| 1819 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1820 |
"node_modules/@types/use-sync-external-store": {
|
| 1821 |
"version": "0.0.6",
|
| 1822 |
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
| 1823 |
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
| 1824 |
"license": "MIT"
|
| 1825 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1826 |
"node_modules/@vitest/mocker": {
|
| 1827 |
"version": "5.0.0",
|
| 1828 |
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.0.tgz",
|
|
@@ -1926,6 +1974,15 @@
|
|
| 1926 |
"node": ">=4"
|
| 1927 |
}
|
| 1928 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1929 |
"node_modules/baseline-browser-mapping": {
|
| 1930 |
"version": "2.11.22",
|
| 1931 |
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.22.tgz",
|
|
@@ -1968,6 +2025,15 @@
|
|
| 1968 |
],
|
| 1969 |
"license": "CC-BY-4.0"
|
| 1970 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1971 |
"node_modules/chai": {
|
| 1972 |
"version": "6.2.2",
|
| 1973 |
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
|
@@ -1978,6 +2044,42 @@
|
|
| 1978 |
"node": ">=18"
|
| 1979 |
}
|
| 1980 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1981 |
"node_modules/client-only": {
|
| 1982 |
"version": "0.0.1",
|
| 1983 |
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
|
|
@@ -1993,6 +2095,15 @@
|
|
| 1993 |
"node": ">=6"
|
| 1994 |
}
|
| 1995 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1996 |
"node_modules/css-tree": {
|
| 1997 |
"version": "3.2.1",
|
| 1998 |
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
|
|
@@ -2018,7 +2129,6 @@
|
|
| 2018 |
"version": "3.2.3",
|
| 2019 |
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
| 2020 |
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
| 2021 |
-
"devOptional": true,
|
| 2022 |
"license": "MIT"
|
| 2023 |
},
|
| 2024 |
"node_modules/d3-array": {
|
|
@@ -2171,6 +2281,22 @@
|
|
| 2171 |
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
| 2172 |
}
|
| 2173 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2174 |
"node_modules/decimal.js": {
|
| 2175 |
"version": "10.6.0",
|
| 2176 |
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
|
@@ -2184,11 +2310,22 @@
|
|
| 2184 |
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
| 2185 |
"license": "MIT"
|
| 2186 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2187 |
"node_modules/dequal": {
|
| 2188 |
"version": "2.0.3",
|
| 2189 |
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
| 2190 |
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
| 2191 |
-
"dev": true,
|
| 2192 |
"license": "MIT",
|
| 2193 |
"engines": {
|
| 2194 |
"node": ">=6"
|
|
@@ -2204,6 +2341,18 @@
|
|
| 2204 |
"node": ">=8"
|
| 2205 |
}
|
| 2206 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2207 |
"node_modules/dom-accessibility-api": {
|
| 2208 |
"version": "0.5.16",
|
| 2209 |
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
|
@@ -2258,6 +2407,26 @@
|
|
| 2258 |
"tests/browser-compat"
|
| 2259 |
]
|
| 2260 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2261 |
"node_modules/estree-walker": {
|
| 2262 |
"version": "3.0.3",
|
| 2263 |
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
|
@@ -2284,6 +2453,11 @@
|
|
| 2284 |
"node": ">=12.0.0"
|
| 2285 |
}
|
| 2286 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2287 |
"node_modules/fdir": {
|
| 2288 |
"version": "6.5.0",
|
| 2289 |
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
|
@@ -2325,6 +2499,79 @@
|
|
| 2325 |
"dev": true,
|
| 2326 |
"license": "ISC"
|
| 2327 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2328 |
"node_modules/html-encoding-sniffer": {
|
| 2329 |
"version": "6.0.0",
|
| 2330 |
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
|
|
@@ -2338,6 +2585,15 @@
|
|
| 2338 |
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
| 2339 |
}
|
| 2340 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2341 |
"node_modules/immer": {
|
| 2342 |
"version": "11.1.18",
|
| 2343 |
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.18.tgz",
|
|
@@ -2358,6 +2614,11 @@
|
|
| 2358 |
"node": ">=8"
|
| 2359 |
}
|
| 2360 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2361 |
"node_modules/internmap": {
|
| 2362 |
"version": "2.0.3",
|
| 2363 |
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
|
@@ -2367,6 +2628,57 @@
|
|
| 2367 |
"node": ">=12"
|
| 2368 |
}
|
| 2369 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2370 |
"node_modules/is-potential-custom-element-name": {
|
| 2371 |
"version": "1.0.1",
|
| 2372 |
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
|
|
@@ -2673,73 +2985,895 @@
|
|
| 2673 |
"url": "https://opencollective.com/parcel"
|
| 2674 |
}
|
| 2675 |
},
|
| 2676 |
-
"node_modules/lightningcss-win32-x64-msvc": {
|
| 2677 |
-
"version": "1.32.0",
|
| 2678 |
-
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
|
| 2679 |
-
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
|
| 2680 |
-
"cpu": [
|
| 2681 |
-
"x64"
|
| 2682 |
-
],
|
| 2683 |
-
"dev": true,
|
| 2684 |
-
"license": "MPL-2.0",
|
| 2685 |
-
"optional": true,
|
| 2686 |
-
"os": [
|
| 2687 |
-
"win32"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2688 |
],
|
| 2689 |
-
"
|
| 2690 |
-
"
|
| 2691 |
-
|
| 2692 |
-
|
| 2693 |
-
"
|
| 2694 |
-
"url": "https://opencollective.com/parcel"
|
| 2695 |
}
|
| 2696 |
},
|
| 2697 |
-
"node_modules/
|
| 2698 |
-
"version": "
|
| 2699 |
-
"resolved": "https://registry.npmjs.org/
|
| 2700 |
-
"integrity": "sha512-
|
| 2701 |
-
"
|
| 2702 |
-
|
| 2703 |
-
|
| 2704 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2705 |
}
|
| 2706 |
},
|
| 2707 |
-
"node_modules/
|
| 2708 |
-
"version": "
|
| 2709 |
-
"resolved": "https://registry.npmjs.org/
|
| 2710 |
-
"integrity": "sha512-
|
| 2711 |
-
"
|
| 2712 |
-
|
| 2713 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2714 |
}
|
| 2715 |
},
|
| 2716 |
-
"node_modules/
|
| 2717 |
-
"version": "
|
| 2718 |
-
"resolved": "https://registry.npmjs.org/
|
| 2719 |
-
"integrity": "sha512-
|
| 2720 |
-
"
|
| 2721 |
-
|
| 2722 |
-
|
| 2723 |
-
|
| 2724 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2725 |
}
|
| 2726 |
},
|
| 2727 |
-
"node_modules/
|
| 2728 |
-
"version": "
|
| 2729 |
-
"resolved": "https://registry.npmjs.org/
|
| 2730 |
-
"integrity": "sha512-
|
| 2731 |
-
"
|
| 2732 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2733 |
"dependencies": {
|
| 2734 |
-
"
|
|
|
|
|
|
|
|
|
|
| 2735 |
}
|
| 2736 |
},
|
| 2737 |
-
"node_modules/
|
| 2738 |
-
"version": "2.
|
| 2739 |
-
"resolved": "https://registry.npmjs.org/
|
| 2740 |
-
"integrity": "sha512-
|
| 2741 |
-
"
|
| 2742 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2743 |
},
|
| 2744 |
"node_modules/min-indent": {
|
| 2745 |
"version": "1.0.1",
|
|
@@ -2751,6 +3885,11 @@
|
|
| 2751 |
"node": ">=4"
|
| 2752 |
}
|
| 2753 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2754 |
"node_modules/nanoid": {
|
| 2755 |
"version": "3.3.19",
|
| 2756 |
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
|
|
@@ -2864,6 +4003,29 @@
|
|
| 2864 |
"node": ">=12.20.0"
|
| 2865 |
}
|
| 2866 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2867 |
"node_modules/parse5": {
|
| 2868 |
"version": "8.0.1",
|
| 2869 |
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
|
|
@@ -2949,6 +4111,15 @@
|
|
| 2949 |
"license": "MIT",
|
| 2950 |
"peer": true
|
| 2951 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2952 |
"node_modules/punycode": {
|
| 2953 |
"version": "2.3.1",
|
| 2954 |
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
|
@@ -2987,6 +4158,32 @@
|
|
| 2987 |
"license": "MIT",
|
| 2988 |
"peer": true
|
| 2989 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2990 |
"node_modules/react-redux": {
|
| 2991 |
"version": "9.3.0",
|
| 2992 |
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
|
|
@@ -3069,6 +4266,84 @@
|
|
| 3069 |
"redux": "^5.0.0"
|
| 3070 |
}
|
| 3071 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3072 |
"node_modules/require-from-string": {
|
| 3073 |
"version": "2.0.2",
|
| 3074 |
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
|
@@ -3218,6 +4493,15 @@
|
|
| 3218 |
"node": ">=0.10.0"
|
| 3219 |
}
|
| 3220 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3221 |
"node_modules/stackback": {
|
| 3222 |
"version": "0.0.2",
|
| 3223 |
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
|
@@ -3232,6 +4516,19 @@
|
|
| 3232 |
"dev": true,
|
| 3233 |
"license": "MIT"
|
| 3234 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3235 |
"node_modules/strip-indent": {
|
| 3236 |
"version": "3.0.0",
|
| 3237 |
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
|
|
@@ -3245,6 +4542,22 @@
|
|
| 3245 |
"node": ">=8"
|
| 3246 |
}
|
| 3247 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3248 |
"node_modules/styled-jsx": {
|
| 3249 |
"version": "5.1.6",
|
| 3250 |
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
|
|
@@ -3385,6 +4698,24 @@
|
|
| 3385 |
"node": ">=20"
|
| 3386 |
}
|
| 3387 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3388 |
"node_modules/tslib": {
|
| 3389 |
"version": "2.8.1",
|
| 3390 |
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
|
@@ -3422,6 +4753,100 @@
|
|
| 3422 |
"dev": true,
|
| 3423 |
"license": "MIT"
|
| 3424 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3425 |
"node_modules/use-sync-external-store": {
|
| 3426 |
"version": "1.7.0",
|
| 3427 |
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz",
|
|
@@ -3431,6 +4856,32 @@
|
|
| 3431 |
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
| 3432 |
}
|
| 3433 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3434 |
"node_modules/victory-vendor": {
|
| 3435 |
"version": "37.3.6",
|
| 3436 |
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
|
@@ -3979,6 +5430,15 @@
|
|
| 3979 |
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
| 3980 |
"dev": true,
|
| 3981 |
"license": "MIT"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3982 |
}
|
| 3983 |
}
|
| 3984 |
}
|
|
|
|
| 12 |
"next": "16.3.5",
|
| 13 |
"react": "19.3.0",
|
| 14 |
"react-dom": "19.3.0",
|
| 15 |
+
"react-markdown": "^10.1.0",
|
| 16 |
+
"recharts": "^3.8.0",
|
| 17 |
+
"rehype-highlight": "^7.0.2",
|
| 18 |
+
"remark-gfm": "^4.0.1"
|
| 19 |
},
|
| 20 |
"devDependencies": {
|
| 21 |
"@tailwindcss/postcss": "4.3.3",
|
|
|
|
| 1776 |
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
| 1777 |
"license": "MIT"
|
| 1778 |
},
|
| 1779 |
+
"node_modules/@types/debug": {
|
| 1780 |
+
"version": "4.1.13",
|
| 1781 |
+
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
|
| 1782 |
+
"integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==",
|
| 1783 |
+
"dependencies": {
|
| 1784 |
+
"@types/ms": "*"
|
| 1785 |
+
}
|
| 1786 |
+
},
|
| 1787 |
"node_modules/@types/deep-eql": {
|
| 1788 |
"version": "4.0.2",
|
| 1789 |
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
|
|
|
| 1795 |
"version": "1.0.9",
|
| 1796 |
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
|
| 1797 |
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
|
|
|
|
| 1798 |
"license": "MIT"
|
| 1799 |
},
|
| 1800 |
+
"node_modules/@types/estree-jsx": {
|
| 1801 |
+
"version": "1.0.5",
|
| 1802 |
+
"resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz",
|
| 1803 |
+
"integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==",
|
| 1804 |
+
"dependencies": {
|
| 1805 |
+
"@types/estree": "*"
|
| 1806 |
+
}
|
| 1807 |
+
},
|
| 1808 |
+
"node_modules/@types/hast": {
|
| 1809 |
+
"version": "3.0.5",
|
| 1810 |
+
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz",
|
| 1811 |
+
"integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==",
|
| 1812 |
+
"dependencies": {
|
| 1813 |
+
"@types/unist": "*"
|
| 1814 |
+
}
|
| 1815 |
+
},
|
| 1816 |
+
"node_modules/@types/mdast": {
|
| 1817 |
+
"version": "4.0.4",
|
| 1818 |
+
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
|
| 1819 |
+
"integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==",
|
| 1820 |
+
"dependencies": {
|
| 1821 |
+
"@types/unist": "*"
|
| 1822 |
+
}
|
| 1823 |
+
},
|
| 1824 |
+
"node_modules/@types/ms": {
|
| 1825 |
+
"version": "2.1.0",
|
| 1826 |
+
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
| 1827 |
+
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="
|
| 1828 |
+
},
|
| 1829 |
"node_modules/@types/node": {
|
| 1830 |
"version": "24.13.4",
|
| 1831 |
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.4.tgz",
|
|
|
|
| 1840 |
"version": "19.3.0",
|
| 1841 |
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
|
| 1842 |
"integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
|
|
|
|
| 1843 |
"license": "MIT",
|
| 1844 |
"dependencies": {
|
| 1845 |
"csstype": "^3.2.2"
|
|
|
|
| 1855 |
"@types/react": "^19.3.0"
|
| 1856 |
}
|
| 1857 |
},
|
| 1858 |
+
"node_modules/@types/unist": {
|
| 1859 |
+
"version": "3.0.3",
|
| 1860 |
+
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
| 1861 |
+
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="
|
| 1862 |
+
},
|
| 1863 |
"node_modules/@types/use-sync-external-store": {
|
| 1864 |
"version": "0.0.6",
|
| 1865 |
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
| 1866 |
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
| 1867 |
"license": "MIT"
|
| 1868 |
},
|
| 1869 |
+
"node_modules/@ungap/structured-clone": {
|
| 1870 |
+
"version": "1.4.0",
|
| 1871 |
+
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz",
|
| 1872 |
+
"integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ=="
|
| 1873 |
+
},
|
| 1874 |
"node_modules/@vitest/mocker": {
|
| 1875 |
"version": "5.0.0",
|
| 1876 |
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.0.tgz",
|
|
|
|
| 1974 |
"node": ">=4"
|
| 1975 |
}
|
| 1976 |
},
|
| 1977 |
+
"node_modules/bail": {
|
| 1978 |
+
"version": "2.0.2",
|
| 1979 |
+
"resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz",
|
| 1980 |
+
"integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==",
|
| 1981 |
+
"funding": {
|
| 1982 |
+
"type": "github",
|
| 1983 |
+
"url": "https://github.com/sponsors/wooorm"
|
| 1984 |
+
}
|
| 1985 |
+
},
|
| 1986 |
"node_modules/baseline-browser-mapping": {
|
| 1987 |
"version": "2.11.22",
|
| 1988 |
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.22.tgz",
|
|
|
|
| 2025 |
],
|
| 2026 |
"license": "CC-BY-4.0"
|
| 2027 |
},
|
| 2028 |
+
"node_modules/ccount": {
|
| 2029 |
+
"version": "2.0.1",
|
| 2030 |
+
"resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
|
| 2031 |
+
"integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==",
|
| 2032 |
+
"funding": {
|
| 2033 |
+
"type": "github",
|
| 2034 |
+
"url": "https://github.com/sponsors/wooorm"
|
| 2035 |
+
}
|
| 2036 |
+
},
|
| 2037 |
"node_modules/chai": {
|
| 2038 |
"version": "6.2.2",
|
| 2039 |
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
|
|
|
| 2044 |
"node": ">=18"
|
| 2045 |
}
|
| 2046 |
},
|
| 2047 |
+
"node_modules/character-entities": {
|
| 2048 |
+
"version": "2.0.2",
|
| 2049 |
+
"resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz",
|
| 2050 |
+
"integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==",
|
| 2051 |
+
"funding": {
|
| 2052 |
+
"type": "github",
|
| 2053 |
+
"url": "https://github.com/sponsors/wooorm"
|
| 2054 |
+
}
|
| 2055 |
+
},
|
| 2056 |
+
"node_modules/character-entities-html4": {
|
| 2057 |
+
"version": "2.1.0",
|
| 2058 |
+
"resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz",
|
| 2059 |
+
"integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==",
|
| 2060 |
+
"funding": {
|
| 2061 |
+
"type": "github",
|
| 2062 |
+
"url": "https://github.com/sponsors/wooorm"
|
| 2063 |
+
}
|
| 2064 |
+
},
|
| 2065 |
+
"node_modules/character-entities-legacy": {
|
| 2066 |
+
"version": "3.0.0",
|
| 2067 |
+
"resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz",
|
| 2068 |
+
"integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==",
|
| 2069 |
+
"funding": {
|
| 2070 |
+
"type": "github",
|
| 2071 |
+
"url": "https://github.com/sponsors/wooorm"
|
| 2072 |
+
}
|
| 2073 |
+
},
|
| 2074 |
+
"node_modules/character-reference-invalid": {
|
| 2075 |
+
"version": "2.0.1",
|
| 2076 |
+
"resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz",
|
| 2077 |
+
"integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==",
|
| 2078 |
+
"funding": {
|
| 2079 |
+
"type": "github",
|
| 2080 |
+
"url": "https://github.com/sponsors/wooorm"
|
| 2081 |
+
}
|
| 2082 |
+
},
|
| 2083 |
"node_modules/client-only": {
|
| 2084 |
"version": "0.0.1",
|
| 2085 |
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
|
|
|
|
| 2095 |
"node": ">=6"
|
| 2096 |
}
|
| 2097 |
},
|
| 2098 |
+
"node_modules/comma-separated-tokens": {
|
| 2099 |
+
"version": "2.0.3",
|
| 2100 |
+
"resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
|
| 2101 |
+
"integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==",
|
| 2102 |
+
"funding": {
|
| 2103 |
+
"type": "github",
|
| 2104 |
+
"url": "https://github.com/sponsors/wooorm"
|
| 2105 |
+
}
|
| 2106 |
+
},
|
| 2107 |
"node_modules/css-tree": {
|
| 2108 |
"version": "3.2.1",
|
| 2109 |
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
|
|
|
|
| 2129 |
"version": "3.2.3",
|
| 2130 |
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
| 2131 |
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
|
|
|
| 2132 |
"license": "MIT"
|
| 2133 |
},
|
| 2134 |
"node_modules/d3-array": {
|
|
|
|
| 2281 |
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
| 2282 |
}
|
| 2283 |
},
|
| 2284 |
+
"node_modules/debug": {
|
| 2285 |
+
"version": "4.4.3",
|
| 2286 |
+
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
| 2287 |
+
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
| 2288 |
+
"dependencies": {
|
| 2289 |
+
"ms": "^2.1.3"
|
| 2290 |
+
},
|
| 2291 |
+
"engines": {
|
| 2292 |
+
"node": ">=6.0"
|
| 2293 |
+
},
|
| 2294 |
+
"peerDependenciesMeta": {
|
| 2295 |
+
"supports-color": {
|
| 2296 |
+
"optional": true
|
| 2297 |
+
}
|
| 2298 |
+
}
|
| 2299 |
+
},
|
| 2300 |
"node_modules/decimal.js": {
|
| 2301 |
"version": "10.6.0",
|
| 2302 |
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
|
|
|
| 2310 |
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
| 2311 |
"license": "MIT"
|
| 2312 |
},
|
| 2313 |
+
"node_modules/decode-named-character-reference": {
|
| 2314 |
+
"version": "1.3.0",
|
| 2315 |
+
"resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
|
| 2316 |
+
"integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==",
|
| 2317 |
+
"dependencies": {
|
| 2318 |
+
"character-entities": "^2.0.0"
|
| 2319 |
+
},
|
| 2320 |
+
"funding": {
|
| 2321 |
+
"type": "github",
|
| 2322 |
+
"url": "https://github.com/sponsors/wooorm"
|
| 2323 |
+
}
|
| 2324 |
+
},
|
| 2325 |
"node_modules/dequal": {
|
| 2326 |
"version": "2.0.3",
|
| 2327 |
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
|
| 2328 |
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
|
|
|
|
| 2329 |
"license": "MIT",
|
| 2330 |
"engines": {
|
| 2331 |
"node": ">=6"
|
|
|
|
| 2341 |
"node": ">=8"
|
| 2342 |
}
|
| 2343 |
},
|
| 2344 |
+
"node_modules/devlop": {
|
| 2345 |
+
"version": "1.1.0",
|
| 2346 |
+
"resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
|
| 2347 |
+
"integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==",
|
| 2348 |
+
"dependencies": {
|
| 2349 |
+
"dequal": "^2.0.0"
|
| 2350 |
+
},
|
| 2351 |
+
"funding": {
|
| 2352 |
+
"type": "github",
|
| 2353 |
+
"url": "https://github.com/sponsors/wooorm"
|
| 2354 |
+
}
|
| 2355 |
+
},
|
| 2356 |
"node_modules/dom-accessibility-api": {
|
| 2357 |
"version": "0.5.16",
|
| 2358 |
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
|
|
|
| 2407 |
"tests/browser-compat"
|
| 2408 |
]
|
| 2409 |
},
|
| 2410 |
+
"node_modules/escape-string-regexp": {
|
| 2411 |
+
"version": "5.0.0",
|
| 2412 |
+
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
|
| 2413 |
+
"integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
|
| 2414 |
+
"engines": {
|
| 2415 |
+
"node": ">=12"
|
| 2416 |
+
},
|
| 2417 |
+
"funding": {
|
| 2418 |
+
"url": "https://github.com/sponsors/sindresorhus"
|
| 2419 |
+
}
|
| 2420 |
+
},
|
| 2421 |
+
"node_modules/estree-util-is-identifier-name": {
|
| 2422 |
+
"version": "3.0.0",
|
| 2423 |
+
"resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz",
|
| 2424 |
+
"integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==",
|
| 2425 |
+
"funding": {
|
| 2426 |
+
"type": "opencollective",
|
| 2427 |
+
"url": "https://opencollective.com/unified"
|
| 2428 |
+
}
|
| 2429 |
+
},
|
| 2430 |
"node_modules/estree-walker": {
|
| 2431 |
"version": "3.0.3",
|
| 2432 |
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
|
|
|
| 2453 |
"node": ">=12.0.0"
|
| 2454 |
}
|
| 2455 |
},
|
| 2456 |
+
"node_modules/extend": {
|
| 2457 |
+
"version": "3.0.2",
|
| 2458 |
+
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
| 2459 |
+
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="
|
| 2460 |
+
},
|
| 2461 |
"node_modules/fdir": {
|
| 2462 |
"version": "6.5.0",
|
| 2463 |
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
|
|
|
| 2499 |
"dev": true,
|
| 2500 |
"license": "ISC"
|
| 2501 |
},
|
| 2502 |
+
"node_modules/hast-util-is-element": {
|
| 2503 |
+
"version": "3.0.0",
|
| 2504 |
+
"resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz",
|
| 2505 |
+
"integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==",
|
| 2506 |
+
"dependencies": {
|
| 2507 |
+
"@types/hast": "^3.0.0"
|
| 2508 |
+
},
|
| 2509 |
+
"funding": {
|
| 2510 |
+
"type": "opencollective",
|
| 2511 |
+
"url": "https://opencollective.com/unified"
|
| 2512 |
+
}
|
| 2513 |
+
},
|
| 2514 |
+
"node_modules/hast-util-to-jsx-runtime": {
|
| 2515 |
+
"version": "2.3.6",
|
| 2516 |
+
"resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz",
|
| 2517 |
+
"integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==",
|
| 2518 |
+
"dependencies": {
|
| 2519 |
+
"@types/estree": "^1.0.0",
|
| 2520 |
+
"@types/hast": "^3.0.0",
|
| 2521 |
+
"@types/unist": "^3.0.0",
|
| 2522 |
+
"comma-separated-tokens": "^2.0.0",
|
| 2523 |
+
"devlop": "^1.0.0",
|
| 2524 |
+
"estree-util-is-identifier-name": "^3.0.0",
|
| 2525 |
+
"hast-util-whitespace": "^3.0.0",
|
| 2526 |
+
"mdast-util-mdx-expression": "^2.0.0",
|
| 2527 |
+
"mdast-util-mdx-jsx": "^3.0.0",
|
| 2528 |
+
"mdast-util-mdxjs-esm": "^2.0.0",
|
| 2529 |
+
"property-information": "^7.0.0",
|
| 2530 |
+
"space-separated-tokens": "^2.0.0",
|
| 2531 |
+
"style-to-js": "^1.0.0",
|
| 2532 |
+
"unist-util-position": "^5.0.0",
|
| 2533 |
+
"vfile-message": "^4.0.0"
|
| 2534 |
+
},
|
| 2535 |
+
"funding": {
|
| 2536 |
+
"type": "opencollective",
|
| 2537 |
+
"url": "https://opencollective.com/unified"
|
| 2538 |
+
}
|
| 2539 |
+
},
|
| 2540 |
+
"node_modules/hast-util-to-text": {
|
| 2541 |
+
"version": "4.0.2",
|
| 2542 |
+
"resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz",
|
| 2543 |
+
"integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==",
|
| 2544 |
+
"dependencies": {
|
| 2545 |
+
"@types/hast": "^3.0.0",
|
| 2546 |
+
"@types/unist": "^3.0.0",
|
| 2547 |
+
"hast-util-is-element": "^3.0.0",
|
| 2548 |
+
"unist-util-find-after": "^5.0.0"
|
| 2549 |
+
},
|
| 2550 |
+
"funding": {
|
| 2551 |
+
"type": "opencollective",
|
| 2552 |
+
"url": "https://opencollective.com/unified"
|
| 2553 |
+
}
|
| 2554 |
+
},
|
| 2555 |
+
"node_modules/hast-util-whitespace": {
|
| 2556 |
+
"version": "3.0.0",
|
| 2557 |
+
"resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz",
|
| 2558 |
+
"integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==",
|
| 2559 |
+
"dependencies": {
|
| 2560 |
+
"@types/hast": "^3.0.0"
|
| 2561 |
+
},
|
| 2562 |
+
"funding": {
|
| 2563 |
+
"type": "opencollective",
|
| 2564 |
+
"url": "https://opencollective.com/unified"
|
| 2565 |
+
}
|
| 2566 |
+
},
|
| 2567 |
+
"node_modules/highlight.js": {
|
| 2568 |
+
"version": "11.11.2",
|
| 2569 |
+
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.2.tgz",
|
| 2570 |
+
"integrity": "sha512-oaXMACAU0kzOMXBjWpNcX+vlwSBCIAiZ9BHa7gA15NOTtT2L/l8OSZDuqS2XppOhZBPJ7hm4o8ep2kyuip2uEQ==",
|
| 2571 |
+
"engines": {
|
| 2572 |
+
"node": ">=12.0.0"
|
| 2573 |
+
}
|
| 2574 |
+
},
|
| 2575 |
"node_modules/html-encoding-sniffer": {
|
| 2576 |
"version": "6.0.0",
|
| 2577 |
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
|
|
|
|
| 2585 |
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
| 2586 |
}
|
| 2587 |
},
|
| 2588 |
+
"node_modules/html-url-attributes": {
|
| 2589 |
+
"version": "3.0.1",
|
| 2590 |
+
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
|
| 2591 |
+
"integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==",
|
| 2592 |
+
"funding": {
|
| 2593 |
+
"type": "opencollective",
|
| 2594 |
+
"url": "https://opencollective.com/unified"
|
| 2595 |
+
}
|
| 2596 |
+
},
|
| 2597 |
"node_modules/immer": {
|
| 2598 |
"version": "11.1.18",
|
| 2599 |
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.18.tgz",
|
|
|
|
| 2614 |
"node": ">=8"
|
| 2615 |
}
|
| 2616 |
},
|
| 2617 |
+
"node_modules/inline-style-parser": {
|
| 2618 |
+
"version": "0.2.7",
|
| 2619 |
+
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
|
| 2620 |
+
"integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="
|
| 2621 |
+
},
|
| 2622 |
"node_modules/internmap": {
|
| 2623 |
"version": "2.0.3",
|
| 2624 |
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
|
|
|
| 2628 |
"node": ">=12"
|
| 2629 |
}
|
| 2630 |
},
|
| 2631 |
+
"node_modules/is-alphabetical": {
|
| 2632 |
+
"version": "2.0.1",
|
| 2633 |
+
"resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
|
| 2634 |
+
"integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==",
|
| 2635 |
+
"funding": {
|
| 2636 |
+
"type": "github",
|
| 2637 |
+
"url": "https://github.com/sponsors/wooorm"
|
| 2638 |
+
}
|
| 2639 |
+
},
|
| 2640 |
+
"node_modules/is-alphanumerical": {
|
| 2641 |
+
"version": "2.0.1",
|
| 2642 |
+
"resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz",
|
| 2643 |
+
"integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==",
|
| 2644 |
+
"dependencies": {
|
| 2645 |
+
"is-alphabetical": "^2.0.0",
|
| 2646 |
+
"is-decimal": "^2.0.0"
|
| 2647 |
+
},
|
| 2648 |
+
"funding": {
|
| 2649 |
+
"type": "github",
|
| 2650 |
+
"url": "https://github.com/sponsors/wooorm"
|
| 2651 |
+
}
|
| 2652 |
+
},
|
| 2653 |
+
"node_modules/is-decimal": {
|
| 2654 |
+
"version": "2.0.1",
|
| 2655 |
+
"resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz",
|
| 2656 |
+
"integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==",
|
| 2657 |
+
"funding": {
|
| 2658 |
+
"type": "github",
|
| 2659 |
+
"url": "https://github.com/sponsors/wooorm"
|
| 2660 |
+
}
|
| 2661 |
+
},
|
| 2662 |
+
"node_modules/is-hexadecimal": {
|
| 2663 |
+
"version": "2.0.1",
|
| 2664 |
+
"resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz",
|
| 2665 |
+
"integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==",
|
| 2666 |
+
"funding": {
|
| 2667 |
+
"type": "github",
|
| 2668 |
+
"url": "https://github.com/sponsors/wooorm"
|
| 2669 |
+
}
|
| 2670 |
+
},
|
| 2671 |
+
"node_modules/is-plain-obj": {
|
| 2672 |
+
"version": "4.1.0",
|
| 2673 |
+
"resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz",
|
| 2674 |
+
"integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==",
|
| 2675 |
+
"engines": {
|
| 2676 |
+
"node": ">=12"
|
| 2677 |
+
},
|
| 2678 |
+
"funding": {
|
| 2679 |
+
"url": "https://github.com/sponsors/sindresorhus"
|
| 2680 |
+
}
|
| 2681 |
+
},
|
| 2682 |
"node_modules/is-potential-custom-element-name": {
|
| 2683 |
"version": "1.0.1",
|
| 2684 |
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
|
|
|
|
| 2985 |
"url": "https://opencollective.com/parcel"
|
| 2986 |
}
|
| 2987 |
},
|
| 2988 |
+
"node_modules/lightningcss-win32-x64-msvc": {
|
| 2989 |
+
"version": "1.32.0",
|
| 2990 |
+
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
|
| 2991 |
+
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
|
| 2992 |
+
"cpu": [
|
| 2993 |
+
"x64"
|
| 2994 |
+
],
|
| 2995 |
+
"dev": true,
|
| 2996 |
+
"license": "MPL-2.0",
|
| 2997 |
+
"optional": true,
|
| 2998 |
+
"os": [
|
| 2999 |
+
"win32"
|
| 3000 |
+
],
|
| 3001 |
+
"engines": {
|
| 3002 |
+
"node": ">= 12.0.0"
|
| 3003 |
+
},
|
| 3004 |
+
"funding": {
|
| 3005 |
+
"type": "opencollective",
|
| 3006 |
+
"url": "https://opencollective.com/parcel"
|
| 3007 |
+
}
|
| 3008 |
+
},
|
| 3009 |
+
"node_modules/longest-streak": {
|
| 3010 |
+
"version": "3.1.0",
|
| 3011 |
+
"resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
|
| 3012 |
+
"integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==",
|
| 3013 |
+
"funding": {
|
| 3014 |
+
"type": "github",
|
| 3015 |
+
"url": "https://github.com/sponsors/wooorm"
|
| 3016 |
+
}
|
| 3017 |
+
},
|
| 3018 |
+
"node_modules/lowlight": {
|
| 3019 |
+
"version": "3.3.0",
|
| 3020 |
+
"resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz",
|
| 3021 |
+
"integrity": "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==",
|
| 3022 |
+
"dependencies": {
|
| 3023 |
+
"@types/hast": "^3.0.0",
|
| 3024 |
+
"devlop": "^1.0.0",
|
| 3025 |
+
"highlight.js": "~11.11.0"
|
| 3026 |
+
},
|
| 3027 |
+
"funding": {
|
| 3028 |
+
"type": "github",
|
| 3029 |
+
"url": "https://github.com/sponsors/wooorm"
|
| 3030 |
+
}
|
| 3031 |
+
},
|
| 3032 |
+
"node_modules/lru-cache": {
|
| 3033 |
+
"version": "11.5.2",
|
| 3034 |
+
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
|
| 3035 |
+
"integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
|
| 3036 |
+
"dev": true,
|
| 3037 |
+
"license": "BlueOak-1.0.0",
|
| 3038 |
+
"engines": {
|
| 3039 |
+
"node": "20 || >=22"
|
| 3040 |
+
}
|
| 3041 |
+
},
|
| 3042 |
+
"node_modules/lucide-react": {
|
| 3043 |
+
"version": "0.577.0",
|
| 3044 |
+
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.577.0.tgz",
|
| 3045 |
+
"integrity": "sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A==",
|
| 3046 |
+
"license": "ISC",
|
| 3047 |
+
"peerDependencies": {
|
| 3048 |
+
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
| 3049 |
+
}
|
| 3050 |
+
},
|
| 3051 |
+
"node_modules/lz-string": {
|
| 3052 |
+
"version": "1.5.0",
|
| 3053 |
+
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
|
| 3054 |
+
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
|
| 3055 |
+
"dev": true,
|
| 3056 |
+
"license": "MIT",
|
| 3057 |
+
"peer": true,
|
| 3058 |
+
"bin": {
|
| 3059 |
+
"lz-string": "bin/bin.js"
|
| 3060 |
+
}
|
| 3061 |
+
},
|
| 3062 |
+
"node_modules/magic-string": {
|
| 3063 |
+
"version": "0.30.21",
|
| 3064 |
+
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
|
| 3065 |
+
"integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
|
| 3066 |
+
"dev": true,
|
| 3067 |
+
"license": "MIT",
|
| 3068 |
+
"dependencies": {
|
| 3069 |
+
"@jridgewell/sourcemap-codec": "^1.5.5"
|
| 3070 |
+
}
|
| 3071 |
+
},
|
| 3072 |
+
"node_modules/markdown-table": {
|
| 3073 |
+
"version": "3.0.4",
|
| 3074 |
+
"resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz",
|
| 3075 |
+
"integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==",
|
| 3076 |
+
"funding": {
|
| 3077 |
+
"type": "github",
|
| 3078 |
+
"url": "https://github.com/sponsors/wooorm"
|
| 3079 |
+
}
|
| 3080 |
+
},
|
| 3081 |
+
"node_modules/mdast-util-find-and-replace": {
|
| 3082 |
+
"version": "3.0.2",
|
| 3083 |
+
"resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz",
|
| 3084 |
+
"integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==",
|
| 3085 |
+
"dependencies": {
|
| 3086 |
+
"@types/mdast": "^4.0.0",
|
| 3087 |
+
"escape-string-regexp": "^5.0.0",
|
| 3088 |
+
"unist-util-is": "^6.0.0",
|
| 3089 |
+
"unist-util-visit-parents": "^6.0.0"
|
| 3090 |
+
},
|
| 3091 |
+
"funding": {
|
| 3092 |
+
"type": "opencollective",
|
| 3093 |
+
"url": "https://opencollective.com/unified"
|
| 3094 |
+
}
|
| 3095 |
+
},
|
| 3096 |
+
"node_modules/mdast-util-from-markdown": {
|
| 3097 |
+
"version": "2.0.3",
|
| 3098 |
+
"resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz",
|
| 3099 |
+
"integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==",
|
| 3100 |
+
"dependencies": {
|
| 3101 |
+
"@types/mdast": "^4.0.0",
|
| 3102 |
+
"@types/unist": "^3.0.0",
|
| 3103 |
+
"decode-named-character-reference": "^1.0.0",
|
| 3104 |
+
"devlop": "^1.0.0",
|
| 3105 |
+
"mdast-util-to-string": "^4.0.0",
|
| 3106 |
+
"micromark": "^4.0.0",
|
| 3107 |
+
"micromark-util-decode-numeric-character-reference": "^2.0.0",
|
| 3108 |
+
"micromark-util-decode-string": "^2.0.0",
|
| 3109 |
+
"micromark-util-normalize-identifier": "^2.0.0",
|
| 3110 |
+
"micromark-util-symbol": "^2.0.0",
|
| 3111 |
+
"micromark-util-types": "^2.0.0",
|
| 3112 |
+
"unist-util-stringify-position": "^4.0.0"
|
| 3113 |
+
},
|
| 3114 |
+
"funding": {
|
| 3115 |
+
"type": "opencollective",
|
| 3116 |
+
"url": "https://opencollective.com/unified"
|
| 3117 |
+
}
|
| 3118 |
+
},
|
| 3119 |
+
"node_modules/mdast-util-gfm": {
|
| 3120 |
+
"version": "3.1.0",
|
| 3121 |
+
"resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz",
|
| 3122 |
+
"integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==",
|
| 3123 |
+
"dependencies": {
|
| 3124 |
+
"mdast-util-from-markdown": "^2.0.0",
|
| 3125 |
+
"mdast-util-gfm-autolink-literal": "^2.0.0",
|
| 3126 |
+
"mdast-util-gfm-footnote": "^2.0.0",
|
| 3127 |
+
"mdast-util-gfm-strikethrough": "^2.0.0",
|
| 3128 |
+
"mdast-util-gfm-table": "^2.0.0",
|
| 3129 |
+
"mdast-util-gfm-task-list-item": "^2.0.0",
|
| 3130 |
+
"mdast-util-to-markdown": "^2.0.0"
|
| 3131 |
+
},
|
| 3132 |
+
"funding": {
|
| 3133 |
+
"type": "opencollective",
|
| 3134 |
+
"url": "https://opencollective.com/unified"
|
| 3135 |
+
}
|
| 3136 |
+
},
|
| 3137 |
+
"node_modules/mdast-util-gfm-autolink-literal": {
|
| 3138 |
+
"version": "2.0.1",
|
| 3139 |
+
"resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz",
|
| 3140 |
+
"integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==",
|
| 3141 |
+
"dependencies": {
|
| 3142 |
+
"@types/mdast": "^4.0.0",
|
| 3143 |
+
"ccount": "^2.0.0",
|
| 3144 |
+
"devlop": "^1.0.0",
|
| 3145 |
+
"mdast-util-find-and-replace": "^3.0.0",
|
| 3146 |
+
"micromark-util-character": "^2.0.0"
|
| 3147 |
+
},
|
| 3148 |
+
"funding": {
|
| 3149 |
+
"type": "opencollective",
|
| 3150 |
+
"url": "https://opencollective.com/unified"
|
| 3151 |
+
}
|
| 3152 |
+
},
|
| 3153 |
+
"node_modules/mdast-util-gfm-footnote": {
|
| 3154 |
+
"version": "2.1.0",
|
| 3155 |
+
"resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz",
|
| 3156 |
+
"integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==",
|
| 3157 |
+
"dependencies": {
|
| 3158 |
+
"@types/mdast": "^4.0.0",
|
| 3159 |
+
"devlop": "^1.1.0",
|
| 3160 |
+
"mdast-util-from-markdown": "^2.0.0",
|
| 3161 |
+
"mdast-util-to-markdown": "^2.0.0",
|
| 3162 |
+
"micromark-util-normalize-identifier": "^2.0.0"
|
| 3163 |
+
},
|
| 3164 |
+
"funding": {
|
| 3165 |
+
"type": "opencollective",
|
| 3166 |
+
"url": "https://opencollective.com/unified"
|
| 3167 |
+
}
|
| 3168 |
+
},
|
| 3169 |
+
"node_modules/mdast-util-gfm-strikethrough": {
|
| 3170 |
+
"version": "2.0.0",
|
| 3171 |
+
"resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz",
|
| 3172 |
+
"integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==",
|
| 3173 |
+
"dependencies": {
|
| 3174 |
+
"@types/mdast": "^4.0.0",
|
| 3175 |
+
"mdast-util-from-markdown": "^2.0.0",
|
| 3176 |
+
"mdast-util-to-markdown": "^2.0.0"
|
| 3177 |
+
},
|
| 3178 |
+
"funding": {
|
| 3179 |
+
"type": "opencollective",
|
| 3180 |
+
"url": "https://opencollective.com/unified"
|
| 3181 |
+
}
|
| 3182 |
+
},
|
| 3183 |
+
"node_modules/mdast-util-gfm-table": {
|
| 3184 |
+
"version": "2.0.0",
|
| 3185 |
+
"resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz",
|
| 3186 |
+
"integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==",
|
| 3187 |
+
"dependencies": {
|
| 3188 |
+
"@types/mdast": "^4.0.0",
|
| 3189 |
+
"devlop": "^1.0.0",
|
| 3190 |
+
"markdown-table": "^3.0.0",
|
| 3191 |
+
"mdast-util-from-markdown": "^2.0.0",
|
| 3192 |
+
"mdast-util-to-markdown": "^2.0.0"
|
| 3193 |
+
},
|
| 3194 |
+
"funding": {
|
| 3195 |
+
"type": "opencollective",
|
| 3196 |
+
"url": "https://opencollective.com/unified"
|
| 3197 |
+
}
|
| 3198 |
+
},
|
| 3199 |
+
"node_modules/mdast-util-gfm-task-list-item": {
|
| 3200 |
+
"version": "2.0.0",
|
| 3201 |
+
"resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz",
|
| 3202 |
+
"integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==",
|
| 3203 |
+
"dependencies": {
|
| 3204 |
+
"@types/mdast": "^4.0.0",
|
| 3205 |
+
"devlop": "^1.0.0",
|
| 3206 |
+
"mdast-util-from-markdown": "^2.0.0",
|
| 3207 |
+
"mdast-util-to-markdown": "^2.0.0"
|
| 3208 |
+
},
|
| 3209 |
+
"funding": {
|
| 3210 |
+
"type": "opencollective",
|
| 3211 |
+
"url": "https://opencollective.com/unified"
|
| 3212 |
+
}
|
| 3213 |
+
},
|
| 3214 |
+
"node_modules/mdast-util-mdx-expression": {
|
| 3215 |
+
"version": "2.0.1",
|
| 3216 |
+
"resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz",
|
| 3217 |
+
"integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==",
|
| 3218 |
+
"dependencies": {
|
| 3219 |
+
"@types/estree-jsx": "^1.0.0",
|
| 3220 |
+
"@types/hast": "^3.0.0",
|
| 3221 |
+
"@types/mdast": "^4.0.0",
|
| 3222 |
+
"devlop": "^1.0.0",
|
| 3223 |
+
"mdast-util-from-markdown": "^2.0.0",
|
| 3224 |
+
"mdast-util-to-markdown": "^2.0.0"
|
| 3225 |
+
},
|
| 3226 |
+
"funding": {
|
| 3227 |
+
"type": "opencollective",
|
| 3228 |
+
"url": "https://opencollective.com/unified"
|
| 3229 |
+
}
|
| 3230 |
+
},
|
| 3231 |
+
"node_modules/mdast-util-mdx-jsx": {
|
| 3232 |
+
"version": "3.2.0",
|
| 3233 |
+
"resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz",
|
| 3234 |
+
"integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==",
|
| 3235 |
+
"dependencies": {
|
| 3236 |
+
"@types/estree-jsx": "^1.0.0",
|
| 3237 |
+
"@types/hast": "^3.0.0",
|
| 3238 |
+
"@types/mdast": "^4.0.0",
|
| 3239 |
+
"@types/unist": "^3.0.0",
|
| 3240 |
+
"ccount": "^2.0.0",
|
| 3241 |
+
"devlop": "^1.1.0",
|
| 3242 |
+
"mdast-util-from-markdown": "^2.0.0",
|
| 3243 |
+
"mdast-util-to-markdown": "^2.0.0",
|
| 3244 |
+
"parse-entities": "^4.0.0",
|
| 3245 |
+
"stringify-entities": "^4.0.0",
|
| 3246 |
+
"unist-util-stringify-position": "^4.0.0",
|
| 3247 |
+
"vfile-message": "^4.0.0"
|
| 3248 |
+
},
|
| 3249 |
+
"funding": {
|
| 3250 |
+
"type": "opencollective",
|
| 3251 |
+
"url": "https://opencollective.com/unified"
|
| 3252 |
+
}
|
| 3253 |
+
},
|
| 3254 |
+
"node_modules/mdast-util-mdxjs-esm": {
|
| 3255 |
+
"version": "2.0.1",
|
| 3256 |
+
"resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz",
|
| 3257 |
+
"integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==",
|
| 3258 |
+
"dependencies": {
|
| 3259 |
+
"@types/estree-jsx": "^1.0.0",
|
| 3260 |
+
"@types/hast": "^3.0.0",
|
| 3261 |
+
"@types/mdast": "^4.0.0",
|
| 3262 |
+
"devlop": "^1.0.0",
|
| 3263 |
+
"mdast-util-from-markdown": "^2.0.0",
|
| 3264 |
+
"mdast-util-to-markdown": "^2.0.0"
|
| 3265 |
+
},
|
| 3266 |
+
"funding": {
|
| 3267 |
+
"type": "opencollective",
|
| 3268 |
+
"url": "https://opencollective.com/unified"
|
| 3269 |
+
}
|
| 3270 |
+
},
|
| 3271 |
+
"node_modules/mdast-util-phrasing": {
|
| 3272 |
+
"version": "4.1.0",
|
| 3273 |
+
"resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz",
|
| 3274 |
+
"integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==",
|
| 3275 |
+
"dependencies": {
|
| 3276 |
+
"@types/mdast": "^4.0.0",
|
| 3277 |
+
"unist-util-is": "^6.0.0"
|
| 3278 |
+
},
|
| 3279 |
+
"funding": {
|
| 3280 |
+
"type": "opencollective",
|
| 3281 |
+
"url": "https://opencollective.com/unified"
|
| 3282 |
+
}
|
| 3283 |
+
},
|
| 3284 |
+
"node_modules/mdast-util-to-hast": {
|
| 3285 |
+
"version": "13.2.1",
|
| 3286 |
+
"resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz",
|
| 3287 |
+
"integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==",
|
| 3288 |
+
"dependencies": {
|
| 3289 |
+
"@types/hast": "^3.0.0",
|
| 3290 |
+
"@types/mdast": "^4.0.0",
|
| 3291 |
+
"@ungap/structured-clone": "^1.0.0",
|
| 3292 |
+
"devlop": "^1.0.0",
|
| 3293 |
+
"micromark-util-sanitize-uri": "^2.0.0",
|
| 3294 |
+
"trim-lines": "^3.0.0",
|
| 3295 |
+
"unist-util-position": "^5.0.0",
|
| 3296 |
+
"unist-util-visit": "^5.0.0",
|
| 3297 |
+
"vfile": "^6.0.0"
|
| 3298 |
+
},
|
| 3299 |
+
"funding": {
|
| 3300 |
+
"type": "opencollective",
|
| 3301 |
+
"url": "https://opencollective.com/unified"
|
| 3302 |
+
}
|
| 3303 |
+
},
|
| 3304 |
+
"node_modules/mdast-util-to-markdown": {
|
| 3305 |
+
"version": "2.1.2",
|
| 3306 |
+
"resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz",
|
| 3307 |
+
"integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==",
|
| 3308 |
+
"dependencies": {
|
| 3309 |
+
"@types/mdast": "^4.0.0",
|
| 3310 |
+
"@types/unist": "^3.0.0",
|
| 3311 |
+
"longest-streak": "^3.0.0",
|
| 3312 |
+
"mdast-util-phrasing": "^4.0.0",
|
| 3313 |
+
"mdast-util-to-string": "^4.0.0",
|
| 3314 |
+
"micromark-util-classify-character": "^2.0.0",
|
| 3315 |
+
"micromark-util-decode-string": "^2.0.0",
|
| 3316 |
+
"unist-util-visit": "^5.0.0",
|
| 3317 |
+
"zwitch": "^2.0.0"
|
| 3318 |
+
},
|
| 3319 |
+
"funding": {
|
| 3320 |
+
"type": "opencollective",
|
| 3321 |
+
"url": "https://opencollective.com/unified"
|
| 3322 |
+
}
|
| 3323 |
+
},
|
| 3324 |
+
"node_modules/mdast-util-to-string": {
|
| 3325 |
+
"version": "4.0.0",
|
| 3326 |
+
"resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz",
|
| 3327 |
+
"integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==",
|
| 3328 |
+
"dependencies": {
|
| 3329 |
+
"@types/mdast": "^4.0.0"
|
| 3330 |
+
},
|
| 3331 |
+
"funding": {
|
| 3332 |
+
"type": "opencollective",
|
| 3333 |
+
"url": "https://opencollective.com/unified"
|
| 3334 |
+
}
|
| 3335 |
+
},
|
| 3336 |
+
"node_modules/mdn-data": {
|
| 3337 |
+
"version": "2.27.1",
|
| 3338 |
+
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
|
| 3339 |
+
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
|
| 3340 |
+
"dev": true,
|
| 3341 |
+
"license": "CC0-1.0"
|
| 3342 |
+
},
|
| 3343 |
+
"node_modules/micromark": {
|
| 3344 |
+
"version": "4.0.2",
|
| 3345 |
+
"resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz",
|
| 3346 |
+
"integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==",
|
| 3347 |
+
"funding": [
|
| 3348 |
+
{
|
| 3349 |
+
"type": "GitHub Sponsors",
|
| 3350 |
+
"url": "https://github.com/sponsors/unifiedjs"
|
| 3351 |
+
},
|
| 3352 |
+
{
|
| 3353 |
+
"type": "OpenCollective",
|
| 3354 |
+
"url": "https://opencollective.com/unified"
|
| 3355 |
+
}
|
| 3356 |
+
],
|
| 3357 |
+
"dependencies": {
|
| 3358 |
+
"@types/debug": "^4.0.0",
|
| 3359 |
+
"debug": "^4.0.0",
|
| 3360 |
+
"decode-named-character-reference": "^1.0.0",
|
| 3361 |
+
"devlop": "^1.0.0",
|
| 3362 |
+
"micromark-core-commonmark": "^2.0.0",
|
| 3363 |
+
"micromark-factory-space": "^2.0.0",
|
| 3364 |
+
"micromark-util-character": "^2.0.0",
|
| 3365 |
+
"micromark-util-chunked": "^2.0.0",
|
| 3366 |
+
"micromark-util-combine-extensions": "^2.0.0",
|
| 3367 |
+
"micromark-util-decode-numeric-character-reference": "^2.0.0",
|
| 3368 |
+
"micromark-util-encode": "^2.0.0",
|
| 3369 |
+
"micromark-util-normalize-identifier": "^2.0.0",
|
| 3370 |
+
"micromark-util-resolve-all": "^2.0.0",
|
| 3371 |
+
"micromark-util-sanitize-uri": "^2.0.0",
|
| 3372 |
+
"micromark-util-subtokenize": "^2.0.0",
|
| 3373 |
+
"micromark-util-symbol": "^2.0.0",
|
| 3374 |
+
"micromark-util-types": "^2.0.0"
|
| 3375 |
+
}
|
| 3376 |
+
},
|
| 3377 |
+
"node_modules/micromark-core-commonmark": {
|
| 3378 |
+
"version": "2.0.3",
|
| 3379 |
+
"resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz",
|
| 3380 |
+
"integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==",
|
| 3381 |
+
"funding": [
|
| 3382 |
+
{
|
| 3383 |
+
"type": "GitHub Sponsors",
|
| 3384 |
+
"url": "https://github.com/sponsors/unifiedjs"
|
| 3385 |
+
},
|
| 3386 |
+
{
|
| 3387 |
+
"type": "OpenCollective",
|
| 3388 |
+
"url": "https://opencollective.com/unified"
|
| 3389 |
+
}
|
| 3390 |
+
],
|
| 3391 |
+
"dependencies": {
|
| 3392 |
+
"decode-named-character-reference": "^1.0.0",
|
| 3393 |
+
"devlop": "^1.0.0",
|
| 3394 |
+
"micromark-factory-destination": "^2.0.0",
|
| 3395 |
+
"micromark-factory-label": "^2.0.0",
|
| 3396 |
+
"micromark-factory-space": "^2.0.0",
|
| 3397 |
+
"micromark-factory-title": "^2.0.0",
|
| 3398 |
+
"micromark-factory-whitespace": "^2.0.0",
|
| 3399 |
+
"micromark-util-character": "^2.0.0",
|
| 3400 |
+
"micromark-util-chunked": "^2.0.0",
|
| 3401 |
+
"micromark-util-classify-character": "^2.0.0",
|
| 3402 |
+
"micromark-util-html-tag-name": "^2.0.0",
|
| 3403 |
+
"micromark-util-normalize-identifier": "^2.0.0",
|
| 3404 |
+
"micromark-util-resolve-all": "^2.0.0",
|
| 3405 |
+
"micromark-util-subtokenize": "^2.0.0",
|
| 3406 |
+
"micromark-util-symbol": "^2.0.0",
|
| 3407 |
+
"micromark-util-types": "^2.0.0"
|
| 3408 |
+
}
|
| 3409 |
+
},
|
| 3410 |
+
"node_modules/micromark-extension-gfm": {
|
| 3411 |
+
"version": "3.0.0",
|
| 3412 |
+
"resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz",
|
| 3413 |
+
"integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==",
|
| 3414 |
+
"dependencies": {
|
| 3415 |
+
"micromark-extension-gfm-autolink-literal": "^2.0.0",
|
| 3416 |
+
"micromark-extension-gfm-footnote": "^2.0.0",
|
| 3417 |
+
"micromark-extension-gfm-strikethrough": "^2.0.0",
|
| 3418 |
+
"micromark-extension-gfm-table": "^2.0.0",
|
| 3419 |
+
"micromark-extension-gfm-tagfilter": "^2.0.0",
|
| 3420 |
+
"micromark-extension-gfm-task-list-item": "^2.0.0",
|
| 3421 |
+
"micromark-util-combine-extensions": "^2.0.0",
|
| 3422 |
+
"micromark-util-types": "^2.0.0"
|
| 3423 |
+
},
|
| 3424 |
+
"funding": {
|
| 3425 |
+
"type": "opencollective",
|
| 3426 |
+
"url": "https://opencollective.com/unified"
|
| 3427 |
+
}
|
| 3428 |
+
},
|
| 3429 |
+
"node_modules/micromark-extension-gfm-autolink-literal": {
|
| 3430 |
+
"version": "2.1.0",
|
| 3431 |
+
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz",
|
| 3432 |
+
"integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==",
|
| 3433 |
+
"dependencies": {
|
| 3434 |
+
"micromark-util-character": "^2.0.0",
|
| 3435 |
+
"micromark-util-sanitize-uri": "^2.0.0",
|
| 3436 |
+
"micromark-util-symbol": "^2.0.0",
|
| 3437 |
+
"micromark-util-types": "^2.0.0"
|
| 3438 |
+
},
|
| 3439 |
+
"funding": {
|
| 3440 |
+
"type": "opencollective",
|
| 3441 |
+
"url": "https://opencollective.com/unified"
|
| 3442 |
+
}
|
| 3443 |
+
},
|
| 3444 |
+
"node_modules/micromark-extension-gfm-footnote": {
|
| 3445 |
+
"version": "2.1.0",
|
| 3446 |
+
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz",
|
| 3447 |
+
"integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==",
|
| 3448 |
+
"dependencies": {
|
| 3449 |
+
"devlop": "^1.0.0",
|
| 3450 |
+
"micromark-core-commonmark": "^2.0.0",
|
| 3451 |
+
"micromark-factory-space": "^2.0.0",
|
| 3452 |
+
"micromark-util-character": "^2.0.0",
|
| 3453 |
+
"micromark-util-normalize-identifier": "^2.0.0",
|
| 3454 |
+
"micromark-util-sanitize-uri": "^2.0.0",
|
| 3455 |
+
"micromark-util-symbol": "^2.0.0",
|
| 3456 |
+
"micromark-util-types": "^2.0.0"
|
| 3457 |
+
},
|
| 3458 |
+
"funding": {
|
| 3459 |
+
"type": "opencollective",
|
| 3460 |
+
"url": "https://opencollective.com/unified"
|
| 3461 |
+
}
|
| 3462 |
+
},
|
| 3463 |
+
"node_modules/micromark-extension-gfm-strikethrough": {
|
| 3464 |
+
"version": "2.1.0",
|
| 3465 |
+
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz",
|
| 3466 |
+
"integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==",
|
| 3467 |
+
"dependencies": {
|
| 3468 |
+
"devlop": "^1.0.0",
|
| 3469 |
+
"micromark-util-chunked": "^2.0.0",
|
| 3470 |
+
"micromark-util-classify-character": "^2.0.0",
|
| 3471 |
+
"micromark-util-resolve-all": "^2.0.0",
|
| 3472 |
+
"micromark-util-symbol": "^2.0.0",
|
| 3473 |
+
"micromark-util-types": "^2.0.0"
|
| 3474 |
+
},
|
| 3475 |
+
"funding": {
|
| 3476 |
+
"type": "opencollective",
|
| 3477 |
+
"url": "https://opencollective.com/unified"
|
| 3478 |
+
}
|
| 3479 |
+
},
|
| 3480 |
+
"node_modules/micromark-extension-gfm-table": {
|
| 3481 |
+
"version": "2.1.2",
|
| 3482 |
+
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.2.tgz",
|
| 3483 |
+
"integrity": "sha512-pRzm4kDTu0MjlmBkxmS9yYhw60nncfcEwu9NNdPFSQEFXS95ZKyIIyTSHu/o3ReBUrLKYEq+7YaXCRn/bPB4MA==",
|
| 3484 |
+
"dependencies": {
|
| 3485 |
+
"devlop": "^1.0.0",
|
| 3486 |
+
"micromark-factory-space": "^2.0.0",
|
| 3487 |
+
"micromark-util-character": "^2.0.0",
|
| 3488 |
+
"micromark-util-symbol": "^2.0.0",
|
| 3489 |
+
"micromark-util-types": "^2.0.0"
|
| 3490 |
+
},
|
| 3491 |
+
"funding": {
|
| 3492 |
+
"type": "opencollective",
|
| 3493 |
+
"url": "https://opencollective.com/unified"
|
| 3494 |
+
}
|
| 3495 |
+
},
|
| 3496 |
+
"node_modules/micromark-extension-gfm-tagfilter": {
|
| 3497 |
+
"version": "2.0.0",
|
| 3498 |
+
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz",
|
| 3499 |
+
"integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==",
|
| 3500 |
+
"dependencies": {
|
| 3501 |
+
"micromark-util-types": "^2.0.0"
|
| 3502 |
+
},
|
| 3503 |
+
"funding": {
|
| 3504 |
+
"type": "opencollective",
|
| 3505 |
+
"url": "https://opencollective.com/unified"
|
| 3506 |
+
}
|
| 3507 |
+
},
|
| 3508 |
+
"node_modules/micromark-extension-gfm-task-list-item": {
|
| 3509 |
+
"version": "2.1.0",
|
| 3510 |
+
"resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz",
|
| 3511 |
+
"integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==",
|
| 3512 |
+
"dependencies": {
|
| 3513 |
+
"devlop": "^1.0.0",
|
| 3514 |
+
"micromark-factory-space": "^2.0.0",
|
| 3515 |
+
"micromark-util-character": "^2.0.0",
|
| 3516 |
+
"micromark-util-symbol": "^2.0.0",
|
| 3517 |
+
"micromark-util-types": "^2.0.0"
|
| 3518 |
+
},
|
| 3519 |
+
"funding": {
|
| 3520 |
+
"type": "opencollective",
|
| 3521 |
+
"url": "https://opencollective.com/unified"
|
| 3522 |
+
}
|
| 3523 |
+
},
|
| 3524 |
+
"node_modules/micromark-factory-destination": {
|
| 3525 |
+
"version": "2.0.1",
|
| 3526 |
+
"resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz",
|
| 3527 |
+
"integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==",
|
| 3528 |
+
"funding": [
|
| 3529 |
+
{
|
| 3530 |
+
"type": "GitHub Sponsors",
|
| 3531 |
+
"url": "https://github.com/sponsors/unifiedjs"
|
| 3532 |
+
},
|
| 3533 |
+
{
|
| 3534 |
+
"type": "OpenCollective",
|
| 3535 |
+
"url": "https://opencollective.com/unified"
|
| 3536 |
+
}
|
| 3537 |
+
],
|
| 3538 |
+
"dependencies": {
|
| 3539 |
+
"micromark-util-character": "^2.0.0",
|
| 3540 |
+
"micromark-util-symbol": "^2.0.0",
|
| 3541 |
+
"micromark-util-types": "^2.0.0"
|
| 3542 |
+
}
|
| 3543 |
+
},
|
| 3544 |
+
"node_modules/micromark-factory-label": {
|
| 3545 |
+
"version": "2.0.1",
|
| 3546 |
+
"resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz",
|
| 3547 |
+
"integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==",
|
| 3548 |
+
"funding": [
|
| 3549 |
+
{
|
| 3550 |
+
"type": "GitHub Sponsors",
|
| 3551 |
+
"url": "https://github.com/sponsors/unifiedjs"
|
| 3552 |
+
},
|
| 3553 |
+
{
|
| 3554 |
+
"type": "OpenCollective",
|
| 3555 |
+
"url": "https://opencollective.com/unified"
|
| 3556 |
+
}
|
| 3557 |
+
],
|
| 3558 |
+
"dependencies": {
|
| 3559 |
+
"devlop": "^1.0.0",
|
| 3560 |
+
"micromark-util-character": "^2.0.0",
|
| 3561 |
+
"micromark-util-symbol": "^2.0.0",
|
| 3562 |
+
"micromark-util-types": "^2.0.0"
|
| 3563 |
+
}
|
| 3564 |
+
},
|
| 3565 |
+
"node_modules/micromark-factory-space": {
|
| 3566 |
+
"version": "2.0.1",
|
| 3567 |
+
"resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz",
|
| 3568 |
+
"integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==",
|
| 3569 |
+
"funding": [
|
| 3570 |
+
{
|
| 3571 |
+
"type": "GitHub Sponsors",
|
| 3572 |
+
"url": "https://github.com/sponsors/unifiedjs"
|
| 3573 |
+
},
|
| 3574 |
+
{
|
| 3575 |
+
"type": "OpenCollective",
|
| 3576 |
+
"url": "https://opencollective.com/unified"
|
| 3577 |
+
}
|
| 3578 |
+
],
|
| 3579 |
+
"dependencies": {
|
| 3580 |
+
"micromark-util-character": "^2.0.0",
|
| 3581 |
+
"micromark-util-types": "^2.0.0"
|
| 3582 |
+
}
|
| 3583 |
+
},
|
| 3584 |
+
"node_modules/micromark-factory-title": {
|
| 3585 |
+
"version": "2.0.1",
|
| 3586 |
+
"resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz",
|
| 3587 |
+
"integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==",
|
| 3588 |
+
"funding": [
|
| 3589 |
+
{
|
| 3590 |
+
"type": "GitHub Sponsors",
|
| 3591 |
+
"url": "https://github.com/sponsors/unifiedjs"
|
| 3592 |
+
},
|
| 3593 |
+
{
|
| 3594 |
+
"type": "OpenCollective",
|
| 3595 |
+
"url": "https://opencollective.com/unified"
|
| 3596 |
+
}
|
| 3597 |
+
],
|
| 3598 |
+
"dependencies": {
|
| 3599 |
+
"micromark-factory-space": "^2.0.0",
|
| 3600 |
+
"micromark-util-character": "^2.0.0",
|
| 3601 |
+
"micromark-util-symbol": "^2.0.0",
|
| 3602 |
+
"micromark-util-types": "^2.0.0"
|
| 3603 |
+
}
|
| 3604 |
+
},
|
| 3605 |
+
"node_modules/micromark-factory-whitespace": {
|
| 3606 |
+
"version": "2.0.1",
|
| 3607 |
+
"resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz",
|
| 3608 |
+
"integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==",
|
| 3609 |
+
"funding": [
|
| 3610 |
+
{
|
| 3611 |
+
"type": "GitHub Sponsors",
|
| 3612 |
+
"url": "https://github.com/sponsors/unifiedjs"
|
| 3613 |
+
},
|
| 3614 |
+
{
|
| 3615 |
+
"type": "OpenCollective",
|
| 3616 |
+
"url": "https://opencollective.com/unified"
|
| 3617 |
+
}
|
| 3618 |
+
],
|
| 3619 |
+
"dependencies": {
|
| 3620 |
+
"micromark-factory-space": "^2.0.0",
|
| 3621 |
+
"micromark-util-character": "^2.0.0",
|
| 3622 |
+
"micromark-util-symbol": "^2.0.0",
|
| 3623 |
+
"micromark-util-types": "^2.0.0"
|
| 3624 |
+
}
|
| 3625 |
+
},
|
| 3626 |
+
"node_modules/micromark-util-character": {
|
| 3627 |
+
"version": "2.1.1",
|
| 3628 |
+
"resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz",
|
| 3629 |
+
"integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==",
|
| 3630 |
+
"funding": [
|
| 3631 |
+
{
|
| 3632 |
+
"type": "GitHub Sponsors",
|
| 3633 |
+
"url": "https://github.com/sponsors/unifiedjs"
|
| 3634 |
+
},
|
| 3635 |
+
{
|
| 3636 |
+
"type": "OpenCollective",
|
| 3637 |
+
"url": "https://opencollective.com/unified"
|
| 3638 |
+
}
|
| 3639 |
+
],
|
| 3640 |
+
"dependencies": {
|
| 3641 |
+
"micromark-util-symbol": "^2.0.0",
|
| 3642 |
+
"micromark-util-types": "^2.0.0"
|
| 3643 |
+
}
|
| 3644 |
+
},
|
| 3645 |
+
"node_modules/micromark-util-chunked": {
|
| 3646 |
+
"version": "2.0.1",
|
| 3647 |
+
"resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz",
|
| 3648 |
+
"integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==",
|
| 3649 |
+
"funding": [
|
| 3650 |
+
{
|
| 3651 |
+
"type": "GitHub Sponsors",
|
| 3652 |
+
"url": "https://github.com/sponsors/unifiedjs"
|
| 3653 |
+
},
|
| 3654 |
+
{
|
| 3655 |
+
"type": "OpenCollective",
|
| 3656 |
+
"url": "https://opencollective.com/unified"
|
| 3657 |
+
}
|
| 3658 |
+
],
|
| 3659 |
+
"dependencies": {
|
| 3660 |
+
"micromark-util-symbol": "^2.0.0"
|
| 3661 |
+
}
|
| 3662 |
+
},
|
| 3663 |
+
"node_modules/micromark-util-classify-character": {
|
| 3664 |
+
"version": "2.0.1",
|
| 3665 |
+
"resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz",
|
| 3666 |
+
"integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==",
|
| 3667 |
+
"funding": [
|
| 3668 |
+
{
|
| 3669 |
+
"type": "GitHub Sponsors",
|
| 3670 |
+
"url": "https://github.com/sponsors/unifiedjs"
|
| 3671 |
+
},
|
| 3672 |
+
{
|
| 3673 |
+
"type": "OpenCollective",
|
| 3674 |
+
"url": "https://opencollective.com/unified"
|
| 3675 |
+
}
|
| 3676 |
+
],
|
| 3677 |
+
"dependencies": {
|
| 3678 |
+
"micromark-util-character": "^2.0.0",
|
| 3679 |
+
"micromark-util-symbol": "^2.0.0",
|
| 3680 |
+
"micromark-util-types": "^2.0.0"
|
| 3681 |
+
}
|
| 3682 |
+
},
|
| 3683 |
+
"node_modules/micromark-util-combine-extensions": {
|
| 3684 |
+
"version": "2.0.1",
|
| 3685 |
+
"resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz",
|
| 3686 |
+
"integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==",
|
| 3687 |
+
"funding": [
|
| 3688 |
+
{
|
| 3689 |
+
"type": "GitHub Sponsors",
|
| 3690 |
+
"url": "https://github.com/sponsors/unifiedjs"
|
| 3691 |
+
},
|
| 3692 |
+
{
|
| 3693 |
+
"type": "OpenCollective",
|
| 3694 |
+
"url": "https://opencollective.com/unified"
|
| 3695 |
+
}
|
| 3696 |
+
],
|
| 3697 |
+
"dependencies": {
|
| 3698 |
+
"micromark-util-chunked": "^2.0.0",
|
| 3699 |
+
"micromark-util-types": "^2.0.0"
|
| 3700 |
+
}
|
| 3701 |
+
},
|
| 3702 |
+
"node_modules/micromark-util-decode-numeric-character-reference": {
|
| 3703 |
+
"version": "2.0.2",
|
| 3704 |
+
"resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz",
|
| 3705 |
+
"integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==",
|
| 3706 |
+
"funding": [
|
| 3707 |
+
{
|
| 3708 |
+
"type": "GitHub Sponsors",
|
| 3709 |
+
"url": "https://github.com/sponsors/unifiedjs"
|
| 3710 |
+
},
|
| 3711 |
+
{
|
| 3712 |
+
"type": "OpenCollective",
|
| 3713 |
+
"url": "https://opencollective.com/unified"
|
| 3714 |
+
}
|
| 3715 |
+
],
|
| 3716 |
+
"dependencies": {
|
| 3717 |
+
"micromark-util-symbol": "^2.0.0"
|
| 3718 |
+
}
|
| 3719 |
+
},
|
| 3720 |
+
"node_modules/micromark-util-decode-string": {
|
| 3721 |
+
"version": "2.0.1",
|
| 3722 |
+
"resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz",
|
| 3723 |
+
"integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==",
|
| 3724 |
+
"funding": [
|
| 3725 |
+
{
|
| 3726 |
+
"type": "GitHub Sponsors",
|
| 3727 |
+
"url": "https://github.com/sponsors/unifiedjs"
|
| 3728 |
+
},
|
| 3729 |
+
{
|
| 3730 |
+
"type": "OpenCollective",
|
| 3731 |
+
"url": "https://opencollective.com/unified"
|
| 3732 |
+
}
|
| 3733 |
],
|
| 3734 |
+
"dependencies": {
|
| 3735 |
+
"decode-named-character-reference": "^1.0.0",
|
| 3736 |
+
"micromark-util-character": "^2.0.0",
|
| 3737 |
+
"micromark-util-decode-numeric-character-reference": "^2.0.0",
|
| 3738 |
+
"micromark-util-symbol": "^2.0.0"
|
|
|
|
| 3739 |
}
|
| 3740 |
},
|
| 3741 |
+
"node_modules/micromark-util-encode": {
|
| 3742 |
+
"version": "2.0.1",
|
| 3743 |
+
"resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz",
|
| 3744 |
+
"integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==",
|
| 3745 |
+
"funding": [
|
| 3746 |
+
{
|
| 3747 |
+
"type": "GitHub Sponsors",
|
| 3748 |
+
"url": "https://github.com/sponsors/unifiedjs"
|
| 3749 |
+
},
|
| 3750 |
+
{
|
| 3751 |
+
"type": "OpenCollective",
|
| 3752 |
+
"url": "https://opencollective.com/unified"
|
| 3753 |
+
}
|
| 3754 |
+
]
|
| 3755 |
+
},
|
| 3756 |
+
"node_modules/micromark-util-html-tag-name": {
|
| 3757 |
+
"version": "2.0.1",
|
| 3758 |
+
"resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz",
|
| 3759 |
+
"integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==",
|
| 3760 |
+
"funding": [
|
| 3761 |
+
{
|
| 3762 |
+
"type": "GitHub Sponsors",
|
| 3763 |
+
"url": "https://github.com/sponsors/unifiedjs"
|
| 3764 |
+
},
|
| 3765 |
+
{
|
| 3766 |
+
"type": "OpenCollective",
|
| 3767 |
+
"url": "https://opencollective.com/unified"
|
| 3768 |
+
}
|
| 3769 |
+
]
|
| 3770 |
+
},
|
| 3771 |
+
"node_modules/micromark-util-normalize-identifier": {
|
| 3772 |
+
"version": "2.0.1",
|
| 3773 |
+
"resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz",
|
| 3774 |
+
"integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==",
|
| 3775 |
+
"funding": [
|
| 3776 |
+
{
|
| 3777 |
+
"type": "GitHub Sponsors",
|
| 3778 |
+
"url": "https://github.com/sponsors/unifiedjs"
|
| 3779 |
+
},
|
| 3780 |
+
{
|
| 3781 |
+
"type": "OpenCollective",
|
| 3782 |
+
"url": "https://opencollective.com/unified"
|
| 3783 |
+
}
|
| 3784 |
+
],
|
| 3785 |
+
"dependencies": {
|
| 3786 |
+
"micromark-util-symbol": "^2.0.0"
|
| 3787 |
}
|
| 3788 |
},
|
| 3789 |
+
"node_modules/micromark-util-resolve-all": {
|
| 3790 |
+
"version": "2.0.1",
|
| 3791 |
+
"resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz",
|
| 3792 |
+
"integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==",
|
| 3793 |
+
"funding": [
|
| 3794 |
+
{
|
| 3795 |
+
"type": "GitHub Sponsors",
|
| 3796 |
+
"url": "https://github.com/sponsors/unifiedjs"
|
| 3797 |
+
},
|
| 3798 |
+
{
|
| 3799 |
+
"type": "OpenCollective",
|
| 3800 |
+
"url": "https://opencollective.com/unified"
|
| 3801 |
+
}
|
| 3802 |
+
],
|
| 3803 |
+
"dependencies": {
|
| 3804 |
+
"micromark-util-types": "^2.0.0"
|
| 3805 |
}
|
| 3806 |
},
|
| 3807 |
+
"node_modules/micromark-util-sanitize-uri": {
|
| 3808 |
+
"version": "2.0.1",
|
| 3809 |
+
"resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz",
|
| 3810 |
+
"integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==",
|
| 3811 |
+
"funding": [
|
| 3812 |
+
{
|
| 3813 |
+
"type": "GitHub Sponsors",
|
| 3814 |
+
"url": "https://github.com/sponsors/unifiedjs"
|
| 3815 |
+
},
|
| 3816 |
+
{
|
| 3817 |
+
"type": "OpenCollective",
|
| 3818 |
+
"url": "https://opencollective.com/unified"
|
| 3819 |
+
}
|
| 3820 |
+
],
|
| 3821 |
+
"dependencies": {
|
| 3822 |
+
"micromark-util-character": "^2.0.0",
|
| 3823 |
+
"micromark-util-encode": "^2.0.0",
|
| 3824 |
+
"micromark-util-symbol": "^2.0.0"
|
| 3825 |
}
|
| 3826 |
},
|
| 3827 |
+
"node_modules/micromark-util-subtokenize": {
|
| 3828 |
+
"version": "2.1.0",
|
| 3829 |
+
"resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz",
|
| 3830 |
+
"integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==",
|
| 3831 |
+
"funding": [
|
| 3832 |
+
{
|
| 3833 |
+
"type": "GitHub Sponsors",
|
| 3834 |
+
"url": "https://github.com/sponsors/unifiedjs"
|
| 3835 |
+
},
|
| 3836 |
+
{
|
| 3837 |
+
"type": "OpenCollective",
|
| 3838 |
+
"url": "https://opencollective.com/unified"
|
| 3839 |
+
}
|
| 3840 |
+
],
|
| 3841 |
"dependencies": {
|
| 3842 |
+
"devlop": "^1.0.0",
|
| 3843 |
+
"micromark-util-chunked": "^2.0.0",
|
| 3844 |
+
"micromark-util-symbol": "^2.0.0",
|
| 3845 |
+
"micromark-util-types": "^2.0.0"
|
| 3846 |
}
|
| 3847 |
},
|
| 3848 |
+
"node_modules/micromark-util-symbol": {
|
| 3849 |
+
"version": "2.0.1",
|
| 3850 |
+
"resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz",
|
| 3851 |
+
"integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==",
|
| 3852 |
+
"funding": [
|
| 3853 |
+
{
|
| 3854 |
+
"type": "GitHub Sponsors",
|
| 3855 |
+
"url": "https://github.com/sponsors/unifiedjs"
|
| 3856 |
+
},
|
| 3857 |
+
{
|
| 3858 |
+
"type": "OpenCollective",
|
| 3859 |
+
"url": "https://opencollective.com/unified"
|
| 3860 |
+
}
|
| 3861 |
+
]
|
| 3862 |
+
},
|
| 3863 |
+
"node_modules/micromark-util-types": {
|
| 3864 |
+
"version": "2.0.2",
|
| 3865 |
+
"resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz",
|
| 3866 |
+
"integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==",
|
| 3867 |
+
"funding": [
|
| 3868 |
+
{
|
| 3869 |
+
"type": "GitHub Sponsors",
|
| 3870 |
+
"url": "https://github.com/sponsors/unifiedjs"
|
| 3871 |
+
},
|
| 3872 |
+
{
|
| 3873 |
+
"type": "OpenCollective",
|
| 3874 |
+
"url": "https://opencollective.com/unified"
|
| 3875 |
+
}
|
| 3876 |
+
]
|
| 3877 |
},
|
| 3878 |
"node_modules/min-indent": {
|
| 3879 |
"version": "1.0.1",
|
|
|
|
| 3885 |
"node": ">=4"
|
| 3886 |
}
|
| 3887 |
},
|
| 3888 |
+
"node_modules/ms": {
|
| 3889 |
+
"version": "2.1.3",
|
| 3890 |
+
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
| 3891 |
+
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
|
| 3892 |
+
},
|
| 3893 |
"node_modules/nanoid": {
|
| 3894 |
"version": "3.3.19",
|
| 3895 |
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz",
|
|
|
|
| 4003 |
"node": ">=12.20.0"
|
| 4004 |
}
|
| 4005 |
},
|
| 4006 |
+
"node_modules/parse-entities": {
|
| 4007 |
+
"version": "4.0.2",
|
| 4008 |
+
"resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz",
|
| 4009 |
+
"integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==",
|
| 4010 |
+
"dependencies": {
|
| 4011 |
+
"@types/unist": "^2.0.0",
|
| 4012 |
+
"character-entities-legacy": "^3.0.0",
|
| 4013 |
+
"character-reference-invalid": "^2.0.0",
|
| 4014 |
+
"decode-named-character-reference": "^1.0.0",
|
| 4015 |
+
"is-alphanumerical": "^2.0.0",
|
| 4016 |
+
"is-decimal": "^2.0.0",
|
| 4017 |
+
"is-hexadecimal": "^2.0.0"
|
| 4018 |
+
},
|
| 4019 |
+
"funding": {
|
| 4020 |
+
"type": "github",
|
| 4021 |
+
"url": "https://github.com/sponsors/wooorm"
|
| 4022 |
+
}
|
| 4023 |
+
},
|
| 4024 |
+
"node_modules/parse-entities/node_modules/@types/unist": {
|
| 4025 |
+
"version": "2.0.11",
|
| 4026 |
+
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz",
|
| 4027 |
+
"integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="
|
| 4028 |
+
},
|
| 4029 |
"node_modules/parse5": {
|
| 4030 |
"version": "8.0.1",
|
| 4031 |
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
|
|
|
|
| 4111 |
"license": "MIT",
|
| 4112 |
"peer": true
|
| 4113 |
},
|
| 4114 |
+
"node_modules/property-information": {
|
| 4115 |
+
"version": "7.2.0",
|
| 4116 |
+
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz",
|
| 4117 |
+
"integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==",
|
| 4118 |
+
"funding": {
|
| 4119 |
+
"type": "github",
|
| 4120 |
+
"url": "https://github.com/sponsors/wooorm"
|
| 4121 |
+
}
|
| 4122 |
+
},
|
| 4123 |
"node_modules/punycode": {
|
| 4124 |
"version": "2.3.1",
|
| 4125 |
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
|
|
|
| 4158 |
"license": "MIT",
|
| 4159 |
"peer": true
|
| 4160 |
},
|
| 4161 |
+
"node_modules/react-markdown": {
|
| 4162 |
+
"version": "10.1.0",
|
| 4163 |
+
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
|
| 4164 |
+
"integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==",
|
| 4165 |
+
"dependencies": {
|
| 4166 |
+
"@types/hast": "^3.0.0",
|
| 4167 |
+
"@types/mdast": "^4.0.0",
|
| 4168 |
+
"devlop": "^1.0.0",
|
| 4169 |
+
"hast-util-to-jsx-runtime": "^2.0.0",
|
| 4170 |
+
"html-url-attributes": "^3.0.0",
|
| 4171 |
+
"mdast-util-to-hast": "^13.0.0",
|
| 4172 |
+
"remark-parse": "^11.0.0",
|
| 4173 |
+
"remark-rehype": "^11.0.0",
|
| 4174 |
+
"unified": "^11.0.0",
|
| 4175 |
+
"unist-util-visit": "^5.0.0",
|
| 4176 |
+
"vfile": "^6.0.0"
|
| 4177 |
+
},
|
| 4178 |
+
"funding": {
|
| 4179 |
+
"type": "opencollective",
|
| 4180 |
+
"url": "https://opencollective.com/unified"
|
| 4181 |
+
},
|
| 4182 |
+
"peerDependencies": {
|
| 4183 |
+
"@types/react": ">=18",
|
| 4184 |
+
"react": ">=18"
|
| 4185 |
+
}
|
| 4186 |
+
},
|
| 4187 |
"node_modules/react-redux": {
|
| 4188 |
"version": "9.3.0",
|
| 4189 |
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
|
|
|
|
| 4266 |
"redux": "^5.0.0"
|
| 4267 |
}
|
| 4268 |
},
|
| 4269 |
+
"node_modules/rehype-highlight": {
|
| 4270 |
+
"version": "7.0.2",
|
| 4271 |
+
"resolved": "https://registry.npmjs.org/rehype-highlight/-/rehype-highlight-7.0.2.tgz",
|
| 4272 |
+
"integrity": "sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA==",
|
| 4273 |
+
"dependencies": {
|
| 4274 |
+
"@types/hast": "^3.0.0",
|
| 4275 |
+
"hast-util-to-text": "^4.0.0",
|
| 4276 |
+
"lowlight": "^3.0.0",
|
| 4277 |
+
"unist-util-visit": "^5.0.0",
|
| 4278 |
+
"vfile": "^6.0.0"
|
| 4279 |
+
},
|
| 4280 |
+
"funding": {
|
| 4281 |
+
"type": "opencollective",
|
| 4282 |
+
"url": "https://opencollective.com/unified"
|
| 4283 |
+
}
|
| 4284 |
+
},
|
| 4285 |
+
"node_modules/remark-gfm": {
|
| 4286 |
+
"version": "4.0.1",
|
| 4287 |
+
"resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
|
| 4288 |
+
"integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==",
|
| 4289 |
+
"dependencies": {
|
| 4290 |
+
"@types/mdast": "^4.0.0",
|
| 4291 |
+
"mdast-util-gfm": "^3.0.0",
|
| 4292 |
+
"micromark-extension-gfm": "^3.0.0",
|
| 4293 |
+
"remark-parse": "^11.0.0",
|
| 4294 |
+
"remark-stringify": "^11.0.0",
|
| 4295 |
+
"unified": "^11.0.0"
|
| 4296 |
+
},
|
| 4297 |
+
"funding": {
|
| 4298 |
+
"type": "opencollective",
|
| 4299 |
+
"url": "https://opencollective.com/unified"
|
| 4300 |
+
}
|
| 4301 |
+
},
|
| 4302 |
+
"node_modules/remark-parse": {
|
| 4303 |
+
"version": "11.0.0",
|
| 4304 |
+
"resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
|
| 4305 |
+
"integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==",
|
| 4306 |
+
"dependencies": {
|
| 4307 |
+
"@types/mdast": "^4.0.0",
|
| 4308 |
+
"mdast-util-from-markdown": "^2.0.0",
|
| 4309 |
+
"micromark-util-types": "^2.0.0",
|
| 4310 |
+
"unified": "^11.0.0"
|
| 4311 |
+
},
|
| 4312 |
+
"funding": {
|
| 4313 |
+
"type": "opencollective",
|
| 4314 |
+
"url": "https://opencollective.com/unified"
|
| 4315 |
+
}
|
| 4316 |
+
},
|
| 4317 |
+
"node_modules/remark-rehype": {
|
| 4318 |
+
"version": "11.1.2",
|
| 4319 |
+
"resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz",
|
| 4320 |
+
"integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==",
|
| 4321 |
+
"dependencies": {
|
| 4322 |
+
"@types/hast": "^3.0.0",
|
| 4323 |
+
"@types/mdast": "^4.0.0",
|
| 4324 |
+
"mdast-util-to-hast": "^13.0.0",
|
| 4325 |
+
"unified": "^11.0.0",
|
| 4326 |
+
"vfile": "^6.0.0"
|
| 4327 |
+
},
|
| 4328 |
+
"funding": {
|
| 4329 |
+
"type": "opencollective",
|
| 4330 |
+
"url": "https://opencollective.com/unified"
|
| 4331 |
+
}
|
| 4332 |
+
},
|
| 4333 |
+
"node_modules/remark-stringify": {
|
| 4334 |
+
"version": "11.0.0",
|
| 4335 |
+
"resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz",
|
| 4336 |
+
"integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==",
|
| 4337 |
+
"dependencies": {
|
| 4338 |
+
"@types/mdast": "^4.0.0",
|
| 4339 |
+
"mdast-util-to-markdown": "^2.0.0",
|
| 4340 |
+
"unified": "^11.0.0"
|
| 4341 |
+
},
|
| 4342 |
+
"funding": {
|
| 4343 |
+
"type": "opencollective",
|
| 4344 |
+
"url": "https://opencollective.com/unified"
|
| 4345 |
+
}
|
| 4346 |
+
},
|
| 4347 |
"node_modules/require-from-string": {
|
| 4348 |
"version": "2.0.2",
|
| 4349 |
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
|
|
|
| 4493 |
"node": ">=0.10.0"
|
| 4494 |
}
|
| 4495 |
},
|
| 4496 |
+
"node_modules/space-separated-tokens": {
|
| 4497 |
+
"version": "2.0.2",
|
| 4498 |
+
"resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz",
|
| 4499 |
+
"integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==",
|
| 4500 |
+
"funding": {
|
| 4501 |
+
"type": "github",
|
| 4502 |
+
"url": "https://github.com/sponsors/wooorm"
|
| 4503 |
+
}
|
| 4504 |
+
},
|
| 4505 |
"node_modules/stackback": {
|
| 4506 |
"version": "0.0.2",
|
| 4507 |
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
|
|
|
| 4516 |
"dev": true,
|
| 4517 |
"license": "MIT"
|
| 4518 |
},
|
| 4519 |
+
"node_modules/stringify-entities": {
|
| 4520 |
+
"version": "4.0.4",
|
| 4521 |
+
"resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
|
| 4522 |
+
"integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==",
|
| 4523 |
+
"dependencies": {
|
| 4524 |
+
"character-entities-html4": "^2.0.0",
|
| 4525 |
+
"character-entities-legacy": "^3.0.0"
|
| 4526 |
+
},
|
| 4527 |
+
"funding": {
|
| 4528 |
+
"type": "github",
|
| 4529 |
+
"url": "https://github.com/sponsors/wooorm"
|
| 4530 |
+
}
|
| 4531 |
+
},
|
| 4532 |
"node_modules/strip-indent": {
|
| 4533 |
"version": "3.0.0",
|
| 4534 |
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
|
|
|
|
| 4542 |
"node": ">=8"
|
| 4543 |
}
|
| 4544 |
},
|
| 4545 |
+
"node_modules/style-to-js": {
|
| 4546 |
+
"version": "1.1.21",
|
| 4547 |
+
"resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz",
|
| 4548 |
+
"integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==",
|
| 4549 |
+
"dependencies": {
|
| 4550 |
+
"style-to-object": "1.0.14"
|
| 4551 |
+
}
|
| 4552 |
+
},
|
| 4553 |
+
"node_modules/style-to-object": {
|
| 4554 |
+
"version": "1.0.14",
|
| 4555 |
+
"resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz",
|
| 4556 |
+
"integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==",
|
| 4557 |
+
"dependencies": {
|
| 4558 |
+
"inline-style-parser": "0.2.7"
|
| 4559 |
+
}
|
| 4560 |
+
},
|
| 4561 |
"node_modules/styled-jsx": {
|
| 4562 |
"version": "5.1.6",
|
| 4563 |
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
|
|
|
|
| 4698 |
"node": ">=20"
|
| 4699 |
}
|
| 4700 |
},
|
| 4701 |
+
"node_modules/trim-lines": {
|
| 4702 |
+
"version": "3.0.1",
|
| 4703 |
+
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
|
| 4704 |
+
"integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==",
|
| 4705 |
+
"funding": {
|
| 4706 |
+
"type": "github",
|
| 4707 |
+
"url": "https://github.com/sponsors/wooorm"
|
| 4708 |
+
}
|
| 4709 |
+
},
|
| 4710 |
+
"node_modules/trough": {
|
| 4711 |
+
"version": "2.2.0",
|
| 4712 |
+
"resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz",
|
| 4713 |
+
"integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==",
|
| 4714 |
+
"funding": {
|
| 4715 |
+
"type": "github",
|
| 4716 |
+
"url": "https://github.com/sponsors/wooorm"
|
| 4717 |
+
}
|
| 4718 |
+
},
|
| 4719 |
"node_modules/tslib": {
|
| 4720 |
"version": "2.8.1",
|
| 4721 |
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
|
|
|
| 4753 |
"dev": true,
|
| 4754 |
"license": "MIT"
|
| 4755 |
},
|
| 4756 |
+
"node_modules/unified": {
|
| 4757 |
+
"version": "11.0.5",
|
| 4758 |
+
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
|
| 4759 |
+
"integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==",
|
| 4760 |
+
"dependencies": {
|
| 4761 |
+
"@types/unist": "^3.0.0",
|
| 4762 |
+
"bail": "^2.0.0",
|
| 4763 |
+
"devlop": "^1.0.0",
|
| 4764 |
+
"extend": "^3.0.0",
|
| 4765 |
+
"is-plain-obj": "^4.0.0",
|
| 4766 |
+
"trough": "^2.0.0",
|
| 4767 |
+
"vfile": "^6.0.0"
|
| 4768 |
+
},
|
| 4769 |
+
"funding": {
|
| 4770 |
+
"type": "opencollective",
|
| 4771 |
+
"url": "https://opencollective.com/unified"
|
| 4772 |
+
}
|
| 4773 |
+
},
|
| 4774 |
+
"node_modules/unist-util-find-after": {
|
| 4775 |
+
"version": "5.0.0",
|
| 4776 |
+
"resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz",
|
| 4777 |
+
"integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==",
|
| 4778 |
+
"dependencies": {
|
| 4779 |
+
"@types/unist": "^3.0.0",
|
| 4780 |
+
"unist-util-is": "^6.0.0"
|
| 4781 |
+
},
|
| 4782 |
+
"funding": {
|
| 4783 |
+
"type": "opencollective",
|
| 4784 |
+
"url": "https://opencollective.com/unified"
|
| 4785 |
+
}
|
| 4786 |
+
},
|
| 4787 |
+
"node_modules/unist-util-is": {
|
| 4788 |
+
"version": "6.0.1",
|
| 4789 |
+
"resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz",
|
| 4790 |
+
"integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==",
|
| 4791 |
+
"dependencies": {
|
| 4792 |
+
"@types/unist": "^3.0.0"
|
| 4793 |
+
},
|
| 4794 |
+
"funding": {
|
| 4795 |
+
"type": "opencollective",
|
| 4796 |
+
"url": "https://opencollective.com/unified"
|
| 4797 |
+
}
|
| 4798 |
+
},
|
| 4799 |
+
"node_modules/unist-util-position": {
|
| 4800 |
+
"version": "5.0.0",
|
| 4801 |
+
"resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz",
|
| 4802 |
+
"integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==",
|
| 4803 |
+
"dependencies": {
|
| 4804 |
+
"@types/unist": "^3.0.0"
|
| 4805 |
+
},
|
| 4806 |
+
"funding": {
|
| 4807 |
+
"type": "opencollective",
|
| 4808 |
+
"url": "https://opencollective.com/unified"
|
| 4809 |
+
}
|
| 4810 |
+
},
|
| 4811 |
+
"node_modules/unist-util-stringify-position": {
|
| 4812 |
+
"version": "4.0.0",
|
| 4813 |
+
"resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz",
|
| 4814 |
+
"integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==",
|
| 4815 |
+
"dependencies": {
|
| 4816 |
+
"@types/unist": "^3.0.0"
|
| 4817 |
+
},
|
| 4818 |
+
"funding": {
|
| 4819 |
+
"type": "opencollective",
|
| 4820 |
+
"url": "https://opencollective.com/unified"
|
| 4821 |
+
}
|
| 4822 |
+
},
|
| 4823 |
+
"node_modules/unist-util-visit": {
|
| 4824 |
+
"version": "5.1.0",
|
| 4825 |
+
"resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz",
|
| 4826 |
+
"integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==",
|
| 4827 |
+
"dependencies": {
|
| 4828 |
+
"@types/unist": "^3.0.0",
|
| 4829 |
+
"unist-util-is": "^6.0.0",
|
| 4830 |
+
"unist-util-visit-parents": "^6.0.0"
|
| 4831 |
+
},
|
| 4832 |
+
"funding": {
|
| 4833 |
+
"type": "opencollective",
|
| 4834 |
+
"url": "https://opencollective.com/unified"
|
| 4835 |
+
}
|
| 4836 |
+
},
|
| 4837 |
+
"node_modules/unist-util-visit-parents": {
|
| 4838 |
+
"version": "6.0.2",
|
| 4839 |
+
"resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz",
|
| 4840 |
+
"integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==",
|
| 4841 |
+
"dependencies": {
|
| 4842 |
+
"@types/unist": "^3.0.0",
|
| 4843 |
+
"unist-util-is": "^6.0.0"
|
| 4844 |
+
},
|
| 4845 |
+
"funding": {
|
| 4846 |
+
"type": "opencollective",
|
| 4847 |
+
"url": "https://opencollective.com/unified"
|
| 4848 |
+
}
|
| 4849 |
+
},
|
| 4850 |
"node_modules/use-sync-external-store": {
|
| 4851 |
"version": "1.7.0",
|
| 4852 |
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.7.0.tgz",
|
|
|
|
| 4856 |
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
| 4857 |
}
|
| 4858 |
},
|
| 4859 |
+
"node_modules/vfile": {
|
| 4860 |
+
"version": "6.0.3",
|
| 4861 |
+
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
|
| 4862 |
+
"integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==",
|
| 4863 |
+
"dependencies": {
|
| 4864 |
+
"@types/unist": "^3.0.0",
|
| 4865 |
+
"vfile-message": "^4.0.0"
|
| 4866 |
+
},
|
| 4867 |
+
"funding": {
|
| 4868 |
+
"type": "opencollective",
|
| 4869 |
+
"url": "https://opencollective.com/unified"
|
| 4870 |
+
}
|
| 4871 |
+
},
|
| 4872 |
+
"node_modules/vfile-message": {
|
| 4873 |
+
"version": "4.0.3",
|
| 4874 |
+
"resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz",
|
| 4875 |
+
"integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==",
|
| 4876 |
+
"dependencies": {
|
| 4877 |
+
"@types/unist": "^3.0.0",
|
| 4878 |
+
"unist-util-stringify-position": "^4.0.0"
|
| 4879 |
+
},
|
| 4880 |
+
"funding": {
|
| 4881 |
+
"type": "opencollective",
|
| 4882 |
+
"url": "https://opencollective.com/unified"
|
| 4883 |
+
}
|
| 4884 |
+
},
|
| 4885 |
"node_modules/victory-vendor": {
|
| 4886 |
"version": "37.3.6",
|
| 4887 |
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
|
|
|
| 5430 |
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
| 5431 |
"dev": true,
|
| 5432 |
"license": "MIT"
|
| 5433 |
+
},
|
| 5434 |
+
"node_modules/zwitch": {
|
| 5435 |
+
"version": "2.0.4",
|
| 5436 |
+
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
|
| 5437 |
+
"integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==",
|
| 5438 |
+
"funding": {
|
| 5439 |
+
"type": "github",
|
| 5440 |
+
"url": "https://github.com/sponsors/wooorm"
|
| 5441 |
+
}
|
| 5442 |
}
|
| 5443 |
}
|
| 5444 |
}
|
frontend/package.json
CHANGED
|
@@ -13,7 +13,10 @@
|
|
| 13 |
"next": "16.3.5",
|
| 14 |
"react": "19.3.0",
|
| 15 |
"react-dom": "19.3.0",
|
| 16 |
-
"
|
|
|
|
|
|
|
|
|
|
| 17 |
},
|
| 18 |
"devDependencies": {
|
| 19 |
"@tailwindcss/postcss": "4.3.3",
|
|
|
|
| 13 |
"next": "16.3.5",
|
| 14 |
"react": "19.3.0",
|
| 15 |
"react-dom": "19.3.0",
|
| 16 |
+
"react-markdown": "^10.1.0",
|
| 17 |
+
"recharts": "^3.8.0",
|
| 18 |
+
"rehype-highlight": "^7.0.2",
|
| 19 |
+
"remark-gfm": "^4.0.1"
|
| 20 |
},
|
| 21 |
"devDependencies": {
|
| 22 |
"@tailwindcss/postcss": "4.3.3",
|
frontend/tests/catalog.test.tsx
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
| 2 |
+
import userEvent from "@testing-library/user-event";
|
| 3 |
+
import { describe, expect, it, vi } from "vitest";
|
| 4 |
+
import { loadAllModels, Model } from "@/lib/api";
|
| 5 |
+
import { demoModels, demoProviders } from "@/lib/demo";
|
| 6 |
+
import Playground from "@/app/playground";
|
| 7 |
+
import MarkdownOutput from "@/app/markdown-output";
|
| 8 |
+
|
| 9 |
+
const catalog: Model[] = Array.from({ length: 1205 }, (_, i) => ({
|
| 10 |
+
...demoModels[0],
|
| 11 |
+
id: `model-${i}`,
|
| 12 |
+
name: `Catalog model ${i}`,
|
| 13 |
+
model_id: `catalog/${i}`,
|
| 14 |
+
route_id: `provider::catalog/${i}`,
|
| 15 |
+
input_price: i === 1204 ? 0 : 1,
|
| 16 |
+
output_price: i === 1204 ? 0 : 2,
|
| 17 |
+
}));
|
| 18 |
+
|
| 19 |
+
describe("Complete model catalog", () => {
|
| 20 |
+
it("loads every page, including free models beyond the first thousand", async () => {
|
| 21 |
+
const fetcher = vi.fn(async (url: string) => {
|
| 22 |
+
const offset = Number(
|
| 23 |
+
new URL(url, "http://test").searchParams.get("offset"),
|
| 24 |
+
);
|
| 25 |
+
return new Response(
|
| 26 |
+
JSON.stringify({
|
| 27 |
+
data: catalog.slice(offset, offset + 1000),
|
| 28 |
+
total: catalog.length,
|
| 29 |
+
}),
|
| 30 |
+
);
|
| 31 |
+
});
|
| 32 |
+
vi.stubGlobal("fetch", fetcher);
|
| 33 |
+
const all = await loadAllModels();
|
| 34 |
+
expect(all).toHaveLength(1205);
|
| 35 |
+
expect(all.at(-1)?.input_price).toBe(0);
|
| 36 |
+
expect(fetcher.mock.calls.map(([url]) => url)).toEqual([
|
| 37 |
+
"/api/models?limit=1000&offset=0",
|
| 38 |
+
"/api/models?limit=1000&offset=1000",
|
| 39 |
+
]);
|
| 40 |
+
});
|
| 41 |
+
it("reports incomplete catalogs instead of silently hiding models", async () => {
|
| 42 |
+
vi.stubGlobal(
|
| 43 |
+
"fetch",
|
| 44 |
+
vi
|
| 45 |
+
.fn()
|
| 46 |
+
.mockResolvedValue(
|
| 47 |
+
new Response(JSON.stringify({ data: [], total: 1205 })),
|
| 48 |
+
),
|
| 49 |
+
);
|
| 50 |
+
await expect(loadAllModels()).rejects.toThrow("complete catalog");
|
| 51 |
+
});
|
| 52 |
+
it("searches the full catalog and keeps selection when the workspace refreshes", async () => {
|
| 53 |
+
const loadCatalog = vi.fn().mockResolvedValue(catalog);
|
| 54 |
+
const props = {
|
| 55 |
+
demo: false,
|
| 56 |
+
providers: demoProviders,
|
| 57 |
+
models: catalog.slice(0, 100),
|
| 58 |
+
loadCatalog,
|
| 59 |
+
requireAccount: (fn: () => void) => fn(),
|
| 60 |
+
notify: vi.fn(),
|
| 61 |
+
onFinish: vi.fn().mockResolvedValue(undefined),
|
| 62 |
+
};
|
| 63 |
+
const fetcher = vi
|
| 64 |
+
.fn()
|
| 65 |
+
.mockImplementation(
|
| 66 |
+
async () =>
|
| 67 |
+
new Response(
|
| 68 |
+
'data: {"choices":[{"delta":{"content":"Selected model worked"}}]}\n\ndata: [DONE]\n\n',
|
| 69 |
+
),
|
| 70 |
+
);
|
| 71 |
+
vi.stubGlobal("fetch", fetcher);
|
| 72 |
+
const { rerender } = render(<Playground {...props} />);
|
| 73 |
+
const user = userEvent.setup();
|
| 74 |
+
await user.click(screen.getByRole("button", { name: "Select model" }));
|
| 75 |
+
expect(
|
| 76 |
+
await screen.findByText("1205 matching models · 1205 in this catalog"),
|
| 77 |
+
).toBeVisible();
|
| 78 |
+
await user.click(screen.getByRole("checkbox", { name: "Free only" }));
|
| 79 |
+
fireEvent.change(
|
| 80 |
+
screen.getByRole("textbox", { name: "Search all models" }),
|
| 81 |
+
{ target: { value: "catalog/1204" } },
|
| 82 |
+
);
|
| 83 |
+
await user.click(
|
| 84 |
+
screen.getByRole("button", { name: /Choose Catalog model 1204/ }),
|
| 85 |
+
);
|
| 86 |
+
await user.click(screen.getByRole("button", { name: "Send request" }));
|
| 87 |
+
expect(await screen.findByText("Selected model worked")).toBeVisible();
|
| 88 |
+
rerender(<Playground {...props} models={[]} />);
|
| 89 |
+
expect(
|
| 90 |
+
screen.getByRole("button", { name: "Select model" }),
|
| 91 |
+
).toHaveTextContent("Catalog model 1204");
|
| 92 |
+
await user.click(screen.getByRole("button", { name: "Send request" }));
|
| 93 |
+
await waitFor(() => expect(fetcher).toHaveBeenCalledTimes(2));
|
| 94 |
+
expect(
|
| 95 |
+
fetcher.mock.calls.map((call) => JSON.parse(call[1].body).model),
|
| 96 |
+
).toEqual(["provider::catalog/1204", "provider::catalog/1204"]);
|
| 97 |
+
expect(loadCatalog).toHaveBeenCalledOnce();
|
| 98 |
+
});
|
| 99 |
+
it("blocks manual auto-routing and supports a model selected in explorer", async () => {
|
| 100 |
+
const fetcher = vi.fn();
|
| 101 |
+
vi.stubGlobal("fetch", fetcher);
|
| 102 |
+
const props = {
|
| 103 |
+
demo: false,
|
| 104 |
+
providers: demoProviders,
|
| 105 |
+
models: catalog,
|
| 106 |
+
loadCatalog: vi.fn().mockResolvedValue(catalog),
|
| 107 |
+
requireAccount: (fn: () => void) => fn(),
|
| 108 |
+
notify: vi.fn(),
|
| 109 |
+
onFinish: vi.fn().mockResolvedValue(undefined),
|
| 110 |
+
};
|
| 111 |
+
const { unmount } = render(<Playground {...props} />);
|
| 112 |
+
const user = userEvent.setup();
|
| 113 |
+
await user.selectOptions(
|
| 114 |
+
screen.getByLabelText("Routing strategy"),
|
| 115 |
+
"manual",
|
| 116 |
+
);
|
| 117 |
+
expect(screen.getByRole("button", { name: "Send request" })).toBeDisabled();
|
| 118 |
+
fireEvent.keyDown(screen.getByLabelText("Your message"), {
|
| 119 |
+
key: "Enter",
|
| 120 |
+
ctrlKey: true,
|
| 121 |
+
});
|
| 122 |
+
expect(fetcher).not.toHaveBeenCalled();
|
| 123 |
+
unmount();
|
| 124 |
+
render(<Playground {...props} initialModel={catalog[1204]} />);
|
| 125 |
+
expect(
|
| 126 |
+
screen.getByRole("button", { name: "Select model" }),
|
| 127 |
+
).toHaveTextContent("Catalog model 1204");
|
| 128 |
+
expect(screen.getByLabelText("Routing strategy")).toHaveValue("manual");
|
| 129 |
+
});
|
| 130 |
+
it("renders Markdown and code without loading images or executing HTML", () => {
|
| 131 |
+
const { container } = render(
|
| 132 |
+
<MarkdownOutput
|
| 133 |
+
text={
|
| 134 |
+
"## Result\n\n**Useful** output\n\n```js\nconst answer = 42;\n```\n\n\n\n<script>alert(1)</script>\n\n[bad](javascript:alert(1))"
|
| 135 |
+
}
|
| 136 |
+
notify={vi.fn()}
|
| 137 |
+
/>,
|
| 138 |
+
);
|
| 139 |
+
expect(screen.getByRole("heading", { name: "Result" })).toBeVisible();
|
| 140 |
+
expect(screen.getByRole("button", { name: "Copy code" })).toBeEnabled();
|
| 141 |
+
expect(container.querySelector("pre code")).toHaveTextContent(
|
| 142 |
+
"const answer = 42;",
|
| 143 |
+
);
|
| 144 |
+
expect(container.querySelector("img,script")).toBeNull();
|
| 145 |
+
expect(container.querySelector('a[href^="javascript:"]')).toBeNull();
|
| 146 |
+
});
|
| 147 |
+
});
|
frontend/tests/playground.test.tsx
CHANGED
|
@@ -10,6 +10,7 @@ function mount(demo = false) {
|
|
| 10 |
demo,
|
| 11 |
providers: demoProviders,
|
| 12 |
models: demoModels,
|
|
|
|
| 13 |
requireAccount: vi.fn((fn: () => void) => {
|
| 14 |
if (!demo) fn();
|
| 15 |
}),
|
|
|
|
| 10 |
demo,
|
| 11 |
providers: demoProviders,
|
| 12 |
models: demoModels,
|
| 13 |
+
loadCatalog: vi.fn().mockResolvedValue(demoModels),
|
| 14 |
requireAccount: vi.fn((fn: () => void) => {
|
| 15 |
if (!demo) fn();
|
| 16 |
}),
|
frontend/tests/setup.ts
CHANGED
|
@@ -40,6 +40,7 @@ export function setViewport(width: number) {
|
|
| 40 |
});
|
| 41 |
}
|
| 42 |
beforeEach(() => {
|
|
|
|
| 43 |
setViewport(1440);
|
| 44 |
window.history.replaceState(null, "", "/");
|
| 45 |
document.documentElement.lang = "en";
|
|
|
|
| 40 |
});
|
| 41 |
}
|
| 42 |
beforeEach(() => {
|
| 43 |
+
window.scrollTo = vi.fn();
|
| 44 |
setViewport(1440);
|
| 45 |
window.history.replaceState(null, "", "/");
|
| 46 |
document.documentElement.lang = "en";
|
frontend/tests/workspace.test.tsx
CHANGED
|
@@ -370,7 +370,9 @@ describe("Authenticated workspace actions", () => {
|
|
| 370 |
const { user, fetcher } = await mounted(true);
|
| 371 |
await go("models");
|
| 372 |
await user.click(
|
| 373 |
-
screen.
|
|
|
|
|
|
|
| 374 |
);
|
| 375 |
await waitFor(() =>
|
| 376 |
expect(fetcher).toHaveBeenCalledWith(
|
|
|
|
| 370 |
const { user, fetcher } = await mounted(true);
|
| 371 |
await go("models");
|
| 372 |
await user.click(
|
| 373 |
+
await screen.findByRole("button", {
|
| 374 |
+
name: `Favorite ${demoModels[0].name}`,
|
| 375 |
+
}),
|
| 376 |
);
|
| 377 |
await waitFor(() =>
|
| 378 |
expect(fetcher).toHaveBeenCalledWith(
|
scripts/admin.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Manage administrator roles using access to the gateway server."""
|
| 2 |
+
|
| 3 |
+
import argparse
|
| 4 |
+
import asyncio
|
| 5 |
+
import os
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
from app.admin_bootstrap import provision_admin
|
| 9 |
+
from app.config import Settings
|
| 10 |
+
from app.db import create_database
|
| 11 |
+
from app.models import User
|
| 12 |
+
from sqlalchemy import select
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
async def run() -> None:
|
| 16 |
+
parser = argparse.ArgumentParser(description=__doc__)
|
| 17 |
+
parser.add_argument("action", choices=["grant", "revoke", "list", "bootstrap"])
|
| 18 |
+
parser.add_argument(
|
| 19 |
+
"--email",
|
| 20 |
+
help="Exact account email; only bootstrap with a server seed can create it",
|
| 21 |
+
)
|
| 22 |
+
args = parser.parse_args()
|
| 23 |
+
if args.action != "list" and not args.email:
|
| 24 |
+
parser.error("--email is required for grant/revoke/bootstrap")
|
| 25 |
+
# docker exec does not inherit variables set inside deploy/start.py.
|
| 26 |
+
root = Path(os.environ.get("DATA_DIR", "/data"))
|
| 27 |
+
if (
|
| 28 |
+
not os.environ.get("DATABASE_URL")
|
| 29 |
+
and (root / "postgres" / "PG_VERSION").exists()
|
| 30 |
+
):
|
| 31 |
+
os.environ["DATABASE_URL"] = (
|
| 32 |
+
f"postgresql+asyncpg://gateway@/gateway?host={root / 'run'}"
|
| 33 |
+
)
|
| 34 |
+
if not os.environ.get("REDIS_URL") and (root / "redis.sock").exists():
|
| 35 |
+
os.environ["REDIS_URL"] = f"unix://{root / 'redis.sock'}?db=0"
|
| 36 |
+
db_factory = create_database(Settings().database_url)
|
| 37 |
+
try:
|
| 38 |
+
async with db_factory() as db:
|
| 39 |
+
if args.action == "list":
|
| 40 |
+
for user in await db.scalars(
|
| 41 |
+
select(User).where(User.is_admin.is_(True))
|
| 42 |
+
):
|
| 43 |
+
print(f"{user.email} — administrator")
|
| 44 |
+
return
|
| 45 |
+
try:
|
| 46 |
+
user = await provision_admin(
|
| 47 |
+
db,
|
| 48 |
+
args.email,
|
| 49 |
+
args.action,
|
| 50 |
+
os.environ.get("ADMIN_BOOTSTRAP_PASSWORD_HASH"),
|
| 51 |
+
)
|
| 52 |
+
except ValueError:
|
| 53 |
+
raise SystemExit(
|
| 54 |
+
"Admin provisioning failed: check the existing account, suspension status, and bootstrap hash configuration. No password was changed."
|
| 55 |
+
) from None
|
| 56 |
+
print(
|
| 57 |
+
f"Administrator role {'granted to' if user.is_admin else 'revoked from'} {user.email}."
|
| 58 |
+
)
|
| 59 |
+
print(
|
| 60 |
+
"Sign in at /#admin with the account password. Existing passwords are never changed."
|
| 61 |
+
)
|
| 62 |
+
finally:
|
| 63 |
+
await db_factory.kw["bind"].dispose()
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
if __name__ == "__main__":
|
| 67 |
+
asyncio.run(run())
|
universal-ai-gateway-v1.zip
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:2776619003b565b68b158f134718a33c0a5a95b0a6ed07d6a5f5c754e82b26f5
|
| 3 |
+
size 650506
|