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 @@
+
\ 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 @@
+
\ 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 @@
+
\ 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 @@
+
\ 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 = (
+
Opening Moyai
+ + Continue to {moyaiUrl} + +Moyai
++ {moyaiUrl ? `Connected to ${moyaiUrl}` : "Not connected"} +
++ This deployment is now linked to your LiteLLM gateway. +
+ +{footnote}
++ The open source cloud coding agent +
++ 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. +
++ Deploy it on your own infrastructure and point it at this gateway. Your code and credentials stay in your + accounts. +
+
+ git clone {MOYAI_GITHUB_URL}.git
+
+