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>
|
|
@ -853,6 +853,7 @@ class LiteLLMRoutes(enum.Enum):
|
|||
"/public/mcp_hub",
|
||||
"/public/skill_hub",
|
||||
"/public/litellm_model_cost_map",
|
||||
"/moyai/connect/exchange",
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
|||
260
litellm/proxy/moyai_endpoints.py
Normal 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),
|
||||
)
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
|||
290
tests/unit/proxy/test_moyai_endpoints.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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 |
25
ui/litellm-dashboard/public/assets/moyai/logos/deepseek.svg
Normal 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 |
|
|
@ -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 |
|
|
@ -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 |
BIN
ui/litellm-dashboard/public/assets/moyai/logos/hermes.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
16
ui/litellm-dashboard/public/assets/moyai/logos/opencode.svg
Normal 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 |
28
ui/litellm-dashboard/public/assets/moyai/logos/xai.svg
Normal 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 |
19
ui/litellm-dashboard/public/assets/moyai/moyai-head.svg
Normal 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 |
|
|
@ -29,5 +29,5 @@ function getSnapshot() {
|
|||
}
|
||||
|
||||
export function useDisableBlogPosts() {
|
||||
return useSyncExternalStore(subscribe, getSnapshot);
|
||||
return useSyncExternalStore(subscribe, getSnapshot, () => false);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,5 +29,5 @@ function getSnapshot() {
|
|||
}
|
||||
|
||||
export function useDisableBouncingIcon() {
|
||||
return useSyncExternalStore(subscribe, getSnapshot);
|
||||
return useSyncExternalStore(subscribe, getSnapshot, () => false);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,5 +31,5 @@ function getSnapshot() {
|
|||
}
|
||||
|
||||
export function useDisableShowNewBadge() {
|
||||
return useSyncExternalStore(subscribe, getSnapshot);
|
||||
return useSyncExternalStore(subscribe, getSnapshot, () => false);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,5 +31,5 @@ function getSnapshot() {
|
|||
}
|
||||
|
||||
export function useDisableShowPrompts() {
|
||||
return useSyncExternalStore(subscribe, getSnapshot);
|
||||
return useSyncExternalStore(subscribe, getSnapshot, () => false);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,5 +31,5 @@ function getSnapshot() {
|
|||
}
|
||||
|
||||
export function useHideAutoRouterAnnouncement() {
|
||||
return useSyncExternalStore(subscribe, getSnapshot);
|
||||
return useSyncExternalStore(subscribe, getSnapshot, () => false);
|
||||
}
|
||||
|
|
|
|||
99
ui/litellm-dashboard/src/app/moyai/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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" }];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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?.();
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
84
ui/litellm-dashboard/src/components/moyai/MoyaiConnected.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
463
ui/litellm-dashboard/src/components/moyai/MoyaiLanding.tsx
Normal 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’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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
14
ui/litellm-dashboard/src/components/moyai/moyaiConnect.ts
Normal 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(/\/+$/, "");
|
||||
}
|
||||
168
ui/litellm-dashboard/src/components/moyai/moyaiSky.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
128
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -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;
|
||||
|
|
|
|||