diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 0ed2b38cfa6..7f92573fde7 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -853,6 +853,7 @@ class LiteLLMRoutes(enum.Enum): "/public/mcp_hub", "/public/skill_hub", "/public/litellm_model_cost_map", + "/moyai/connect/exchange", ) ) diff --git a/litellm/proxy/moyai_endpoints.py b/litellm/proxy/moyai_endpoints.py new file mode 100644 index 00000000000..655fc0560f4 --- /dev/null +++ b/litellm/proxy/moyai_endpoints.py @@ -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), + ) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 8864dc518ab..aa00bfe5d26 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -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) diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py index d307805427a..368c2e9758e 100644 --- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py @@ -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, } diff --git a/tests/unit/proxy/test_moyai_endpoints.py b/tests/unit/proxy/test_moyai_endpoints.py new file mode 100644 index 00000000000..a0b0d56b637 --- /dev/null +++ b/tests/unit/proxy/test_moyai_endpoints.py @@ -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 diff --git a/tests/unit/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py b/tests/unit/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py index bea305417f3..4a7cee80c61 100644 --- a/tests/unit/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py +++ b/tests/unit/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py @@ -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 diff --git a/ui/litellm-dashboard/public/assets/moyai/logos/anthropic.svg b/ui/litellm-dashboard/public/assets/moyai/logos/anthropic.svg new file mode 100644 index 00000000000..a37f591fb76 --- /dev/null +++ b/ui/litellm-dashboard/public/assets/moyai/logos/anthropic.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/ui/litellm-dashboard/public/assets/moyai/logos/bedrock.svg b/ui/litellm-dashboard/public/assets/moyai/logos/bedrock.svg new file mode 100644 index 00000000000..e0f929a7a97 --- /dev/null +++ b/ui/litellm-dashboard/public/assets/moyai/logos/bedrock.svg @@ -0,0 +1 @@ +Bedrock \ No newline at end of file diff --git a/ui/litellm-dashboard/public/assets/moyai/logos/deepseek.svg b/ui/litellm-dashboard/public/assets/moyai/logos/deepseek.svg new file mode 100644 index 00000000000..c4754047da2 --- /dev/null +++ b/ui/litellm-dashboard/public/assets/moyai/logos/deepseek.svg @@ -0,0 +1,25 @@ + + + + + + diff --git a/ui/litellm-dashboard/public/assets/moyai/logos/fireworks.svg b/ui/litellm-dashboard/public/assets/moyai/logos/fireworks.svg new file mode 100644 index 00000000000..a23445cf94b --- /dev/null +++ b/ui/litellm-dashboard/public/assets/moyai/logos/fireworks.svg @@ -0,0 +1 @@ +Fireworks \ No newline at end of file diff --git a/ui/litellm-dashboard/public/assets/moyai/logos/google.svg b/ui/litellm-dashboard/public/assets/moyai/logos/google.svg new file mode 100644 index 00000000000..7bc4a38ce7a --- /dev/null +++ b/ui/litellm-dashboard/public/assets/moyai/logos/google.svg @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/ui/litellm-dashboard/public/assets/moyai/logos/hermes.png b/ui/litellm-dashboard/public/assets/moyai/logos/hermes.png new file mode 100644 index 00000000000..de47b728d12 Binary files /dev/null and b/ui/litellm-dashboard/public/assets/moyai/logos/hermes.png differ diff --git a/ui/litellm-dashboard/public/assets/moyai/logos/langchain.svg b/ui/litellm-dashboard/public/assets/moyai/logos/langchain.svg new file mode 100644 index 00000000000..939b79989a7 --- /dev/null +++ b/ui/litellm-dashboard/public/assets/moyai/logos/langchain.svg @@ -0,0 +1 @@ +LangChain \ No newline at end of file diff --git a/ui/litellm-dashboard/public/assets/moyai/logos/mistral.svg b/ui/litellm-dashboard/public/assets/moyai/logos/mistral.svg new file mode 100644 index 00000000000..8e03e244bf1 --- /dev/null +++ b/ui/litellm-dashboard/public/assets/moyai/logos/mistral.svg @@ -0,0 +1 @@ +Mistral \ No newline at end of file diff --git a/ui/litellm-dashboard/public/assets/moyai/logos/openai.svg b/ui/litellm-dashboard/public/assets/moyai/logos/openai.svg new file mode 100644 index 00000000000..52dad8269ec --- /dev/null +++ b/ui/litellm-dashboard/public/assets/moyai/logos/openai.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/ui/litellm-dashboard/public/assets/moyai/logos/opencode.svg b/ui/litellm-dashboard/public/assets/moyai/logos/opencode.svg new file mode 100644 index 00000000000..7ed0af003bb --- /dev/null +++ b/ui/litellm-dashboard/public/assets/moyai/logos/opencode.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/ui/litellm-dashboard/public/assets/moyai/logos/xai.svg b/ui/litellm-dashboard/public/assets/moyai/logos/xai.svg new file mode 100644 index 00000000000..9491b192fd5 --- /dev/null +++ b/ui/litellm-dashboard/public/assets/moyai/logos/xai.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + diff --git a/ui/litellm-dashboard/public/assets/moyai/moyai-head.svg b/ui/litellm-dashboard/public/assets/moyai/moyai-head.svg new file mode 100644 index 00000000000..6d69078c4e3 --- /dev/null +++ b/ui/litellm-dashboard/public/assets/moyai/moyai-head.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useDisableBlogPosts.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useDisableBlogPosts.ts index a7b37b78d42..8bffbe9f741 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useDisableBlogPosts.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useDisableBlogPosts.ts @@ -29,5 +29,5 @@ function getSnapshot() { } export function useDisableBlogPosts() { - return useSyncExternalStore(subscribe, getSnapshot); + return useSyncExternalStore(subscribe, getSnapshot, () => false); } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useDisableBouncingIcon.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useDisableBouncingIcon.ts index f5d8087ebe7..0ec94f9915f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useDisableBouncingIcon.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useDisableBouncingIcon.ts @@ -29,5 +29,5 @@ function getSnapshot() { } export function useDisableBouncingIcon() { - return useSyncExternalStore(subscribe, getSnapshot); + return useSyncExternalStore(subscribe, getSnapshot, () => false); } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useDisableShowNewBadge.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useDisableShowNewBadge.ts index d0a618e27ba..0feba3a27fd 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useDisableShowNewBadge.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useDisableShowNewBadge.ts @@ -31,5 +31,5 @@ function getSnapshot() { } export function useDisableShowNewBadge() { - return useSyncExternalStore(subscribe, getSnapshot); + return useSyncExternalStore(subscribe, getSnapshot, () => false); } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useDisableShowPrompts.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useDisableShowPrompts.ts index 801fbdbb99d..872da097868 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useDisableShowPrompts.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useDisableShowPrompts.ts @@ -31,5 +31,5 @@ function getSnapshot() { } export function useDisableShowPrompts() { - return useSyncExternalStore(subscribe, getSnapshot); + return useSyncExternalStore(subscribe, getSnapshot, () => false); } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useHideAutoRouterAnnouncement.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useHideAutoRouterAnnouncement.ts index c213c4834bc..61b9b3eea7a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useHideAutoRouterAnnouncement.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useHideAutoRouterAnnouncement.ts @@ -31,5 +31,5 @@ function getSnapshot() { } export function useHideAutoRouterAnnouncement() { - return useSyncExternalStore(subscribe, getSnapshot); + return useSyncExternalStore(subscribe, getSnapshot, () => false); } diff --git a/ui/litellm-dashboard/src/app/moyai/page.tsx b/ui/litellm-dashboard/src/app/moyai/page.tsx new file mode 100644 index 00000000000..0c2179714b5 --- /dev/null +++ b/ui/litellm-dashboard/src/app/moyai/page.tsx @@ -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 = ( + + ); + } else if (moyaiUrl) { + content = ( +
+

Opening Moyai

+ + Continue to {moyaiUrl} + +
+ ); + } else { + content = ; + } + } + + return ( + + +
+ +
{content}
+
+
+
+ ); +} + +export default function MoyaiPage() { + return ( + + + + ); +} diff --git a/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.test.tsx b/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.test.tsx index a32df932d05..882623b7de7 100644 --- a/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.test.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.test.tsx @@ -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(); + + 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(); + + 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(); + expect(screen.getByRole("button")).toHaveTextContent("Moyai"); + unmount(); + + state.pathname = "/ui/moyai/anything"; + render(); + 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(); + + 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(); + + 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(); + + 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" }]; diff --git a/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.tsx b/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.tsx index b3aba7155d1..24d9e332ad4 100644 --- a/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/ViewSwitcher.tsx @@ -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: (
{e.label} - {!isChatRoute && e.key === mode && } + {!isStandaloneRoute && e.key === mode && }
), onClick: () => selectMode(e.key), })), + { + key: MOYAI, + label: ( +
+ + + + Moyai + Cloud Coding Agent + + + {isMoyaiRoute && } +
+ ), + onClick: () => window.location.assign(moyaiUrl ?? uiHref(MOYAI)), + }, chatItem, ]; diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.test.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.test.tsx index c2834e65498..6a491f6160f 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.test.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.test.tsx @@ -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(); + + 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(); + + 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?.(); diff --git a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx index 612ca05d083..476a4f2f6b8 100644 --- a/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx +++ b/ui/litellm-dashboard/src/components/Settings/AdminSettings/UISettings/UISettings.tsx @@ -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() { } /> + +
+
+

Moyai

+

+ {moyaiUrl ? `Connected to ${moyaiUrl}` : "Not connected"} +

+
+ {moyaiUrl ? ( + + ) : ( + + Connect from the Moyai page + + )} +
+ ({ uiHref: (seg: string) => `/ui/${seg}` })); + +describe("MoyaiConnected", () => { + it("shows workspace, key alias, and model count rows with an Open Moyai CTA", () => { + render(); + + 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(); + + expect(screen.queryByText(/models available through LiteLLM/)).not.toBeInTheDocument(); + expect(screen.queryByText("Virtual key issued")).not.toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/moyai/MoyaiConnected.tsx b/ui/litellm-dashboard/src/components/moyai/MoyaiConnected.tsx new file mode 100644 index 00000000000..9030e2ab96d --- /dev/null +++ b/ui/litellm-dashboard/src/components/moyai/MoyaiConnected.tsx @@ -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 ( +
+
+
+ + Connected +
+ +

Moyai connected

+

+ This deployment is now linked to your LiteLLM gateway. +

+ +
+ {rows.map((row) => + row.value ? ( +
+ + + {row.prefix ? ( + <> + {row.value} {row.label} + + ) : ( + <> + {row.label} {row.value} + + )} + +
+ ) : null, + )} +
+ + +
+
+ ); +} diff --git a/ui/litellm-dashboard/src/components/moyai/MoyaiLanding.module.css b/ui/litellm-dashboard/src/components/moyai/MoyaiLanding.module.css new file mode 100644 index 00000000000..cd57b893017 --- /dev/null +++ b/ui/litellm-dashboard/src/components/moyai/MoyaiLanding.module.css @@ -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: ""; + 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; + } +} diff --git a/ui/litellm-dashboard/src/components/moyai/MoyaiLanding.test.tsx b/ui/litellm-dashboard/src/components/moyai/MoyaiLanding.test.tsx new file mode 100644 index 00000000000..3d3095ddd5e --- /dev/null +++ b/ui/litellm-dashboard/src/components/moyai/MoyaiLanding.test.tsx @@ -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(); + 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(); + + 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(); + + 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(); + + 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(); + + const demoImg = screen.getByAltText(/Moyai demo:/); + fireEvent.error(demoImg); + + expect(screen.getByText("Watch the demo on GitHub")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/moyai/MoyaiLanding.tsx b/ui/litellm-dashboard/src/components/moyai/MoyaiLanding.tsx new file mode 100644 index 00000000000..e64c153145b --- /dev/null +++ b/ui/litellm-dashboard/src/components/moyai/MoyaiLanding.tsx @@ -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(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; + anchorRef: React.RefObject; + delay?: string; + className?: string; +}) { + const ref = useRef(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 ( + + ); +} + +function LogoWall({ title, items, footnote }: { title: string; items: LogoItem[]; footnote: string }) { + return ( +
+
{title}
+
+ {items.map((item) => ( +
+ + {item.name} +
+ ))} +
+

{footnote}

+
+ ); +} + +function GithubCta({ label = "Star Moyai on GitHub" }: { label?: string }) { + return ( + + + {label} + + + ); +} + +function SecondaryCta({ href, icon, children }: { href: string; icon: React.ReactNode; children: React.ReactNode }) { + return ( + + {icon} + {children} + + ); +} + +function DemoPreview() { + const [failed, setFailed] = useState(false); + return ( + +
+ + + + github.com/BerriAI/moyai +
+ {failed ? ( +
+ + Watch the demo on GitHub +
+ ) : ( + Moyai demo: picking a harness and model, then running a task setFailed(true)} + /> + )} + + Full walkthrough on GitHub + +
+ ); +} + +function QuickConnectDialog({ onQuickConnect }: { onQuickConnect: (url: string) => Promise | void }) { + const [open, setOpen] = useState(false); + const [url, setUrl] = useState(""); + const [error, setError] = useState(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 ( + + + } + > + + Quick connect + + + + + Quick connect Moyai + +
+ + 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" + /> +

+ You’ll confirm on your Moyai workspace as an admin, then come straight back +

+ {error && ( +

+ {error} +

+ )} +
+
+ +
+
+
+ ); +} + +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; +}) { + const heroRef = useRef(null); + const anchorRef = useRef(null); + const starsRef = useCanvasScene(startStarfield); + const planetRef = useCanvasScene(startPlanetrise); + + return ( +
+