system HF Staff commited on
Commit
e8aac63
·
verified ·
1 Parent(s): 4ce6de2

Deploy gateway e062ef52b65ba61a8a7a891b6b583130528f3164

Browse files
.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, an admin UI, 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.
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. No shared provider credentials or free quota bypass is provided. Usage costs are partial estimates, not billing records.
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, request.app.state.settings.requests_per_minute
 
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
- if not settings.allow_registration:
 
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
- query = select(RegistryModel, Provider).join(Provider).where(Provider.user_id == user.id)
 
 
 
 
 
173
  if search:
 
174
  query = query.where(
175
- RegistryModel.model_id.ilike("%" + search.replace("%", "").replace("_", "")[:120] + "%")
 
 
 
 
 
 
 
 
 
 
 
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, request.app.state.settings.requests_per_minute
 
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 JSON, Boolean, Float, ForeignKey, Integer, String, Text, UniqueConstraint
 
 
 
 
 
 
 
 
 
 
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=c.provider.name if c else None,
 
 
 
 
 
 
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=estimated,
 
 
 
 
 
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": c.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
- "Upstream request failed. Inspect the request ID in your dashboard; provider response details are redacted.",
 
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">N</div>
50
- <h2>
51
- Your models.
52
- <br />
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("auto"),
47
  [provider, setProvider] = useState(""),
48
- [model, setModel] = useState("auto"),
49
- [stream, setStream] = useState(true),
50
  [output, setOutput] = useState(""),
51
  [running, setRunning] = useState(false),
52
  [info, setInfo] = useState(""),
53
  [alternatives, setAlternatives] = useState(false),
54
- [retries, setRetries] = useState(2);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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) setOutput((prev) => prev + 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 value={mode} onChange={(e) => setMode(e.target.value)}>
 
 
 
 
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
- setModel("auto");
229
  }}
230
  >
231
  <option value="">All enabled providers</option>
@@ -238,29 +322,30 @@ export default function Playground({
238
  ))}
239
  </select>
240
  </label>
241
- <label>
242
- Model
243
- <select value={model} onChange={(e) => setModel(e.target.value)}>
244
- <option value="auto">Auto-select a model</option>
245
- {models
246
- .filter(
247
- (m) =>
248
- (!provider || m.provider_id === provider) &&
249
- m.capabilities.chat !== false,
250
- )
251
- .map((m) => (
252
- <option
253
- key={m.id}
254
- value={provider ? m.model_id : m.route_id}
255
- >
256
- {m.name} · {m.provider_name}
257
- </option>
258
- ))}
259
- </select>
260
- </label>
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={setStream}
 
 
278
  />
279
  <Toggle
280
  label="Allow alternative models"
281
  checked={alternatives}
282
- onChange={setAlternatives}
 
 
283
  />
284
  <p className="form-note">
285
- Your server’s retry limit is the upper bound. Provider selection
286
- stays pinned. Alternatives require confirmed capabilities.
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
- prompt.trim() &&
320
  !running
321
  ) {
322
  e.preventDefault();
@@ -345,7 +436,7 @@ export default function Playground({
345
  ) : (
346
  <button
347
  className="button primary"
348
- disabled={!prompt.trim()}
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 && <CopyButton value={output} onCopy={notify} />}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
363
  </div>
364
  <div
365
  className={`response-output ${!output ? "no-output" : ""}`}
366
  aria-busy={running}
367
  >
368
  {output ? (
369
- <pre className={running ? "streaming" : ""}>{output}</pre>
 
 
 
 
370
  ) : running ? (
371
  <span>
372
  <LoaderCircle size={18} className="spin" />
373
- Waiting for the provider…
374
  </span>
375
  ) : (
376
  <span>
@@ -397,14 +512,21 @@ export default function Playground({
397
  : "Request error"}
398
  </strong>
399
  <span>{requestError.message}</span>
400
- <button
401
- className="text-button"
402
- disabled={running}
403
- onClick={() => requireAccount(() => void run())}
404
- >
405
- <RefreshCw size={14} />
406
- Retry request
407
- </button>
 
 
 
 
 
 
 
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
- {Math.round(log.latency_ms)} <span className="muted">ms</span>
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">N</span>
 
 
241
  <div>
242
- <strong>Nexus gateway</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
- api<User>("/auth/me")
180
- .then((u) => {
 
 
 
181
  setUser(u);
182
- setDemo(false);
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"} · Nexus AI Gateway`;
 
 
 
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
- setModels(r.data);
284
- setTotalModels(r.total);
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
- }, [view, demo, search, capability, filterProvider, modelPage, notify]);
 
 
 
 
 
 
 
 
 
 
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
- setLogs(r.data);
301
- setTotalLogs(r.total);
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.filter(
316
- (x) =>
317
- (!search ||
318
- [x.name, x.model_id].some((v) =>
319
- v.toLowerCase().includes(search.toLowerCase()),
320
- )) &&
321
- (!capability || x.capabilities[capability]) &&
322
- (!filterProvider || x.provider_id === filterProvider),
323
- )
324
- : m;
 
 
 
 
 
 
 
 
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={s.avg_latency_ms ? Math.round(s.avg_latency_ms).toString() : "—"}
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">N</span>
 
 
440
  <span>
441
- nexus<span className="brand-period">.</span>
 
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={() => setMobile(true)}
 
 
 
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 connections
 
 
 
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 providers={p} demo={demo} />
 
 
 
 
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 by name or ID…"
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 : totalModels} models{" "}
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 : totalModels}
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>{k.filter((key) => !key.revoked).length} keys</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
- errorsOnly && demo ? l.filter((x) => x.status >= 400) : l
 
 
 
 
1364
  }
1365
  onSelect={setSelectedLog}
1366
  />
1367
  <div className="table-footer">
1368
- <span>{demo ? l.length : totalLogs} requests</span>
1369
  <Pagination
1370
  page={logPage}
1371
  size={25}
1372
- total={demo ? l.length : totalLogs}
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">N</span>Nexus · Universal AI
1468
- Gateway
 
 
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>{Math.round(selectedLog.latency_ms)} ms</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">{Math.round(a.latency_ms)} ms</span>
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">{selectedLog.error_code}</Banner>
 
 
 
 
 
 
 
 
 
 
 
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 = { id: string; name: string; email: string };
 
 
 
 
 
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 ? "Unknown" : `$${value.toFixed(value < 0.01 ? 5 : 2)}`;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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: demoProviders[i % 4].name,
120
  requested_model: i % 3 === 0 ? "auto/coding" : "auto",
121
- resolved_model: demoModels[i % 6].model_id,
 
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: demoProviders[i % 4].kind,
149
- provider_name: demoProviders[i % 4].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
- "recharts": "^3.8.0"
 
 
 
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
- "engines": {
2690
- "node": ">= 12.0.0"
2691
- },
2692
- "funding": {
2693
- "type": "opencollective",
2694
- "url": "https://opencollective.com/parcel"
2695
  }
2696
  },
2697
- "node_modules/lru-cache": {
2698
- "version": "11.5.2",
2699
- "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz",
2700
- "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==",
2701
- "dev": true,
2702
- "license": "BlueOak-1.0.0",
2703
- "engines": {
2704
- "node": "20 || >=22"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2705
  }
2706
  },
2707
- "node_modules/lucide-react": {
2708
- "version": "0.577.0",
2709
- "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.577.0.tgz",
2710
- "integrity": "sha512-4LjoFv2eEPwYDPg/CUdBJQSDfPyzXCRrVW1X7jrx/trgxnxkHFjnVZINbzvzxjN70dxychOfg+FTYwBiS3pQ5A==",
2711
- "license": "ISC",
2712
- "peerDependencies": {
2713
- "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
 
 
 
 
 
 
 
 
 
2714
  }
2715
  },
2716
- "node_modules/lz-string": {
2717
- "version": "1.5.0",
2718
- "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
2719
- "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
2720
- "dev": true,
2721
- "license": "MIT",
2722
- "peer": true,
2723
- "bin": {
2724
- "lz-string": "bin/bin.js"
 
 
 
 
 
 
 
 
 
2725
  }
2726
  },
2727
- "node_modules/magic-string": {
2728
- "version": "0.30.21",
2729
- "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
2730
- "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
2731
- "dev": true,
2732
- "license": "MIT",
 
 
 
 
 
 
 
 
2733
  "dependencies": {
2734
- "@jridgewell/sourcemap-codec": "^1.5.5"
 
 
 
2735
  }
2736
  },
2737
- "node_modules/mdn-data": {
2738
- "version": "2.27.1",
2739
- "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
2740
- "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
2741
- "dev": true,
2742
- "license": "CC0-1.0"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- "recharts": "^3.8.0"
 
 
 
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![tracker](https://evil.example/track)\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.getByRole("button", { name: `Favorite ${demoModels[0].name}` }),
 
 
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