feat(ui): add Moyai cloud coding agent to the view switcher (#45196)

* feat(ui): add Moyai cloud coding agent to the view switcher

Co-Authored-By: Ishaan Jaffer <155045088+ishaan-berri@users.noreply.github.com>

* fix(ui): restore hover scale on Moyai GitHub CTA

Co-Authored-By: Ishaan Jaffer <155045088+ishaan-berri@users.noreply.github.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
ishaan-berri 2026-10-07 17:41:13 -07:00 • committed by GitHub
parent 70e6a2ff24
commit 5531169710
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 2101 additions and 14 deletions

View file

@ -853,6 +853,7 @@ class LiteLLMRoutes(enum.Enum):
"/public/mcp_hub",
"/public/skill_hub",
"/public/litellm_model_cost_map",
"/moyai/connect/exchange",
)
)

View file

@ -0,0 +1,260 @@
"""Moyai quick-connect endpoints.
`/moyai/connect/start` hands a proxy admin a signed, single-use code pointing
at their Moyai deployment. `/moyai/connect/exchange` trades that code for a
fresh virtual key and persists the deployment as the `moyai_url` UI setting.
The signed code is the credential for the exchange, so it must stay short
lived and single use.
"""
import base64
import hashlib
import hmac
import json
import os
import secrets
import time
from typing import TYPE_CHECKING, Annotated, Final
from urllib.parse import urlencode, urlparse
from fastapi import APIRouter, Depends, HTTPException, Request, status
from pydantic import BaseModel
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import (
UI_SETTINGS_CACHE_KEY,
UI_SETTINGS_CACHE_TTL,
_ui_settings_db,
normalize_moyai_url,
)
from litellm.repositories.config_repository import ConfigRepository
from litellm.repositories.table_repositories import UISettingsRepository
if TYPE_CHECKING:
from litellm.proxy.utils import PrismaClient
router: Final = APIRouter()
_MOYAI_CODE_TTL_SECONDS: Final = 600
_MOYAI_NONCE_CONFIG_PREFIX: Final = "moyai_connect_nonce:"
_MOYAI_CONNECT_EXCHANGE_ROUTE: Final = "/moyai/connect/exchange"
class MoyaiConnectStartRequest(BaseModel):
moyai_url: str
return_to: str
class MoyaiConnectStartResponse(BaseModel):
connect_url: str
class MoyaiConnectExchangeRequest(BaseModel):
code: str
moyai_url: str
class MoyaiConnectExchangeResponse(BaseModel):
api_key: str
key_alias: str
api_base: str
def _b64url(data: bytes) -> str:
return base64.urlsafe_b64encode(data).decode().rstrip("=")
def _b64url_decode(data: str) -> bytes:
return base64.urlsafe_b64decode(data + "=" * (-len(data) % 4))
def _origin(url: str) -> str:
parsed: Final = urlparse(url)
return f"{parsed.scheme}://{parsed.netloc}"
def _master_key_hmac_key(master_key: str) -> bytes:
return hashlib.sha256(master_key.encode()).digest()
def _gateway_url(request: Request) -> str:
if os.environ.get("PROXY_BASE_URL"):
return os.environ["PROXY_BASE_URL"].rstrip("/")
return str(request.base_url).rstrip("/")
_MOYAI_KEY_ALLOWED_ROUTES: Final = ["openai_routes", "anthropic_routes", "/model/info"]
def _sign_connect_code(master_key: str, moyai_url: str, user_id: str | None) -> str:
payload: Final = json.dumps(
{
"moyai_origin": _origin(moyai_url),
"user_id": user_id,
"exp": int(time.time()) + _MOYAI_CODE_TTL_SECONDS,
"nonce": secrets.token_urlsafe(16),
},
separators=(",", ":"),
sort_keys=True,
).encode()
signature: Final = hmac.new(_master_key_hmac_key(master_key), payload, hashlib.sha256).digest()
return f"{_b64url(payload)}.{_b64url(signature)}"
def _decode_connect_code(master_key: str, code: str) -> dict:
try:
payload_b64, signature_b64 = code.split(".", 1)
payload_raw: Final = _b64url_decode(payload_b64)
signature: Final = _b64url_decode(signature_b64)
except (ValueError, TypeError):
raise HTTPException(status_code=400, detail="Invalid Moyai connect code")
expected: Final = hmac.new(_master_key_hmac_key(master_key), payload_raw, hashlib.sha256).digest()
if not hmac.compare_digest(signature, expected):
raise HTTPException(status_code=400, detail="Invalid Moyai connect code")
try:
payload: Final = json.loads(payload_raw)
except (ValueError, TypeError):
raise HTTPException(status_code=400, detail="Invalid Moyai connect code")
if not isinstance(payload, dict) or not isinstance(payload.get("exp"), int) or payload["exp"] < int(time.time()):
raise HTTPException(status_code=400, detail="Invalid Moyai connect code")
if not isinstance(payload.get("moyai_origin"), str) or not isinstance(payload.get("nonce"), str):
raise HTTPException(status_code=400, detail="Invalid Moyai connect code")
return payload
@router.post(
"/moyai/connect/start",
response_model=MoyaiConnectStartResponse,
tags=["moyai"],
)
async def moyai_connect_start(
request: Request,
body: MoyaiConnectStartRequest,
user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)],
) -> MoyaiConnectStartResponse:
from litellm.proxy.proxy_server import master_key
if user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Only proxy admins can connect Moyai")
try:
moyai_url: Final = normalize_moyai_url(body.moyai_url)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
if moyai_url is None:
raise HTTPException(status_code=400, detail="moyai_url is required")
return_to_parsed: Final = urlparse(body.return_to)
if return_to_parsed.scheme not in ("http", "https") or not return_to_parsed.netloc:
raise HTTPException(status_code=400, detail="return_to must be an absolute http or https URL")
if not master_key:
raise HTTPException(
status_code=400,
detail="Moyai quick connect needs LITELLM_MASTER_KEY set on the proxy",
)
code: Final = _sign_connect_code(master_key, moyai_url, user_api_key_dict.user_id)
connect_url: Final = f"{moyai_url}/connect/litellm?" + urlencode(
{"gateway_url": _gateway_url(request), "code": code, "return_to": body.return_to}
)
return MoyaiConnectStartResponse(connect_url=connect_url)
async def _claim_connect_nonce(prisma_client: "PrismaClient", nonce: str, exp: int) -> None:
from prisma.errors import UniqueViolationError
try:
await ConfigRepository(prisma_client, use_writer=True).table.create(
data={
"param_name": f"{_MOYAI_NONCE_CONFIG_PREFIX}{nonce}",
"param_value": json.dumps({"exp": exp}),
}
)
except UniqueViolationError:
raise HTTPException(status_code=400, detail="Invalid Moyai connect code")
async def _moyai_key_alias(prisma_client, moyai_url: str) -> str:
from litellm.repositories.verification_token_repository import (
VerificationTokenRepository,
)
host: Final = urlparse(moyai_url).hostname or "deployment"
alias: Final = f"moyai-{host}"
rows: Final = await VerificationTokenRepository(prisma_client).find_many(where={"key_alias": alias}, take=1)
if rows:
return f"{alias}-{secrets.token_hex(2)}"
return alias
async def _persist_moyai_url(prisma_client, moyai_url: str) -> None:
from litellm.proxy.proxy_server import user_api_key_cache
existing: dict = {}
db_existing: Final = await _ui_settings_db(UISettingsRepository(prisma_client)).find_unique(
where={"id": "ui_settings"}
)
if db_existing and db_existing.ui_settings:
raw: Final = db_existing.ui_settings
existing = json.loads(raw) if isinstance(raw, str) else dict(raw)
ui_settings: Final = {**existing, "moyai_url": moyai_url}
await _ui_settings_db(UISettingsRepository(prisma_client)).upsert(
where={"id": "ui_settings"},
data={
"create": {"id": "ui_settings", "ui_settings": json.dumps(ui_settings)},
"update": {"ui_settings": json.dumps(ui_settings)},
},
)
await user_api_key_cache.async_set_cache(key=UI_SETTINGS_CACHE_KEY, value=ui_settings, ttl=UI_SETTINGS_CACHE_TTL)
@router.post(
_MOYAI_CONNECT_EXCHANGE_ROUTE,
response_model=MoyaiConnectExchangeResponse,
tags=["moyai"],
)
async def moyai_connect_exchange(request: Request, body: MoyaiConnectExchangeRequest) -> MoyaiConnectExchangeResponse:
from litellm.proxy.management_endpoints.key_management_endpoints import generate_key_helper_fn
from litellm.proxy.proxy_server import llm_router, master_key, prisma_client
if not master_key:
raise HTTPException(status_code=400, detail="Invalid Moyai connect code")
payload: Final = _decode_connect_code(master_key, body.code)
try:
moyai_url: Final = normalize_moyai_url(body.moyai_url)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid Moyai connect code")
if moyai_url is None or _origin(moyai_url) != payload["moyai_origin"]:
raise HTTPException(status_code=400, detail="Invalid Moyai connect code")
if prisma_client is None:
raise HTTPException(status_code=400, detail="Moyai quick connect needs a database connected to the proxy")
await _claim_connect_nonce(prisma_client, payload["nonce"], payload["exp"])
alias: Final = await _moyai_key_alias(prisma_client, moyai_url)
key_response: Final = await generate_key_helper_fn(
request_type="key",
key_alias=alias,
allowed_routes=_MOYAI_KEY_ALLOWED_ROUTES,
metadata={
"created_via": "moyai_quick_connect",
"moyai_url": moyai_url,
"connected_by": payload.get("user_id"),
},
table_name="key",
llm_router=llm_router,
)
await _persist_moyai_url(prisma_client, moyai_url)
return MoyaiConnectExchangeResponse(
api_key=key_response["token"],
key_alias=alias,
api_base=_gateway_url(request),
)

View file

@ -770,6 +770,7 @@ from litellm.proxy.middleware.request_size_limit_middleware import (
from litellm.proxy.middleware.security_headers_middleware import (
SecurityHeadersMiddleware,
)
from litellm.proxy.moyai_endpoints import router as moyai_router
from litellm.proxy.ocr_endpoints.endpoints import router as ocr_router
from litellm.proxy.openai_files_endpoints.files_endpoints import (
router as openai_files_router,
@ -20183,6 +20184,7 @@ app.include_router(callback_management_endpoints_router)
app.include_router(debugging_endpoints_router)
app.include_router(rust_control_plane_router)
app.include_router(ui_crud_endpoints_router)
app.include_router(moyai_router)
app.include_router(user_banner_endpoints_router)
app.include_router(latest_release_endpoints_router)
app.include_router(team_callback_router)

View file

@ -15,7 +15,7 @@ from typing import (
from urllib.parse import urlparse
from fastapi import APIRouter, Body, Depends, File, HTTPException, UploadFile
from pydantic import BaseModel, ConfigDict, JsonValue, TypeAdapter, ValidationError, create_model
from pydantic import BaseModel, ConfigDict, JsonValue, TypeAdapter, ValidationError, create_model, field_validator
from pydantic.fields import FieldInfo, PydanticUndefined
from typing_extensions import NotRequired, ReadOnly, TypedDict
@ -234,6 +234,20 @@ class UIThemeSettingsResponse(SettingsResponse):
_TEAM_ADMIN_FIELD_ENUM: Final = tuple(sorted(SUPPORTED_TEAM_ADMIN_PERMISSIONS))
def normalize_moyai_url(value: object) -> str | None:
if value is None:
return None
if not isinstance(value, str):
raise ValueError("moyai_url must be a string")
stripped: Final = value.strip()
if not stripped:
return None
parsed: Final = urlparse(stripped)
if parsed.scheme not in ("http", "https") or not parsed.hostname or parsed.username or parsed.password:
raise ValueError("moyai_url must be an http or https URL with a host and no credentials")
return stripped.rstrip("/")
class UISettings(LiteLLMBaseModel):
"""Configuration for UI-specific flags"""
@ -330,6 +344,16 @@ class UISettings(LiteLLMBaseModel):
description="If true, shows the Chat page in the UI sidebar, letting users chat with an LLM and connect their own MCP server credentials via OAuth.",
)
moyai_url: str | None = Field(
default=None,
description="URL of a connected Moyai deployment. When set, the Moyai entry in the UI navigation opens this deployment instead of the Moyai landing page.",
)
@field_validator("moyai_url", mode="before")
@classmethod
def _validate_moyai_url(cls, value: object) -> object:
return normalize_moyai_url(value)
team_admin_editable_team_fields: Sequence[str] = Field(
default=(),
description=(
@ -368,6 +392,7 @@ ALLOWED_UI_SETTINGS_FIELDS: Final = {
"disable_custom_api_keys",
"disable_key_generate_for_org_admin",
"enable_chat_ui",
"moyai_url",
TEAM_ADMIN_EDITABLE_TEAM_FIELDS_SETTING,
}

View file

@ -0,0 +1,290 @@
import json
import time
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
def _admin() -> UserAPIKeyAuth:
return UserAPIKeyAuth(user_id="admin-user", user_role=LitellmUserRoles.PROXY_ADMIN)
def _request() -> MagicMock:
request = MagicMock()
request.base_url = "http://localhost:4000/"
return request
@pytest.mark.asyncio
async def test_start_rejects_non_admin(monkeypatch: pytest.MonkeyPatch) -> None:
from fastapi import HTTPException
from litellm.proxy import proxy_server
from litellm.proxy.moyai_endpoints import MoyaiConnectStartRequest, moyai_connect_start
monkeypatch.setattr(proxy_server, "master_key", "sk-master")
actor = UserAPIKeyAuth(user_id="member", user_role="internal_user")
with pytest.raises(HTTPException) as exc:
await moyai_connect_start(
_request(),
MoyaiConnectStartRequest(moyai_url="https://moyai.example.com", return_to="http://localhost:3000/ui/moyai"),
actor,
)
assert exc.value.status_code == 403
@pytest.mark.asyncio
async def test_start_requires_master_key(monkeypatch: pytest.MonkeyPatch) -> None:
from fastapi import HTTPException
from litellm.proxy import proxy_server
from litellm.proxy.moyai_endpoints import MoyaiConnectStartRequest, moyai_connect_start
monkeypatch.setattr(proxy_server, "master_key", None)
with pytest.raises(HTTPException) as exc:
await moyai_connect_start(
_request(),
MoyaiConnectStartRequest(moyai_url="https://moyai.example.com", return_to="http://localhost:3000/ui/moyai"),
_admin(),
)
assert exc.value.status_code == 400
assert "LITELLM_MASTER_KEY" in exc.value.detail
@pytest.mark.asyncio
async def test_start_returns_connect_url_with_all_params(monkeypatch: pytest.MonkeyPatch) -> None:
from litellm.proxy import proxy_server
from litellm.proxy.moyai_endpoints import MoyaiConnectStartRequest, moyai_connect_start
from urllib.parse import parse_qs, urlparse
monkeypatch.setattr(proxy_server, "master_key", "sk-master")
response = await moyai_connect_start(
_request(),
MoyaiConnectStartRequest(moyai_url="https://moyai.example.com/", return_to="http://localhost:3000/ui/moyai"),
_admin(),
)
parsed = urlparse(response.connect_url)
assert f"{parsed.scheme}://{parsed.netloc}{parsed.path}" == "https://moyai.example.com/connect/litellm"
params = parse_qs(parsed.query)
assert params["gateway_url"] == ["http://localhost:4000"]
assert params["return_to"] == ["http://localhost:3000/ui/moyai"]
assert params["code"] and "." in params["code"][0]
async def _exchange_env(monkeypatch: pytest.MonkeyPatch):
from litellm.proxy import proxy_server
cache: dict = {}
async def _get(key: str):
return cache.get(key)
async def _set(key: str, value, ttl=None):
cache[key] = value
monkeypatch.setattr(proxy_server, "master_key", "sk-master")
monkeypatch.setattr(proxy_server, "llm_router", None)
user_api_key_cache = SimpleNamespace(
async_get_cache=AsyncMock(side_effect=_get), async_set_cache=AsyncMock(side_effect=_set)
)
monkeypatch.setattr(proxy_server, "user_api_key_cache", user_api_key_cache)
prisma = MagicMock()
prisma.db.litellm_uisettings.find_unique = AsyncMock(return_value=SimpleNamespace(ui_settings={}))
prisma.db.litellm_verificationtoken.find_many = AsyncMock(return_value=[])
claimed_nonces: set = set()
async def _config_create(*, data):
from prisma.errors import UniqueViolationError
param_name = data["param_name"]
if param_name in claimed_nonces:
raise UniqueViolationError({}, message="Unique constraint failed on the fields: (`param_name`)")
claimed_nonces.add(param_name)
nonce_create = AsyncMock(side_effect=_config_create)
config_table = SimpleNamespace(create=nonce_create)
prisma.db.litellm_config = config_table
prisma.writer_db.litellm_config = config_table
persisted: dict = {}
async def _upsert(where, data):
persisted.update(json.loads(data["update"]["ui_settings"]))
prisma.db.litellm_uisettings.upsert = AsyncMock(side_effect=_upsert)
monkeypatch.setattr(proxy_server, "prisma_client", prisma)
mint_calls: list = []
async def _mint(request_type, **kwargs):
mint_calls.append(kwargs)
return {"token": "sk-new-virtual-key"}
monkeypatch.setattr(
"litellm.proxy.management_endpoints.key_management_endpoints.generate_key_helper_fn",
AsyncMock(side_effect=_mint),
)
return persisted, mint_calls, nonce_create
async def _exchange_call(code: str, moyai_url: str):
from litellm.proxy.moyai_endpoints import MoyaiConnectExchangeRequest, moyai_connect_exchange
return await moyai_connect_exchange(_request(), MoyaiConnectExchangeRequest(code=code, moyai_url=moyai_url))
@pytest.mark.asyncio
async def test_exchange_happy_path_mints_key_and_saves_setting(monkeypatch: pytest.MonkeyPatch) -> None:
from litellm.proxy.moyai_endpoints import _sign_connect_code
persisted, mint_calls, _ = await _exchange_env(monkeypatch)
code = _sign_connect_code("sk-master", "https://moyai.example.com", "admin-user")
response = await _exchange_call(code, "https://moyai.example.com")
assert response.api_key == "sk-new-virtual-key"
assert response.key_alias == "moyai-moyai.example.com"
assert response.api_base == "http://localhost:4000"
assert persisted["moyai_url"] == "https://moyai.example.com"
mint = mint_calls[0]
assert "user_id" not in mint
assert mint["allowed_routes"] == ["openai_routes", "anthropic_routes", "/model/info"]
assert mint["metadata"] == {
"created_via": "moyai_quick_connect",
"moyai_url": "https://moyai.example.com",
"connected_by": "admin-user",
}
@pytest.mark.asyncio
async def test_exchange_without_database_fails_before_nonce(monkeypatch: pytest.MonkeyPatch) -> None:
from fastapi import HTTPException
from litellm.proxy import proxy_server
from litellm.proxy.moyai_endpoints import _sign_connect_code
_, _, nonce_create = await _exchange_env(monkeypatch)
monkeypatch.setattr(proxy_server, "prisma_client", None)
code = _sign_connect_code("sk-master", "https://moyai.example.com", "admin-user")
with pytest.raises(HTTPException) as exc:
await _exchange_call(code, "https://moyai.example.com")
assert exc.value.status_code == 400
assert "database" in exc.value.detail
nonce_create.assert_not_called()
@pytest.mark.asyncio
async def test_exchange_rejects_tampered_signature(monkeypatch: pytest.MonkeyPatch) -> None:
from fastapi import HTTPException
from litellm.proxy.moyai_endpoints import _sign_connect_code
await _exchange_env(monkeypatch)
code = _sign_connect_code("sk-master", "https://moyai.example.com", "admin-user")
tampered = code[:-2] + ("aa" if not code.endswith("aa") else "bb")
with pytest.raises(HTTPException) as exc:
await _exchange_call(tampered, "https://moyai.example.com")
assert exc.value.status_code == 400
@pytest.mark.asyncio
async def test_exchange_rejects_expired_code(monkeypatch: pytest.MonkeyPatch) -> None:
import base64
import hashlib
import hmac as hmac_mod
from fastapi import HTTPException
from litellm.proxy.moyai_endpoints import _b64url, _master_key_hmac_key
await _exchange_env(monkeypatch)
payload = json.dumps(
{
"moyai_origin": "https://moyai.example.com",
"user_id": "admin-user",
"exp": int(time.time()) - 10,
"nonce": "n",
},
separators=(",", ":"),
sort_keys=True,
).encode()
sig = hmac_mod.new(_master_key_hmac_key("sk-master"), payload, hashlib.sha256).digest()
code = f"{_b64url(payload)}.{_b64url(sig)}"
with pytest.raises(HTTPException) as exc:
await _exchange_call(code, "https://moyai.example.com")
assert exc.value.status_code == 400
@pytest.mark.asyncio
async def test_exchange_rejects_origin_mismatch(monkeypatch: pytest.MonkeyPatch) -> None:
from fastapi import HTTPException
from litellm.proxy.moyai_endpoints import _sign_connect_code
await _exchange_env(monkeypatch)
code = _sign_connect_code("sk-master", "https://moyai.example.com", "admin-user")
with pytest.raises(HTTPException) as exc:
await _exchange_call(code, "https://evil.example.com")
assert exc.value.status_code == 400
@pytest.mark.asyncio
async def test_exchange_rejects_replayed_nonce(monkeypatch: pytest.MonkeyPatch) -> None:
from fastapi import HTTPException
from litellm.proxy.moyai_endpoints import _sign_connect_code
_, mint_calls, _ = await _exchange_env(monkeypatch)
code = _sign_connect_code("sk-master", "https://moyai.example.com", "admin-user")
await _exchange_call(code, "https://moyai.example.com")
with pytest.raises(HTTPException) as exc:
await _exchange_call(code, "https://moyai.example.com")
assert exc.value.status_code == 400
assert len(mint_calls) == 1
@pytest.mark.asyncio
async def test_exchange_concurrent_replay_claims_nonce_once(monkeypatch: pytest.MonkeyPatch) -> None:
import asyncio
from fastapi import HTTPException
from litellm.proxy.moyai_endpoints import _sign_connect_code
_, mint_calls, _ = await _exchange_env(monkeypatch)
code = _sign_connect_code("sk-master", "https://moyai.example.com", "admin-user")
results = await asyncio.gather(
_exchange_call(code, "https://moyai.example.com"),
_exchange_call(code, "https://moyai.example.com"),
return_exceptions=True,
)
successes = [r for r in results if not isinstance(r, BaseException)]
rejections = [r for r in results if isinstance(r, HTTPException) and r.status_code == 400]
assert len(successes) == 1
assert len(rejections) == 1
assert len(mint_calls) == 1
@pytest.mark.asyncio
async def test_exchange_replay_survives_fresh_worker_cache(monkeypatch: pytest.MonkeyPatch) -> None:
from fastapi import HTTPException
from litellm.caching.caching import DualCache
from litellm.proxy import proxy_server
from litellm.proxy.moyai_endpoints import _sign_connect_code
_, mint_calls, _ = await _exchange_env(monkeypatch)
code = _sign_connect_code("sk-master", "https://moyai.example.com", "admin-user")
await _exchange_call(code, "https://moyai.example.com")
monkeypatch.setattr(proxy_server, "user_api_key_cache", DualCache())
with pytest.raises(HTTPException) as exc:
await _exchange_call(code, "https://moyai.example.com")
assert exc.value.status_code == 400
assert len(mint_calls) == 1

View file

@ -4165,3 +4165,71 @@ class TestSyncUiSettingsToGeneralSettings:
assert general_settings["forward_client_headers_to_llm_api"] is False
assert general_settings.source("forward_client_headers_to_llm_api") == "config"
class TestMoyaiUrlSetting:
@pytest.mark.parametrize(
"value,expected",
[
(None, None),
("", None),
("https://moyai.example.com", "https://moyai.example.com"),
("https://moyai.example.com/", "https://moyai.example.com"),
("http://localhost:8787/", "http://localhost:8787"),
],
)
def test_moyai_url_validator_accepts_and_normalizes(self, value, expected):
from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import UISettings
assert UISettings(moyai_url=value).moyai_url == expected
@pytest.mark.parametrize(
"value",
[
"javascript:alert(1)",
"ftp://moyai.example.com",
"https://user:pass@moyai.example.com",
"https://user@moyai.example.com",
"not-a-url",
"https://",
],
)
def test_moyai_url_validator_rejects(self, value):
from pydantic import ValidationError
from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import UISettings
with pytest.raises(ValidationError):
UISettings(moyai_url=value)
def test_moyai_url_is_in_allowed_ui_settings_fields(self):
from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import ALLOWED_UI_SETTINGS_FIELDS
assert "moyai_url" in ALLOWED_UI_SETTINGS_FIELDS
@pytest.mark.asyncio
async def test_moyai_url_patch_sets_and_clears(self, monkeypatch):
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
from litellm.proxy import proxy_server
from litellm.proxy._types import UserAPIKeyAuth
from litellm.proxy.ui_crud_endpoints.proxy_setting_endpoints import update_ui_settings
prisma = MagicMock()
prisma.db.litellm_uisettings.find_unique = AsyncMock(
return_value=SimpleNamespace(ui_settings={"moyai_url": "https://old.example.com"})
)
persisted: dict = {}
async def _upsert(where, data):
persisted.update(json.loads(data["update"]["ui_settings"]))
prisma.db.litellm_uisettings.upsert = AsyncMock(side_effect=_upsert)
monkeypatch.setattr(proxy_server, "prisma_client", prisma)
monkeypatch.setattr(proxy_server, "store_model_in_db", True)
actor = UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN)
await update_ui_settings({"moyai_url": "https://new.example.com/"}, actor)
assert persisted["moyai_url"] == "https://new.example.com"
await update_ui_settings({"moyai_url": None}, actor)
assert persisted["moyai_url"] is None

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<svg width="46" height="46" viewBox="0 0 46 46" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="23" cy="23" r="23" fill="white"/>
<path d="M32.73 7h-6.945L38.45 39h6.945L32.73 7ZM12.665 7 0 39h7.082l2.59-6.72h13.25l2.59 6.72h7.082L19.929 7h-7.264Zm-.702 19.337 4.334-11.246 4.334 11.246h-8.668Z" fill="#000000"></path>
</svg>

After

Width:  |  Height:  |  Size: 381 B

View file

@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Bedrock</title><defs><linearGradient id="lobe-icons-bedrock-fill" x1="80%" x2="20%" y1="20%" y2="80%"><stop offset="0%" stop-color="#6350FB"></stop><stop offset="50%" stop-color="#3D8FFF"></stop><stop offset="100%" stop-color="#9AD8F8"></stop></linearGradient></defs><path d="M13.05 15.513h3.08c.214 0 .389.177.389.394v1.82a1.704 1.704 0 011.296 1.661c0 .943-.755 1.708-1.685 1.708-.931 0-1.686-.765-1.686-1.708 0-.807.554-1.484 1.297-1.662v-1.425h-2.69v4.663a.395.395 0 01-.188.338l-2.69 1.641a.385.385 0 01-.405-.002l-4.926-3.086a.395.395 0 01-.185-.336V16.3L2.196 14.87A.395.395 0 012 14.555L2 14.528V9.406c0-.14.073-.27.192-.34l2.465-1.462V4.448c0-.129.062-.249.165-.322l.021-.014L9.77 1.058a.385.385 0 01.407 0l2.69 1.675a.395.395 0 01.185.336V7.6h3.856V5.683a1.704 1.704 0 01-1.296-1.662c0-.943.755-1.708 1.685-1.708.931 0 1.685.765 1.685 1.708 0 .807-.553 1.484-1.296 1.662v2.311a.391.391 0 01-.389.394h-4.245v1.806h6.624a1.69 1.69 0 011.64-1.313c.93 0 1.685.764 1.685 1.707 0 .943-.754 1.708-1.685 1.708a1.69 1.69 0 01-1.64-1.314H13.05v1.937h4.953l.915 1.18a1.66 1.66 0 01.84-.227c.931 0 1.685.764 1.685 1.707 0 .943-.754 1.708-1.685 1.708-.93 0-1.685-.765-1.685-1.708 0-.346.102-.668.276-.937l-.724-.935H13.05v1.806zM9.973 1.856L7.93 3.122V6.09h-.778V3.604L5.435 4.669v2.945l2.11 1.36L9.712 7.61V5.334h.778V7.83c0 .136-.07.263-.184.335L7.963 9.638v2.081l1.422 1.009-.446.646-1.406-.998-1.53 1.005-.423-.66 1.605-1.055v-1.99L5.038 8.29l-2.26 1.34v1.676l1.972-1.189.398.677-2.37 1.429V14.3l2.166 1.258 2.27-1.368.397.677-2.176 1.311V19.3l1.876 1.175 2.365-1.426.398.678-2.017 1.216 1.918 1.201 2.298-1.403v-5.78l-4.758 2.893-.4-.675 5.158-3.136V3.289L9.972 1.856zM16.13 18.47a.913.913 0 00-.908.92c0 .507.406.918.908.918a.913.913 0 00.907-.919.913.913 0 00-.907-.92zm3.63-3.81a.913.913 0 00-.908.92c0 .508.406.92.907.92a.913.913 0 00.908-.92.913.913 0 00-.908-.92zm1.555-4.99a.913.913 0 00-.908.92c0 .507.407.918.908.918a.913.913 0 00.907-.919.913.913 0 00-.907-.92zM17.296 3.1a.913.913 0 00-.907.92c0 .508.406.92.907.92a.913.913 0 00.908-.92.913.913 0 00-.908-.92z" fill="url(#lobe-icons-bedrock-fill)" fill-rule="nonzero"></path></svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View file

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 25.4.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 292.6 215.3" style="enable-background:new 0 0 292.6 215.3;" xml:space="preserve">
<style type="text/css">
.st0{fill:#566AB2;}
</style>
<path class="st0" d="M191.3,123.7c-2.4,1-4.9,1.8-7.2,1.9c-3.6,0.2-7.6-1.3-9.7-3.1c-3.3-2.8-5.7-4.4-6.7-9.2
c-0.4-2.1-0.2-5.3,0.2-7.2c0.9-4-0.1-6.5-2.9-8.9c-2.3-1.9-5.2-2.4-8.4-2.4s-2.3-0.5-3.1-1c-1.3-0.7-2.4-2.3-1.4-4.4
c0.3-0.7,2-2.3,2.3-2.5c4.3-2.5,9.4-1.7,14,0.2c4.3,1.7,7.5,5,12.2,9.5c4.8,5.5,5.6,7,8.4,11.1c2.1,3.2,4.1,6.6,5.4,10.4
C195.2,120.5,194.2,122.4,191.3,123.7L191.3,123.7z M153.4,104.3c0-2.1,1.7-3.7,3.8-3.7s0.9,0.1,1.3,0.2c0.5,0.2,1,0.5,1.4,0.9
c0.7,0.7,1.1,1.6,1.1,2.6c0,2.1-1.7,3.8-3.8,3.8s-3.7-1.7-3.7-3.8H153.4z M141.2,182.8c-25.5-20-37.8-26.6-42.9-26.3
c-4.8,0.3-3.9,5.7-2.8,9.3c1.1,3.5,2.5,5.9,4.5,9c1.4,2,2.3,5.1-1.4,7.3c-8.2,5.1-22.5-1.7-23.1-2c-16.6-9.8-30.5-22.7-40.2-40.3
c-9.5-17-14.9-35.2-15.8-54.6c-0.2-4.7,1.1-6.4,5.8-7.2c6.2-1.1,12.5-1.4,18.7-0.5c26,3.8,48.1,15.4,66.7,33.8
c10.6,10.5,18.6,23,26.8,35.2c8.8,13,18.2,25.4,30.2,35.5c4.3,3.6,7.6,6.3,10.9,8.2c-9.8,1.1-26.1,1.3-37.2-7.5L141.2,182.8z
M289.5,18c-3.1-1.5-4.4,1.4-6.3,2.8c-0.6,0.5-1.1,1.1-1.7,1.7c-4.5,4.8-9.8,8-16.8,7.6c-10.1-0.6-18.7,2.6-26.4,10.4
c-1.6-9.5-7-15.2-15.2-18.9c-4.3-1.9-8.6-3.8-11.6-7.9c-2.1-2.9-2.7-6.2-3.7-9.4c-0.7-2-1.3-3.9-3.6-4.3c-2.4-0.4-3.4,1.7-4.3,3.4
c-3.8,7-5.3,14.6-5.2,22.4c0.3,17.5,7.7,31.5,22.4,41.4c1.7,1.1,2.1,2.3,1.6,3.9c-1,3.4-2.2,6.7-3.3,10.1c-0.7,2.2-1.7,2.7-4,1.7
c-8.1-3.4-15-8.4-21.2-14.4c-10.4-10.1-19.9-21.2-31.6-30c-2.8-2.1-5.5-4-8.4-5.7c-12-11.7,1.6-21.3,4.7-22.4
c3.3-1.2,1.2-5.3-9.5-5.2c-10.6,0-20.3,3.6-32.8,8.4c-1.8,0.7-3.7,1.2-5.7,1.7c-11.3-2.1-22.9-2.6-35.1-1.2
c-23,2.5-41.4,13.4-54.8,32C1,68.3-2.8,93.6,1.9,120c4.9,27.8,19.1,50.9,41,68.9c22.6,18.7,48.7,27.8,78.5,26.1
c18.1-1,38.2-3.5,60.9-22.7c5.7,2.8,11.7,4,21.7,4.8c7.7,0.7,15.1-0.4,20.8-1.5c9-1.9,8.4-10.2,5.1-11.7
c-26.3-12.3-20.5-7.3-25.7-11.3c13.3-15.8,33.5-32.2,41.3-85.4c0.6-4.2,0.1-6.9,0-10.3c0-2.1,0.4-2.9,2.8-3.1
c6.6-0.8,13-2.6,18.8-5.8c17-9.3,23.9-24.6,25.5-42.9c0.2-2.8,0-5.7-3-7.2L289.5,18z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View file

@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Fireworks</title><path clip-rule="evenodd" d="M14.8 5l-2.801 6.795L9.195 5H7.397l3.072 7.428a1.64 1.64 0 003.038.002L16.598 5H14.8zm1.196 10.352l5.124-5.244-.699-1.669-5.596 5.739a1.664 1.664 0 00-.343 1.807 1.642 1.642 0 001.516 1.012L16 17l8-.02-.699-1.669-7.303.041h-.002zM2.88 10.104l.699-1.669 5.596 5.739c.468.479.603 1.189.343 1.807a1.643 1.643 0 01-1.516 1.012l-8-.018-.002.002.699-1.669 7.303.042-5.122-5.246z" fill="#5019C5" fill-rule="evenodd"></path></svg>

After

Width:  |  Height:  |  Size: 592 B

View file

@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<svg viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg" fill="none"><path fill="#4285F4" d="M14.9 8.161c0-.476-.039-.954-.121-1.422h-6.64v2.695h3.802a3.24 3.24 0 01-1.407 2.127v1.75h2.269c1.332-1.22 2.097-3.02 2.097-5.15z"/><path fill="#34A853" d="M8.14 15c1.898 0 3.499-.62 4.665-1.69l-2.268-1.749c-.631.427-1.446.669-2.395.669-1.836 0-3.393-1.232-3.952-2.888H1.85v1.803A7.044 7.044 0 008.14 15z"/><path fill="#FBBC04" d="M4.187 9.342a4.17 4.17 0 010-2.68V4.859H1.849a6.97 6.97 0 000 6.286l2.338-1.803z"/><path fill="#EA4335" d="M8.14 3.77a3.837 3.837 0 012.7 1.05l2.01-1.999a6.786 6.786 0 00-4.71-1.82 7.042 7.042 0 00-6.29 3.858L4.186 6.66c.556-1.658 2.116-2.89 3.952-2.89z"/></svg>

After

Width:  |  Height:  |  Size: 728 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>LangChain</title><path d="M7.531 15.976a7.534 7.534 0 000-10.651L2.206 0A7.537 7.537 0 000 5.326c0 1.996.794 3.913 2.206 5.325l5.325 5.325zM18.674 16.469a7.535 7.535 0 00-10.65 0l5.325 5.325a7.536 7.536 0 0010.651 0l-5.326-5.325zM2.218 21.782a7.536 7.536 0 005.326 2.206v-7.531H.012c0 1.996.795 3.914 2.206 5.325zM20.73 8.595a7.534 7.534 0 00-10.651.001l5.325 5.326 5.326-5.327z" fill="#7FC8FF"></path></svg>

After

Width:  |  Height:  |  Size: 532 B

View file

@ -0,0 +1 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Mistral</title><path d="M3.428 3.4h3.429v3.428H3.428V3.4zm13.714 0h3.43v3.428h-3.43V3.4z" fill="gold"></path><path d="M3.428 6.828h6.857v3.429H3.429V6.828zm10.286 0h6.857v3.429h-6.857V6.828z" fill="#FFAF00"></path><path d="M3.428 10.258h17.144v3.428H3.428v-3.428z" fill="#FF8205"></path><path d="M3.428 13.686h3.429v3.428H3.428v-3.428zm6.858 0h3.429v3.428h-3.429v-3.428zm6.856 0h3.43v3.428h-3.43v-3.428z" fill="#FA500F"></path><path d="M0 17.114h10.286v3.429H0v-3.429zm13.714 0H24v3.429H13.714v-3.429z" fill="#E10500"></path></svg>

After

Width:  |  Height:  |  Size: 655 B

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<svg fill="#000000" viewBox="-2 -2 28 28" role="img" xmlns="http://www.w3.org/2000/svg">
<circle cx="12" cy="12" r="14" fill="white" />
<path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -0,0 +1,16 @@
<svg width="240" height="300" viewBox="0 0 240 300" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_1401_86274)">
<mask id="mask0_1401_86274" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="0" y="0" width="240" height="300">
<path d="M240 0H0V300H240V0Z" fill="white"/>
</mask>
<g mask="url(#mask0_1401_86274)">
<path d="M180 240H60V120H180V240Z" fill="#CFCECD"/>
<path d="M180 60H60V240H180V60ZM240 300H0V0H240V300Z" fill="#211E1E"/>
</g>
</g>
<defs>
<clipPath id="clip0_1401_86274">
<rect width="240" height="300" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 593 B

View file

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000">
<defs>
<style>
.cls-1 {
fill: #000;
}
polygon {
fill: #fff;
}
@media ( prefers-color-scheme: dark ) {
.cls-1 {
fill: #fff;
}
polygon {
fill: #000;
}
}
</style>
</defs>
<rect class="cls-1" width="1000" height="1000"/>
<g>
<polygon points="226.83 411.15 501.31 803.15 623.31 803.15 348.82 411.15 226.83 411.15" />
<polygon points="348.72 628.87 226.69 803.15 348.77 803.15 409.76 716.05 348.72 628.87" />
<polygon points="651.23 196.85 440.28 498.12 501.32 585.29 773.31 196.85 651.23 196.85" />
<polygon points="673.31 383.25 673.31 803.15 773.31 803.15 773.31 240.44 673.31 383.25" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 937 B

View file

@ -0,0 +1,19 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="110 70 280 372" role="img" aria-label="Moyai">
<path d="M175 89 309 83 343 134 328 232 377 275Q386 285 375 294L335 304 331 383 284 429 157 393 145 291 122 222 145 129Z" fill="#d9d2e9"/>
<path d="m175 89-30 40-23 93 23 69 12 102 49-28-19-113 13-124Z" fill="#ada0c8"/>
<path d="m309 83 34 51-15 98-30-13 13-67-111-24-25-39Z" fill="#eeeaf6"/>
<path d="m298 219 30 13 49 43q9 10-2 19l-40 10-3-27-39-13Z" fill="#b8aacf"/>
<path d="m206 365 78 22 47-4-47 46-127-36Z" fill="#8e7cae"/>
<path d="m267 190 57 3-5 30-29-9-20 7Z" fill="#24232b"/>
<path d="m286 327 48-2-1 15-47 4Z" fill="#24232b"/>
<path d="m279 354 53-8-1 15-47 11Z" fill="#c4b7d7"/>
<g fill="#66528a" transform="translate(181.0 202.0) scale(0.1774)">
<path d="M296.11,254.16c7.79-1.07,7.43-12.43.39-13.28-6.58-.79-14.5.86-21.17-.23l-50.86.37c-7.25,2.25-6.45,12.27,1.1,13.28l70.54-.14Z"/>
<path d="M309.42,196.83c-35.23-15.22-72.84-26.08-109.96-35.87-33.05-8.71-69.92-16.92-103.8-21.32-31.89-4.14-63.43-3.41-95.51-3.23v6.54c36.23-.59,71.45.42,107.34,5.91,48.38,7.41,99.06,19.56,145.78,33.98,27.15,8.38,53.59,18.79,79.2,31.01l.1,67.98c12.46-2,31.2-8.81,39.28-20.3l-.04-24.63c-10.86-17.17-45.21-32.66-62.4-40.09Z"/>
<path d="M14.79,115.72c-.07.12-.14.23-.21.35,23.36.36,46.79,1.59,69.97,4.75,36.98,5.04,75.62,13.61,111.4,24.06,17.99,5.25,35.13,13.22,54.25,12.22,4.13-.22,8.25-1.11,12.36-1.52l-.56-.73c-22.47-16.17-52.91-34.43-91.33-48.5C101.55,81.06,39.38,81.29.08,85.13l.05,31.74c3.47-.28,6.94-.56,10.39-.99,1.4-.02,2.84-.06,4.28-.16Z"/>
<path d="M333.75-.37L37.67.08C16.84.11-.03,17.02,0,37.85l.06,39.13c27.48-4.04,66.25-6.26,112.14,2.78,1.52.3,6.38,1.29,12.73,2.77,54.99,12.83,104.51,41.22,151.11,71.93,22.76,14.99,46.61,30.85,68.19,47.42,8.92,6.85,19.82,15.04,27.57,24.61l-.29-189.2c-.03-20.83-16.94-37.69-37.77-37.66Z"/>
<path d="M92.09,245.59h6.39s0,.03,0,.03l57.07-.09-48.14-66.6c-.53-.87-3.85-2.78-4.61-2.78h-4.75s0,.18,0,.18h-6.06s0-.09,0-.09l-91.76.14.11,69.62,91.76-.14v-.28Z"/>
<path d="M.4,298.26l.05,35.68c.03,20.83,16.94,37.69,37.77,37.66l296.08-.45c20.83-.03,37.69-16.94,37.66-37.77l-.09-59.31c-17.35,19.08-52.22,22.16-75.65,23.75l-295.82.45Z"/>
<path d="M159.06,253.3c-20.19.1-40.38.04-60.58.04v.26s-42.72.07-42.72.07c-.83.12-1.69.18-2.17,0l-53.25.08v9.76s166.32-.25,166.32-.25c-1.47-1.7-5.76-9.61-7.6-9.96Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View file

@ -29,5 +29,5 @@ function getSnapshot() {
}
export function useDisableBlogPosts() {
return useSyncExternalStore(subscribe, getSnapshot);
return useSyncExternalStore(subscribe, getSnapshot, () => false);
}

View file

@ -29,5 +29,5 @@ function getSnapshot() {
}
export function useDisableBouncingIcon() {
return useSyncExternalStore(subscribe, getSnapshot);
return useSyncExternalStore(subscribe, getSnapshot, () => false);
}

View file

@ -31,5 +31,5 @@ function getSnapshot() {
}
export function useDisableShowNewBadge() {
return useSyncExternalStore(subscribe, getSnapshot);
return useSyncExternalStore(subscribe, getSnapshot, () => false);
}

View file

@ -31,5 +31,5 @@ function getSnapshot() {
}
export function useDisableShowPrompts() {
return useSyncExternalStore(subscribe, getSnapshot);
return useSyncExternalStore(subscribe, getSnapshot, () => false);
}

View file

@ -31,5 +31,5 @@ function getSnapshot() {
}
export function useHideAutoRouterAnnouncement() {
return useSyncExternalStore(subscribe, getSnapshot);
return useSyncExternalStore(subscribe, getSnapshot, () => false);
}

View file

@ -0,0 +1,99 @@
"use client";
import { Suspense, useEffect, useMemo, useSyncExternalStore } from "react";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
import Navbar from "@/components/navbar";
import MoyaiConnected from "@/components/moyai/MoyaiConnected";
import MoyaiLanding from "@/components/moyai/MoyaiLanding";
import { startMoyaiQuickConnect } from "@/components/networking";
import { PluginModeProvider } from "@/contexts/PluginModeContext";
import { ThemeProvider } from "@/contexts/ThemeContext";
import { isProxyAdminRole } from "@/utils/roles";
import { uiHref } from "@/utils/uiHref";
function MoyaiPageContent() {
const { accessToken, userRole } = useAuthorized();
const { data: uiSettings, isLoading, refetch } = useUISettings();
const parsed = useSyncExternalStore(
() => () => {},
() => true,
() => false,
);
const connectedParams = useMemo(() => {
if (!parsed) {
return null;
}
const params = new URLSearchParams(window.location.search);
if (params.get("moyai_connected") !== "1") {
return null;
}
const models = Number(params.get("models"));
return {
keyAlias: params.get("key_alias"),
models: Number.isFinite(models) && params.get("models") !== null ? models : null,
};
}, [parsed]);
useEffect(() => {
if (connectedParams) {
window.history.replaceState(null, "", window.location.pathname);
refetch();
}
}, [connectedParams, refetch]);
const moyaiUrl = (uiSettings?.values?.moyai_url as string | undefined) ?? null;
const settingsReady = parsed && !isLoading;
const shouldOpenMoyai = settingsReady && !connectedParams && moyaiUrl;
useEffect(() => {
if (shouldOpenMoyai) {
window.location.replace(moyaiUrl as string);
}
}, [shouldOpenMoyai, moyaiUrl]);
const onQuickConnect = async (url: string) => {
const response = await startMoyaiQuickConnect(accessToken ?? "", url, window.location.origin + uiHref("moyai"));
window.location.assign(response.connect_url);
};
let content: React.ReactNode = null;
if (parsed && !isLoading) {
if (connectedParams) {
content = (
<MoyaiConnected moyaiUrl={moyaiUrl} keyAlias={connectedParams.keyAlias} models={connectedParams.models} />
);
} else if (moyaiUrl) {
content = (
<div className="flex min-h-[calc(100vh-3.5rem)] flex-col items-center justify-center gap-3 bg-[#04060c] px-6 text-center text-[#e3e3e3]">
<p className="m-0 text-lg font-medium text-white">Opening Moyai</p>
<a href={moyaiUrl} className="text-sm text-[#8b9bff] underline underline-offset-4">
Continue to {moyaiUrl}
</a>
</div>
);
} else {
content = <MoyaiLanding canQuickConnect={isProxyAdminRole(userRole ?? "")} onQuickConnect={onQuickConnect} />;
}
}
return (
<PluginModeProvider accessToken={accessToken}>
<ThemeProvider accessToken={accessToken}>
<div className="flex h-screen flex-col">
<Navbar accessToken={accessToken} isPublicPage={false} />
<main className="min-h-0 flex-1 overflow-y-auto">{content}</main>
</div>
</ThemeProvider>
</PluginModeProvider>
);
}
export default function MoyaiPage() {
return (
<Suspense>
<MoyaiPageContent />
</Suspense>
);
}

View file

@ -9,6 +9,7 @@ const { mockUsePluginMode, mockUseUISettings, mockUsePathname, state } = vi.hois
plugins: [] as { name: string; display_name: string; url: string }[],
activePlugin: null as { name: string; display_name: string; url: string } | null,
enableChatUI: false,
moyaiUrl: undefined as string | undefined,
pathname: "/ui/",
};
return {
@ -19,7 +20,9 @@ const { mockUsePluginMode, mockUseUISettings, mockUsePathname, state } = vi.hois
plugins: state.plugins,
activePlugin: state.activePlugin,
})),
mockUseUISettings: vi.fn(() => ({ data: { values: { enable_chat_ui: state.enableChatUI } } })),
mockUseUISettings: vi.fn(() => ({
data: { values: { enable_chat_ui: state.enableChatUI, moyai_url: state.moyaiUrl } },
})),
mockUsePathname: vi.fn(() => state.pathname),
};
});
@ -45,6 +48,7 @@ describe("ViewSwitcher", () => {
state.mode = "ai-gateway";
state.plugins = [];
state.enableChatUI = false;
state.moyaiUrl = undefined;
state.pathname = "/ui/";
state.setMode.mockClear();
});
@ -135,6 +139,81 @@ describe("ViewSwitcher", () => {
expect(assignSpy).toHaveBeenCalledWith("/ui/");
});
it("shows the Moyai entry with its description regardless of enable_chat_ui", async () => {
render(<ViewSwitcher />);
act(() => {
fireEvent.click(screen.getByRole("button"));
});
expect(await screen.findByText("Moyai")).toBeInTheDocument();
expect(screen.getByText("Cloud Coding Agent")).toBeInTheDocument();
});
it("navigates to the moyai route when the Moyai entry is picked", async () => {
render(<ViewSwitcher />);
act(() => {
fireEvent.click(screen.getByRole("button"));
});
act(() => {
fireEvent.click(screen.getByText("Moyai"));
});
expect(assignSpy).toHaveBeenCalledWith("/ui/moyai");
expect(state.setMode).not.toHaveBeenCalled();
});
it("labels the button Moyai on the moyai route and its subpaths", async () => {
state.pathname = "/ui/moyai";
const { unmount } = render(<ViewSwitcher />);
expect(screen.getByRole("button")).toHaveTextContent("Moyai");
unmount();
state.pathname = "/ui/moyai/anything";
render(<ViewSwitcher />);
expect(screen.getByRole("button")).toHaveTextContent("Moyai");
});
it("navigates back to the dashboard when AI Gateway is picked from the moyai route", async () => {
state.pathname = "/ui/moyai";
render(<ViewSwitcher />);
act(() => {
fireEvent.click(screen.getByRole("button"));
});
expect(await screen.findByText("AI Gateway")).toBeInTheDocument();
act(() => {
fireEvent.click(screen.getByText("AI Gateway"));
});
expect(state.setMode).toHaveBeenCalledWith("ai-gateway");
expect(assignSpy).toHaveBeenCalledWith("/ui/");
});
it("lists Moyai before Chat in the menu", async () => {
state.enableChatUI = true;
render(<ViewSwitcher />);
act(() => {
fireEvent.click(screen.getByRole("button"));
});
expect(await screen.findByText("Moyai")).toBeInTheDocument();
expect(screen.getByText("Moyai").compareDocumentPosition(screen.getByText("Chat"))).toBe(
Node.DOCUMENT_POSITION_FOLLOWING,
);
});
it("navigates straight to the connected deployment when moyai_url is set", async () => {
state.moyaiUrl = "https://moyai.example.com";
render(<ViewSwitcher />);
act(() => {
fireEvent.click(screen.getByRole("button"));
});
act(() => {
fireEvent.click(screen.getByText("Moyai"));
});
expect(assignSpy).toHaveBeenCalledWith("https://moyai.example.com");
});
it("shows Chat as a disabled, non-navigating entry with an admin hint when disabled", async () => {
state.enableChatUI = false;
state.plugins = [{ name: "obs", display_name: "Observability", url: "http://localhost:9000" }];

View file

@ -10,9 +10,21 @@ import { Check, ChevronsUpDown, LayoutGrid } from "lucide-react";
import { usePluginMode } from "@/contexts/PluginModeContext";
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
import { uiHref } from "@/utils/uiHref";
import moyaiHead from "../../../public/assets/moyai/moyai-head.svg";
const GATEWAY = "ai-gateway";
const CHAT = "chat";
const MOYAI = "moyai";
function isRoute(pathname: string, href: string): boolean {
return pathname === href || pathname.startsWith(`${href}/`);
}
function activeLabelFor(isChatRoute: boolean, isMoyaiRoute: boolean, pluginLabel: string | undefined): string {
if (isChatRoute) return "Chat";
if (isMoyaiRoute) return "Moyai";
return pluginLabel ?? "AI Gateway";
}
interface ViewSwitcherItem {
key: string;
@ -27,12 +39,14 @@ export default function ViewSwitcher() {
const pathname = usePathname();
const chatEnabled = Boolean(uiSettings?.values?.enable_chat_ui);
const moyaiUrl = (uiSettings?.values?.moyai_url as string | undefined) ?? null;
const chatHref = uiHref(CHAT);
const normalizedPathname = (pathname ?? "").replace(/\/+$/, "");
const isChatRoute = chatEnabled && (normalizedPathname === chatHref || normalizedPathname.startsWith(`${chatHref}/`));
const isChatRoute = chatEnabled && isRoute(normalizedPathname, uiHref(CHAT));
const isMoyaiRoute = isRoute(normalizedPathname, uiHref(MOYAI));
const isStandaloneRoute = isChatRoute || isMoyaiRoute;
const activeLabel = isChatRoute ? "Chat" : plugins.find((p) => p.name === mode)?.display_name ?? "AI Gateway";
const activeLabel = activeLabelFor(isChatRoute, isMoyaiRoute, plugins.find((p) => p.name === mode)?.display_name);
const modeEntries = [
{ key: GATEWAY, label: "AI Gateway" },
@ -41,9 +55,7 @@ export default function ViewSwitcher() {
const selectMode = (key: string) => {
setMode(key);
// The chat route lives outside the dashboard SPA shell that reacts to `mode`,
// so switching modes from there needs a real navigation, not just state.
if (isChatRoute) {
if (isStandaloneRoute) {
window.location.assign(uiHref(""));
}
};
@ -78,11 +90,27 @@ export default function ViewSwitcher() {
label: (
<div className="flex items-center justify-between gap-6 py-0.5">
<span className="font-medium">{e.label}</span>
{!isChatRoute && e.key === mode && <Check className="size-4 text-info" />}
{!isStandaloneRoute && e.key === mode && <Check className="size-4 text-info" />}
</div>
),
onClick: () => selectMode(e.key),
})),
{
key: MOYAI,
label: (
<div className="flex items-center justify-between gap-6 py-0.5">
<span className="flex items-center gap-2">
<img src={moyaiHead.src} alt="" className="h-4 w-auto" />
<span className="flex flex-col">
<span className="font-medium">Moyai</span>
<span className="text-xs leading-snug text-muted-foreground">Cloud Coding Agent</span>
</span>
</span>
{isMoyaiRoute && <Check className="size-4 text-info" />}
</div>
),
onClick: () => window.location.assign(moyaiUrl ?? uiHref(MOYAI)),
},
chatItem,
];

View file

@ -127,6 +127,45 @@ describe("UISettings", () => {
expect(toast.success).toHaveBeenCalledWith("UI settings updated successfully");
});
it("disconnects Moyai by patching moyai_url to null", () => {
const mutateMock = vi.fn((_settings, options) => {
options?.onSuccess?.();
});
mockUseUpdateUISettings.mockReturnValue({
mutate: mutateMock,
isPending: false,
error: null,
});
mockUseUISettings.mockReturnValue(
buildSettingsResponse({
data: {
...buildSettingsResponse().data,
values: { ...buildSettingsResponse().data.values, moyai_url: "https://moyai.example.com" },
},
}),
);
render(<UISettings />);
expect(screen.getByText("Connected to https://moyai.example.com")).toBeInTheDocument();
act(() => {
fireEvent.click(screen.getByRole("button", { name: "Disconnect" }));
});
expect(mutateMock).toHaveBeenCalledWith(
{ moyai_url: null },
expect.objectContaining({ onSuccess: expect.any(Function) }),
);
});
it("shows a link to the Moyai page when not connected", () => {
render(<UISettings />);
expect(screen.getByText("Not connected")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Connect from the Moyai page" })).toHaveAttribute("href", "/ui/moyai");
});
it("should toggle require auth for public AI Hub setting and call update", () => {
const mutateMock = vi.fn((_settings, options) => {
options?.onSuccess?.();

View file

@ -9,6 +9,8 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button";
import { uiHref } from "@/utils/uiHref";
import PageVisibilitySettings from "./PageVisibilitySettings";
interface SettingRowProps {
@ -66,6 +68,7 @@ export default function UISettings() {
const scopeUserSearchProperty = schema?.properties?.scope_user_search_to_org;
const disableCustomApiKeysProperty = schema?.properties?.disable_custom_api_keys;
const values = data?.values ?? {};
const moyaiUrl = (values.moyai_url as string | undefined) ?? null;
const isDisabledForInternalUsers = Boolean(values.disable_model_add_for_internal_users);
const isDisabledTeamAdminDeleteTeamUser = Boolean(values.disable_team_admin_delete_team_user);
const isAgentsDisabled = Boolean(values.disable_agents_for_internal_users);
@ -252,6 +255,20 @@ export default function UISettings() {
);
};
const handleDisconnectMoyai = () => {
updateSettings(
{ moyai_url: null },
{
onSuccess: () => {
toast.success("UI settings updated successfully");
},
onError: (error) => {
toast.fromError(error);
},
},
);
};
const handleToggleDisableCustomApiKeys = (checked: boolean) => {
updateSettings(
{ disable_custom_api_keys: checked },
@ -366,6 +383,25 @@ export default function UISettings() {
}
/>
<Separator />
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<p className="text-sm font-medium text-foreground">Moyai</p>
<p className="text-sm text-muted-foreground">
{moyaiUrl ? `Connected to ${moyaiUrl}` : "Not connected"}
</p>
</div>
{moyaiUrl ? (
<Button variant="outline" size="sm" disabled={isUpdating} onClick={handleDisconnectMoyai}>
Disconnect
</Button>
) : (
<a href={uiHref("moyai")} className="text-sm text-primary underline underline-offset-4">
Connect from the Moyai page
</a>
)}
</div>
<Separator />
<SettingRow
checked={isAgentsDisabled}

View file

@ -0,0 +1,28 @@
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import MoyaiConnected from "./MoyaiConnected";
vi.mock("@/utils/uiHref", () => ({ uiHref: (seg: string) => `/ui/${seg}` }));
describe("MoyaiConnected", () => {
it("shows workspace, key alias, and model count rows with an Open Moyai CTA", () => {
render(<MoyaiConnected moyaiUrl="https://moyai.example.com" keyAlias="moyai-moyai.example.com" models={42} />);
expect(screen.getByText("Workspace linked")).toBeInTheDocument();
expect(screen.getByText("https://moyai.example.com")).toBeInTheDocument();
expect(screen.getByText("Virtual key issued")).toBeInTheDocument();
expect(screen.getByText("moyai-moyai.example.com")).toBeInTheDocument();
expect(screen.getByText(/models available through LiteLLM/)).toHaveTextContent(
"42 models available through LiteLLM",
);
expect(screen.getByRole("link", { name: /Open Moyai/ })).toHaveAttribute("href", "https://moyai.example.com");
expect(screen.getByRole("link", { name: /Back to AI Gateway/ })).toHaveAttribute("href", "/ui/");
});
it("hides the models row when the count is missing", () => {
render(<MoyaiConnected moyaiUrl="https://moyai.example.com" keyAlias={null} models={null} />);
expect(screen.queryByText(/models available through LiteLLM/)).not.toBeInTheDocument();
expect(screen.queryByText("Virtual key issued")).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,84 @@
"use client";
import React from "react";
import { ArrowUpRight, Check, ExternalLink } from "lucide-react";
import moyaiHead from "../../../public/assets/moyai/moyai-head.svg";
import styles from "./MoyaiLanding.module.css";
import { uiHref } from "@/utils/uiHref";
export default function MoyaiConnected({
moyaiUrl,
keyAlias,
models,
}: {
moyaiUrl: string | null;
keyAlias?: string | null;
models?: number | null;
}) {
const rows = [
{ label: "Workspace linked", value: moyaiUrl },
{ label: "Virtual key issued", value: keyAlias },
{ label: "models available through LiteLLM", value: models != null ? `${models}` : null, prefix: true },
];
return (
<div className="relative isolate min-h-full bg-[radial-gradient(ellipse_at_50%_70%,#0a1226_0%,#04060c_65%)] text-[#e3e3e3]">
<div className="mx-auto flex min-h-[calc(100vh-3.5rem)] max-w-2xl flex-col items-center justify-center px-6 py-16 text-center">
<div
className={`${styles.rise} mb-5 inline-flex items-center gap-2 rounded-full bg-white/[0.06] px-3 py-1 text-[11px] uppercase tracking-[0.22em] text-[rgba(220,228,255,0.75)] shadow-[inset_0_0_0_1px_rgba(255,255,255,0.12)]`}
>
<span className="size-1.5 rounded-full bg-[#28c840] shadow-[0_0_8px_#28c840]" />
Connected
</div>
<img src={moyaiHead.src} alt="" className={`${styles.mark} mb-4 h-14 w-auto`} />
<h1 className="m-0 text-[clamp(28px,3.5vw,44px)] font-medium tracking-[-0.03em] text-white">Moyai connected</h1>
<p className="mb-0 mt-3 max-w-md text-[16px] text-[#c2c6d0]">
This deployment is now linked to your LiteLLM gateway.
</p>
<div className="mt-8 w-full max-w-md space-y-2 text-left">
{rows.map((row) =>
row.value ? (
<div
key={row.label}
className="flex items-center gap-3 rounded-xl bg-white/[0.04] px-4 py-3 text-[15px] text-[#f5f6fa] shadow-[inset_0_0_0_1px_rgba(255,255,255,0.08)]"
>
<Check className="size-4 flex-none text-[#28c840]" />
<span>
{row.prefix ? (
<>
<strong className="font-semibold">{row.value}</strong> {row.label}
</>
) : (
<>
{row.label} <span className="text-[#a6aab3]">{row.value}</span>
</>
)}
</span>
</div>
) : null,
)}
</div>
<div className="mt-8 flex flex-wrap items-center justify-center gap-3">
{moyaiUrl && (
<a
href={moyaiUrl}
className={`${styles.cta} group inline-flex items-center gap-3 rounded-full px-7 py-3.5 text-base font-semibold text-[#05070d] transition-transform hover:scale-[1.03]`}
>
Open Moyai
<ArrowUpRight className="size-4" />
</a>
)}
<a
href={uiHref("")}
className="inline-flex items-center gap-2 rounded-full bg-white/[0.06] px-5 py-3 text-sm font-medium text-white shadow-[inset_0_0_0_1px_rgba(255,255,255,0.18)] backdrop-blur transition-colors hover:bg-white/[0.12]"
>
<ExternalLink className="size-4" />
Back to AI Gateway
</a>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,84 @@
@keyframes moyai-spin {
to {
--moyai-angle: 360deg;
}
}
@keyframes moyai-rise {
from {
opacity: 0;
transform: translateY(16px);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes moyai-fade {
from {
opacity: 0;
}
}
@property --moyai-angle {
syntax: "<angle>";
initial-value: 0deg;
inherits: false;
}
.rise {
animation: moyai-rise 0.9s cubic-bezier(0.2, 0.7, 0.2, 1) both;
}
.orbit {
visibility: hidden;
}
.orbit[data-placed="true"] {
visibility: visible;
animation: moyai-fade 1.2s ease-out both;
}
.cta {
background:
linear-gradient(#ffffff, #ffffff) padding-box,
conic-gradient(from var(--moyai-angle), #8b9bff, #cfe3ff, #ffb98a, #e9d8ff, #8b9bff) border-box;
border: 2px solid transparent;
box-shadow:
0 0 32px rgba(139, 155, 255, 0.55),
0 0 90px rgba(120, 170, 255, 0.35);
animation: moyai-spin 4s linear infinite;
}
.cta:hover {
box-shadow:
0 0 44px rgba(139, 155, 255, 0.8),
0 0 120px rgba(120, 170, 255, 0.5);
}
.connect {
background:
linear-gradient(#0b1020, #0b1020) padding-box,
conic-gradient(from var(--moyai-angle), #8b9bff, #cfe3ff, #ffb98a, #e9d8ff, #8b9bff) border-box;
border: 1.5px solid transparent;
box-shadow: 0 0 22px rgba(139, 155, 255, 0.45);
animation: moyai-spin 4s linear infinite;
}
.connect:hover {
box-shadow: 0 0 34px rgba(139, 155, 255, 0.75);
}
.mark {
filter: drop-shadow(0 0 0.18em rgba(140, 180, 255, 0.45)) drop-shadow(0 0.04em 0.08em rgba(0, 0, 0, 0.6));
}
@media (prefers-reduced-motion: reduce) {
.rise,
.cta,
.connect,
.orbit[data-placed="true"] {
animation: none;
}
}

View file

@ -0,0 +1,56 @@
import { describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import MoyaiLanding, { MOYAI_GITHUB_URL, MOYAI_LAUNCH_POST_URL, MOYAI_WALKTHROUGH_URL } from "./MoyaiLanding";
vi.mock("./moyaiSky", async (importOriginal) => {
const actual = await importOriginal<typeof import("./moyaiSky")>();
return {
...actual,
prefersReducedMotion: () => true,
startStarfield: () => () => {},
startPlanetrise: () => () => {},
};
});
describe("MoyaiLanding", () => {
it("shows the admin quick-connect line and validates the dialog input", async () => {
const onQuickConnect = vi.fn(async () => {});
render(<MoyaiLanding canQuickConnect onQuickConnect={onQuickConnect} />);
fireEvent.click(screen.getByRole("button", { name: "Quick connect" }));
const input = await screen.findByLabelText("Moyai URL");
fireEvent.change(input, { target: { value: "javascript:alert(1)" } });
fireEvent.click(screen.getByRole("button", { name: "Connect" }));
expect(await screen.findByRole("alert")).toHaveTextContent("http or https");
expect(onQuickConnect).not.toHaveBeenCalled();
fireEvent.change(input, { target: { value: " https://moyai.example.com/ " } });
fireEvent.click(screen.getByRole("button", { name: "Connect" }));
expect(onQuickConnect).toHaveBeenCalledWith("https://moyai.example.com");
});
it("shows the non-admin copy when quick connect is unavailable", () => {
render(<MoyaiLanding />);
expect(screen.getByText(/Ask a proxy admin to connect it/)).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Quick connect" })).not.toBeInTheDocument();
});
it("links the GitHub, demo, and launch post CTAs to the exported URLs", () => {
render(<MoyaiLanding />);
expect(screen.getByRole("link", { name: /Star Moyai on GitHub/ })).toHaveAttribute("href", MOYAI_GITHUB_URL);
expect(screen.getByRole("link", { name: /Watch the demo/ })).toHaveAttribute("href", MOYAI_WALKTHROUGH_URL);
expect(screen.getByRole("link", { name: /Read the launch post/ })).toHaveAttribute("href", MOYAI_LAUNCH_POST_URL);
});
it("shows the GitHub fallback when the demo image fails to load", () => {
render(<MoyaiLanding />);
const demoImg = screen.getByAltText(/Moyai demo:/);
fireEvent.error(demoImg);
expect(screen.getByText("Watch the demo on GitHub")).toBeInTheDocument();
});
});

View file

@ -0,0 +1,463 @@
"use client";
import React, { useEffect, useRef, useState } from "react";
import { ArrowRight, ArrowUpRight, Github, Play, Plug, Star } from "lucide-react";
import moyaiHead from "../../../public/assets/moyai/moyai-head.svg";
import anthropicLogo from "../../../public/assets/moyai/logos/anthropic.svg";
import bedrockLogo from "../../../public/assets/moyai/logos/bedrock.svg";
import deepseekLogo from "../../../public/assets/moyai/logos/deepseek.svg";
import fireworksLogo from "../../../public/assets/moyai/logos/fireworks.svg";
import googleLogo from "../../../public/assets/moyai/logos/google.svg";
import hermesLogo from "../../../public/assets/moyai/logos/hermes.png";
import langchainLogo from "../../../public/assets/moyai/logos/langchain.svg";
import mistralLogo from "../../../public/assets/moyai/logos/mistral.svg";
import openaiLogo from "../../../public/assets/moyai/logos/openai.svg";
import opencodeLogo from "../../../public/assets/moyai/logos/opencode.svg";
import xaiLogo from "../../../public/assets/moyai/logos/xai.svg";
import { PLANET_HORIZON, prefersReducedMotion, startPlanetrise, startStarfield } from "./moyaiSky";
import { normalizeMoyaiUrl } from "./moyaiConnect";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import styles from "./MoyaiLanding.module.css";
export const MOYAI_GITHUB_URL = "https://github.com/BerriAI/moyai";
export const MOYAI_WALKTHROUGH_URL = `${MOYAI_GITHUB_URL}#see-it-in-action`;
export const MOYAI_LAUNCH_POST_URL = "https://docs.litellm.ai/blog/moyai-open-source";
const MOYAI_DEMO_GIF_URL = "https://github.com/user-attachments/assets/2de74e6a-c37c-48d6-8a99-de2166c88626";
interface LogoItem {
name: string;
logo: { src: string };
}
const HARNESSES: LogoItem[] = [
{ name: "Claude Code", logo: anthropicLogo },
{ name: "Codex", logo: openaiLogo },
{ name: "Hermes", logo: hermesLogo },
{ name: "OpenCode", logo: opencodeLogo },
{ name: "Deep Agents", logo: langchainLogo },
];
const PROVIDERS: LogoItem[] = [
{ name: "OpenAI", logo: openaiLogo },
{ name: "Anthropic", logo: anthropicLogo },
{ name: "Fireworks", logo: fireworksLogo },
{ name: "Google", logo: googleLogo },
{ name: "xAI", logo: xaiLogo },
{ name: "Mistral", logo: mistralLogo },
{ name: "DeepSeek", logo: deepseekLogo },
{ name: "Bedrock", logo: bedrockLogo },
];
const ORBIT_TILT = 0.2;
const INNER_RADIUS: [number, number] = [0.16, 240];
const OUTER_RADIUS: [number, number] = [0.28, 420];
function useCanvasScene(start: (canvas: HTMLCanvasElement) => () => void) {
const ref = useRef<HTMLCanvasElement>(null);
useEffect(() => {
if (!ref.current) return;
return start(ref.current);
}, [start]);
return ref;
}
function Orbit({
items,
radius,
speed,
heroRef,
anchorRef,
delay,
className = "",
}: {
items: LogoItem[];
radius: [number, number];
speed: number;
heroRef: React.RefObject<HTMLElement | null>;
anchorRef: React.RefObject<HTMLElement | null>;
delay?: string;
className?: string;
}) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = ref.current;
if (!el) return;
const reduce = prefersReducedMotion();
let rafId = 0;
const frame = (ms: number) => {
const hero = heroRef.current?.getBoundingClientRect();
const anchor = anchorRef.current?.getBoundingClientRect();
if (hero && anchor) {
const t = ms / 1000;
const top = anchor.bottom - hero.top + 24;
const bottom = hero.height * PLANET_HORIZON - 24;
const outer = Math.min(hero.width * OUTER_RADIUS[0], OUTER_RADIUS[1]);
const scale = Math.min(1, Math.max(0, (bottom - top) / 2 - 18) / (outer * ORBIT_TILT));
const rx = Math.min(hero.width * radius[0], radius[1]) * scale;
const ry = rx * ORBIT_TILT;
el.style.top = `${(top + bottom) / 2}px`;
el.dataset.placed = "true";
Array.from(el.children).forEach((child, i) => {
const chip = child as HTMLElement;
const theta = (i / items.length) * Math.PI * 2 + t * speed;
const front = Math.sin(theta) > 0;
chip.style.transform = `translate(${Math.cos(theta) * rx}px, ${Math.sin(theta) * ry}px) translate(-50%, -50%)`;
chip.style.zIndex = front ? "2" : "1";
chip.style.opacity = front ? "1" : "0.55";
});
}
if (!reduce) rafId = requestAnimationFrame(frame);
};
rafId = requestAnimationFrame(frame);
let resizeObserver: ResizeObserver | null = null;
let onResize: (() => void) | null = null;
if (reduce) {
const reposition = () => frame(performance.now());
if (typeof ResizeObserver !== "undefined" && heroRef.current) {
resizeObserver = new ResizeObserver(reposition);
resizeObserver.observe(heroRef.current);
} else {
onResize = reposition;
window.addEventListener("resize", reposition);
}
}
return () => {
cancelAnimationFrame(rafId);
resizeObserver?.disconnect();
if (onResize) window.removeEventListener("resize", onResize);
};
}, [items, radius, speed, heroRef, anchorRef]);
return (
<div
ref={ref}
aria-hidden="true"
className={`${styles.orbit} pointer-events-none absolute left-1/2 z-raised h-0 w-0 ${className}`}
style={{ animationDelay: delay }}
>
{items.map((item) => (
<div
key={item.name}
className="absolute left-0 top-0 flex w-max items-center gap-2 whitespace-nowrap rounded-full bg-[rgba(14,18,32,0.72)] py-1.5 pl-1.5 pr-3 text-[13.5px] text-[rgba(236,240,255,0.9)] shadow-[inset_0_0_0_1px_rgba(255,255,255,0.1)] backdrop-blur-sm transition-opacity duration-300"
>
<img src={item.logo.src} alt="" className="size-6 flex-none rounded-full bg-[#f4f5fa] object-contain p-1" />
<span className="max-sm:hidden">{item.name}</span>
</div>
))}
</div>
);
}
function LogoWall({ title, items, footnote }: { title: string; items: LogoItem[]; footnote: string }) {
return (
<figure className="m-0 rounded-2xl bg-white/[0.03] p-6 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.08)]">
<figcaption className="mb-4 text-xs uppercase tracking-[0.16em] text-[#a6aab3]">{title}</figcaption>
<div className="grid grid-cols-[repeat(auto-fit,minmax(140px,1fr))] gap-2.5">
{items.map((item) => (
<div
key={item.name}
className="flex items-center gap-2.5 rounded-xl bg-white/[0.04] px-3 py-2.5 text-[15px] text-[#f5f6fa]"
>
<img src={item.logo.src} alt="" className="size-[30px] rounded-lg bg-[#f4f5fa] object-contain p-[5px]" />
<span>{item.name}</span>
</div>
))}
</div>
<p className="mb-0 mt-4 text-sm text-[#a6aab3]">{footnote}</p>
</figure>
);
}
function GithubCta({ label = "Star Moyai on GitHub" }: { label?: string }) {
return (
<a
href={MOYAI_GITHUB_URL}
target="_blank"
rel="noopener noreferrer"
className={`${styles.cta} group inline-flex items-center gap-3 rounded-full px-7 py-3.5 text-base font-semibold text-[#05070d] transition-transform hover:scale-[1.03]`}
>
<Github className="size-5" />
<span>{label}</span>
<Star className="size-4 fill-[#ffb98a] text-[#ffb98a] transition-transform group-hover:rotate-[72deg]" />
</a>
);
}
function SecondaryCta({ href, icon, children }: { href: string; icon: React.ReactNode; children: React.ReactNode }) {
return (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 rounded-full bg-white/[0.06] px-5 py-3 text-sm font-medium text-white shadow-[inset_0_0_0_1px_rgba(255,255,255,0.18)] backdrop-blur transition-colors hover:bg-white/[0.12]"
>
{icon}
{children}
</a>
);
}
function DemoPreview() {
const [failed, setFailed] = useState(false);
return (
<a
href={MOYAI_WALKTHROUGH_URL}
target="_blank"
rel="noopener noreferrer"
className="group relative block overflow-hidden rounded-2xl bg-[#0b1020] shadow-[0_0_0_1px_rgba(159,171,255,0.22),0_30px_120px_-20px_rgba(120,170,255,0.45)]"
>
<div className="flex items-center gap-1.5 border-b border-white/10 bg-white/[0.04] px-4 py-2.5">
<span className="size-2.5 rounded-full bg-[#ff5f57]" />
<span className="size-2.5 rounded-full bg-[#febc2e]" />
<span className="size-2.5 rounded-full bg-[#28c840]" />
<span className="ml-3 truncate text-xs text-[#a6a4c6]">github.com/BerriAI/moyai</span>
</div>
{failed ? (
<div className="flex aspect-[1196/720] flex-col items-center justify-center gap-3 text-[#c2c6d0]">
<Play className="size-10" />
<span>Watch the demo on GitHub</span>
</div>
) : (
<img
src={MOYAI_DEMO_GIF_URL}
alt="Moyai demo: picking a harness and model, then running a task"
className="block aspect-[1196/720] w-full bg-white object-cover"
loading="lazy"
onError={() => setFailed(true)}
/>
)}
<span className="absolute bottom-4 right-4 inline-flex items-center gap-1.5 rounded-full bg-[#05070d]/80 px-3 py-1.5 text-xs font-medium text-white opacity-0 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.15)] transition-opacity group-hover:opacity-100">
Full walkthrough on GitHub <ArrowUpRight className="size-3.5" />
</span>
</a>
);
}
function QuickConnectDialog({ onQuickConnect }: { onQuickConnect: (url: string) => Promise<void> | void }) {
const [open, setOpen] = useState(false);
const [url, setUrl] = useState("");
const [error, setError] = useState<string | null>(null);
const [pending, setPending] = useState(false);
const connect = async () => {
const normalized = normalizeMoyaiUrl(url);
if (!normalized) {
setError("Enter a full http or https URL, without credentials");
return;
}
setError(null);
setPending(true);
try {
await onQuickConnect(normalized);
} catch (e) {
setError(e instanceof Error ? e.message : "Could not start quick connect");
setPending(false);
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger
render={
<button
type="button"
className={`${styles.connect} inline-flex items-center gap-2 rounded-full px-4 py-1.5 text-sm font-semibold text-white transition-transform hover:scale-[1.03]`}
/>
}
>
<Plug aria-hidden className="size-4 text-[#cfe3ff]" />
Quick connect
<ArrowRight aria-hidden className="size-4" />
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Quick connect Moyai</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-2">
<label htmlFor="moyai-quick-connect-url" className="text-sm font-medium">
Moyai URL
</label>
<input
id="moyai-quick-connect-url"
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://moyai.your-company.com"
className="w-full rounded-md border border-border bg-background px-3 py-2 text-sm"
/>
<p className="m-0 text-xs text-muted-foreground">
You&rsquo;ll confirm on your Moyai workspace as an admin, then come straight back
</p>
{error && (
<p role="alert" className="m-0 text-xs text-red-500">
{error}
</p>
)}
</div>
<div className="flex justify-end gap-2">
<button
type="button"
disabled={pending}
onClick={connect}
className="rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground disabled:opacity-50"
>
{pending ? "Connecting" : "Connect"}
</button>
</div>
</DialogContent>
</Dialog>
);
}
const STATS = [
{ value: "79%", label: "cheaper than our Devin bill" },
{ value: "100+", label: "providers through LiteLLM" },
{ value: "6", label: "agent harnesses" },
];
export default function MoyaiLanding({
canQuickConnect = false,
onQuickConnect,
}: {
canQuickConnect?: boolean;
onQuickConnect?: (url: string) => Promise<void> | void;
}) {
const heroRef = useRef<HTMLElement>(null);
const anchorRef = useRef<HTMLDivElement>(null);
const starsRef = useCanvasScene(startStarfield);
const planetRef = useCanvasScene(startPlanetrise);
return (
<div className="relative isolate min-h-full bg-[radial-gradient(ellipse_at_50%_70%,#0a1226_0%,#04060c_65%)] text-[#e3e3e3]">
<canvas ref={starsRef} aria-hidden="true" className="pointer-events-none absolute inset-0 size-full" />
<section ref={heroRef} className="relative isolate h-[calc(100vh-3.5rem)] min-h-[680px] overflow-hidden">
<canvas ref={planetRef} aria-hidden="true" className="pointer-events-none absolute inset-0 size-full" />
<div ref={anchorRef} className="relative z-sticky flex flex-col items-center px-6 pt-[4vh] text-center">
<div
className={`${styles.rise} mb-5 inline-flex items-center gap-2 rounded-full bg-white/[0.06] px-3 py-1 text-[11px] uppercase tracking-[0.22em] text-[rgba(220,228,255,0.75)] shadow-[inset_0_0_0_1px_rgba(255,255,255,0.12)]`}
>
<span className="size-1.5 rounded-full bg-[#28c840] shadow-[0_0_8px_#28c840]" />
Now open source
</div>
<h1
className={`${styles.rise} m-0 flex flex-col items-center text-[clamp(56px,min(8vw,11vh),112px)] font-medium leading-none tracking-[-0.04em] text-white [text-shadow:0_0_40px_rgba(3,4,8,0.9)]`}
style={{ animationDelay: "0.08s" }}
>
<img src={moyaiHead.src} alt="" className={`${styles.mark} mb-[0.08em] h-[0.78em] w-auto`} />
<span>Moyai</span>
</h1>
<p
className={`${styles.rise} mb-0 mt-3 text-[clamp(18px,1.8vw,26px)] tracking-[-0.01em] text-[rgba(226,234,255,0.86)]`}
style={{ animationDelay: "0.16s" }}
>
The open source cloud coding agent
</p>
<div
className={`${styles.rise} mt-7 flex flex-wrap items-center justify-center gap-3`}
style={{ animationDelay: "0.26s" }}
>
<GithubCta />
<SecondaryCta href={MOYAI_WALKTHROUGH_URL} icon={<Play className="size-4" />}>
Watch the demo
</SecondaryCta>
</div>
<div
className={`${styles.rise} mt-6 text-[15px] text-[rgba(226,234,255,0.82)]`}
style={{ animationDelay: "0.34s" }}
>
{canQuickConnect && onQuickConnect ? (
<span className="inline-flex flex-wrap items-center justify-center gap-3 rounded-full bg-white/[0.05] py-1.5 pl-5 pr-1.5 shadow-[inset_0_0_0_1px_rgba(255,255,255,0.12)] backdrop-blur">
Already have Moyai deployed?
<QuickConnectDialog onQuickConnect={onQuickConnect} />
</span>
) : (
<span>Already have Moyai deployed? Ask a proxy admin to connect it</span>
)}
</div>
</div>
<Orbit items={HARNESSES} radius={INNER_RADIUS} speed={0.07} heroRef={heroRef} anchorRef={anchorRef} />
<Orbit
items={PROVIDERS}
radius={OUTER_RADIUS}
speed={-0.045}
heroRef={heroRef}
anchorRef={anchorRef}
delay="0.3s"
className="max-md:hidden"
/>
<div className="absolute inset-x-0 bottom-[6vh] z-chrome flex flex-col items-center gap-1 px-6 text-center [text-shadow:0_0_30px_rgba(3,4,8,0.95)]">
<span className="text-[clamp(18px,2vw,28px)] font-medium tracking-[-0.02em] text-white">
Works with Claude Code and Codex
</span>
<span className="text-[15px] text-[rgba(240,236,228,0.8)]">Self-hosted · 100+ providers through LiteLLM</span>
</div>
</section>
<div className="relative z-raised mx-auto max-w-5xl px-6 pb-24">
<div className="grid grid-cols-3 gap-3 pt-14 max-sm:grid-cols-1">
{STATS.map((s) => (
<div
key={s.value}
className="rounded-2xl bg-white/[0.03] px-6 py-5 text-center shadow-[inset_0_0_0_1px_rgba(255,255,255,0.08)]"
>
<div className="bg-gradient-to-b from-white to-[#9cc3ff] bg-clip-text text-4xl font-semibold tracking-[-0.03em] text-transparent">
{s.value}
</div>
<div className="mt-1 text-sm text-[#a6aab3]">{s.label}</div>
</div>
))}
</div>
<h2 className="mb-3 mt-20 text-center text-[clamp(28px,3.2vw,44px)] font-medium leading-tight tracking-[-0.03em] text-white">
See it in action
</h2>
<p className="mx-auto mb-8 mt-0 max-w-2xl text-center text-[17px] text-[#c2c6d0]">
Give it a task from Slack or the browser. It edits code, runs your tests in its own cloud workspace, and opens
a pull request while your laptop is closed.
</p>
<DemoPreview />
<div className="mt-16 grid gap-4 md:grid-cols-2">
<LogoWall
title="Any harness"
items={HARNESSES}
footnote="Pick the harness per session. Same workspace, tools and permissions."
/>
<LogoWall
title="Any model, any provider"
items={PROVIDERS}
footnote="Routed through your LiteLLM gateway, with spend tracked per teammate."
/>
</div>
<div className="relative mt-20 overflow-hidden rounded-3xl px-8 py-14 text-center shadow-[0_0_0_1px_rgba(159,171,255,0.2)]">
<div
aria-hidden="true"
className="absolute inset-0 bg-[radial-gradient(ellipse_at_50%_120%,rgba(120,170,255,0.45)_0%,rgba(40,60,140,0.15)_45%,rgba(5,7,13,0)_75%)]"
/>
<img src={moyaiHead.src} alt="" className={`${styles.mark} relative z-raised mx-auto mb-4 h-14 w-auto`} />
<h2 className="relative z-raised m-0 text-[clamp(26px,3vw,40px)] font-medium tracking-[-0.03em] text-white">
Run your own cloud agent
</h2>
<p className="relative z-raised mx-auto mb-7 mt-3 max-w-xl text-[16px] text-[#c2c6d0]">
Deploy it on your own infrastructure and point it at this gateway. Your code and credentials stay in your
accounts.
</p>
<code className="relative z-raised mx-auto mb-8 block w-fit max-w-full overflow-x-auto rounded-xl bg-black/50 px-5 py-3 text-left font-mono text-sm text-[#cfe3ff] shadow-[inset_0_0_0_1px_rgba(255,255,255,0.1)]">
git clone {MOYAI_GITHUB_URL}.git
</code>
<div className="relative z-raised flex flex-wrap items-center justify-center gap-3">
<GithubCta label="Get Moyai on GitHub" />
<SecondaryCta href={MOYAI_LAUNCH_POST_URL} icon={<ArrowUpRight className="size-4" />}>
Read the launch post
</SecondaryCta>
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { normalizeMoyaiUrl } from "./moyaiConnect";
describe("normalizeMoyaiUrl", () => {
it("trims whitespace and strips trailing slashes", () => {
expect(normalizeMoyaiUrl(" https://moyai.example.com/ ")).toBe("https://moyai.example.com");
});
it("keeps paths and ports", () => {
expect(normalizeMoyaiUrl("http://localhost:8080/moyai/")).toBe("http://localhost:8080/moyai");
});
it.each([
"javascript:alert(1)",
"ftp://moyai.example.com",
"not a url",
"https://",
"https://user:pass@moyai.example.com",
"https://user@moyai.example.com",
" ",
])("rejects %s", (input) => {
expect(normalizeMoyaiUrl(input)).toBeNull();
});
});

View file

@ -0,0 +1,14 @@
export function normalizeMoyaiUrl(input: string): string | null {
const trimmed = input.trim();
if (!trimmed) return null;
let parsed: URL;
try {
parsed = new URL(trimmed);
} catch {
return null;
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
if (!parsed.hostname) return null;
if (parsed.username || parsed.password) return null;
return trimmed.replace(/\/+$/, "");
}

View file

@ -0,0 +1,168 @@
export const hashN = (n: number, k: number): number => {
const v = Math.sin(n * 127.1 + k * 311.7) * 43758.5453;
return v - Math.floor(v);
};
export const prefersReducedMotion = (): boolean =>
typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true;
type Draw = (ctx: CanvasRenderingContext2D, w: number, h: number, t: number) => void;
function canvasLoop(canvas: HTMLCanvasElement, draw: Draw): () => void {
const ctx = canvas.getContext("2d");
if (!ctx) return () => {};
const reduce = prefersReducedMotion();
let w = 0;
let h = 0;
let rafId = 0;
const resize = () => {
const dpr = Math.min(window.devicePixelRatio || 1, 2);
const rect = canvas.getBoundingClientRect();
w = rect.width;
h = rect.height;
canvas.width = Math.round(w * dpr);
canvas.height = Math.round(h * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
};
const frame = (ms: number) => {
ctx.clearRect(0, 0, w, h);
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = "source-over";
draw(ctx, w, h, ms / 1000);
if (!reduce) rafId = requestAnimationFrame(frame);
};
resize();
const observer = new ResizeObserver(() => {
resize();
if (reduce) frame(0);
});
observer.observe(canvas);
if (reduce) frame(0);
else rafId = requestAnimationFrame(frame);
let visibilityObserver: IntersectionObserver | null = null;
if (typeof IntersectionObserver !== "undefined") {
visibilityObserver = new IntersectionObserver((entries) => {
const entry = entries[0];
if (!entry) return;
if (entry.isIntersecting) {
if (!reduce) {
cancelAnimationFrame(rafId);
rafId = requestAnimationFrame(frame);
}
} else {
cancelAnimationFrame(rafId);
}
});
visibilityObserver.observe(canvas);
}
return () => {
cancelAnimationFrame(rafId);
observer.disconnect();
visibilityObserver?.disconnect();
};
}
const STAR_COLORS = ["#cfe3ff", "#9cc3ff", "#ffffff", "#ffb98a", "#8b9bff", "#e9d8ff"];
export function startStarfield(canvas: HTMLCanvasElement): () => void {
let stars: { x: number; y: number; r: number; color: string; phase: number; depth: number }[] = [];
let seededFor = "";
return canvasLoop(canvas, (ctx, w, h, t) => {
const key = `${Math.round(w)}x${Math.round(h)}`;
if (key !== seededFor) {
seededFor = key;
stars = Array.from({ length: Math.round((w * h) / 5200) }, (_, n) => ({
x: hashN(n, 1) * w,
y: hashN(n, 2) * h,
r: hashN(n, 3) < 0.06 ? 1.5 : 0.4 + hashN(n, 4) * 0.8,
color: STAR_COLORS[Math.floor(hashN(n, 5) * STAR_COLORS.length)],
phase: hashN(n, 6) * Math.PI * 2,
depth: 0.2 + hashN(n, 7) * 0.8,
}));
}
for (const s of stars) {
const y = (((s.y - t * 3 * s.depth) % h) + h) % h;
ctx.globalAlpha = (0.25 + 0.5 * (0.5 + 0.5 * Math.sin(t * 0.8 + s.phase))) * s.depth;
ctx.fillStyle = s.color;
ctx.beginPath();
ctx.arc(s.x, y, s.r, 0, Math.PI * 2);
ctx.fill();
}
});
}
const CITY = Array.from({ length: 700 }, (_, n) => ({
a: (hashN(n, 1) - 0.5) * 1.3,
depth: Math.pow(hashN(n, 2), 2) * 0.08,
r: 0.4 + hashN(n, 3) * 0.9,
twinkle: hashN(n, 4) * Math.PI * 2,
}));
export const PLANET_HORIZON = 0.8;
export function startPlanetrise(canvas: HTMLCanvasElement): () => void {
return canvasLoop(canvas, (ctx, w, h, t) => {
const r = Math.max(w * 1.25, h * 1.4);
const cx = w / 2;
const cy = h * PLANET_HORIZON + r;
const sunY = cy - r - h * (0.02 + 0.012 * Math.sin(t * 0.15));
const sky = ctx.createRadialGradient(cx, sunY, 0, cx, sunY, w * 0.7);
sky.addColorStop(0, "rgba(120,170,255,0.32)");
sky.addColorStop(0.35, "rgba(70,110,220,0.1)");
sky.addColorStop(1, "rgba(40,60,140,0)");
ctx.fillStyle = sky;
ctx.fillRect(0, 0, w, h);
ctx.globalCompositeOperation = "lighter";
const flare = ctx.createLinearGradient(0, sunY, w, sunY);
flare.addColorStop(0, "rgba(120,170,255,0)");
flare.addColorStop(0.5, "rgba(210,230,255,0.55)");
flare.addColorStop(1, "rgba(120,170,255,0)");
ctx.fillStyle = flare;
ctx.fillRect(0, sunY - 1.2, w, 2.4);
const sun = ctx.createRadialGradient(cx, sunY, 0, cx, sunY, h * 0.16);
sun.addColorStop(0, "rgba(255,255,255,1)");
sun.addColorStop(0.08, "rgba(220,235,255,0.9)");
sun.addColorStop(0.35, "rgba(140,180,255,0.22)");
sun.addColorStop(1, "rgba(100,140,255,0)");
ctx.fillStyle = sun;
ctx.fillRect(0, 0, w, h);
ctx.globalCompositeOperation = "source-over";
ctx.fillStyle = "#04060c";
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.fill();
const limb = ctx.createRadialGradient(cx, cy, r * 0.985, cx, cy, r * 1.012);
limb.addColorStop(0, "rgba(60,110,255,0)");
limb.addColorStop(0.55, "rgba(110,160,255,0.55)");
limb.addColorStop(0.75, "rgba(200,225,255,0.9)");
limb.addColorStop(1, "rgba(120,160,255,0)");
ctx.globalCompositeOperation = "lighter";
ctx.fillStyle = limb;
ctx.beginPath();
ctx.arc(cx, cy, r * 1.012, Math.PI * 1.08, Math.PI * 1.92);
ctx.arc(cx, cy, r * 0.985, Math.PI * 1.92, Math.PI * 1.08, true);
ctx.closePath();
ctx.fill();
ctx.globalCompositeOperation = "source-over";
for (const c of CITY) {
const theta = -Math.PI / 2 + c.a * (w / r);
const rr = r * (0.996 - c.depth);
const x = cx + Math.cos(theta) * rr;
const y = cy + Math.sin(theta) * rr;
if (y > h) continue;
ctx.globalAlpha = (0.25 + 0.5 * (0.5 + 0.5 * Math.sin(t * 1.3 + c.twinkle))) * (1 - c.depth * 8);
ctx.fillStyle = "#ffd59a";
ctx.beginPath();
ctx.arc(x, y, c.r, 0, Math.PI * 2);
ctx.fill();
}
});
}

View file

@ -7051,6 +7051,13 @@ export const updateUiSettings = async (accessToken: string, settings: Record<str
return data;
};
export const startMoyaiQuickConnect = async (accessToken: string, moyaiUrl: string, returnTo: string) => {
return apiClient.post<{ connect_url: string }>("/moyai/connect/start", {
accessToken,
body: { moyai_url: moyaiUrl, return_to: returnTo },
});
};
export type UserBannerSeverity = "info" | "warning" | "error";
export interface UserBanner {

View file

@ -10943,6 +10943,40 @@ export interface paths {
patch?: never;
trace?: never;
};
"/moyai/connect/exchange": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Moyai Connect Exchange */
post: operations["moyai_connect_exchange_moyai_connect_exchange_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/moyai/connect/start": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Moyai Connect Start */
post: operations["moyai_connect_start_moyai_connect_start_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/nvidia_nim/{endpoint}": {
parameters: {
query?: never;
@ -39087,6 +39121,34 @@ export interface components {
} & {
[key: string]: unknown;
};
/** MoyaiConnectExchangeRequest */
MoyaiConnectExchangeRequest: {
/** Code */
code: string;
/** Moyai Url */
moyai_url: string;
};
/** MoyaiConnectExchangeResponse */
MoyaiConnectExchangeResponse: {
/** Api Base */
api_base: string;
/** Api Key */
api_key: string;
/** Key Alias */
key_alias: string;
};
/** MoyaiConnectStartRequest */
MoyaiConnectStartRequest: {
/** Moyai Url */
moyai_url: string;
/** Return To */
return_to: string;
};
/** MoyaiConnectStartResponse */
MoyaiConnectStartResponse: {
/** Connect Url */
connect_url: string;
};
/**
* MutualTLSSecurityScheme
* @description Defines a security scheme using mTLS authentication.
@ -66422,6 +66484,72 @@ export interface operations {
};
};
};
moyai_connect_exchange_moyai_connect_exchange_post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["MoyaiConnectExchangeRequest"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["MoyaiConnectExchangeResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
moyai_connect_start_moyai_connect_start_post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["MoyaiConnectStartRequest"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["MoyaiConnectStartResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
nvidia_nim_proxy_route_nvidia_nim__endpoint__get: {
parameters: {
query?: never;