Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions litellm/proxy/_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -853,6 +853,7 @@ class LiteLLMRoutes(enum.Enum):
"/public/mcp_hub",
"/public/skill_hub",
"/public/litellm_model_cost_map",
"/moyai/connect/exchange",
)
)

Expand Down
260 changes: 260 additions & 0 deletions litellm/proxy/moyai_endpoints.py
Original file line number Diff line number Diff line change
@@ -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("/")
Comment on lines +82 to +84

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Gateway URL loses its prefix

If PROXY_BASE_URL is set and the proxy runs under SERVER_ROOT_PATH, _gateway_url drops the path prefix. Moyai sends its exchange request to the wrong URL, so quick connect fails. The returned api_base also lacks the prefix. Use get_custom_url(str(request.base_url)), which already joins the public base URL and the current root path

Knowledge Base Used: Dashboard and enterprise UI



_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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Helper inputs lack required types

_decode_connect_code returns a bare dict, while _moyai_key_alias and _persist_moyai_url leave prisma_client untyped. AGENTS.md requires strongly typed parameters and forbids coarse types such as bare dict. Add the database type and a typed model for the decoded payload. This repository requirement must be satisfied before merging

Context Used: AGENTS.md (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Connect code can be stolen

If an admin connects a remote HTTP deployment, the browser sends the signed code without encryption. A network observer can redeem it first at /moyai/connect/exchange, receive a working gateway key, and block the intended connection. Require HTTPS for remote quick-connect URLs, with a separate localhost exception if needed

How this was verified: The browser sends code in an HTTP URL, and the public exchange returns an API key using that code without another credential

Rule Used: What: Fail any PR which may contains a security incident on litellm's authentication layer Why: Do not cause security incidents Bad: ```python # Check cache first cache_key = ( f"oidc_userinfo_{token[:20]}" # Use fi... (source)

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}
)
Comment on lines +159 to +161

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Accepted URLs cannot connect

Both URL validators accept queries and fragments, but connect_url appends its path to the raw URL. For https://moyai.example.com/#dashboard, the connection path and code stay in the fragment and never reach the server. An admin can pass the form checks but land at the wrong page. Reject queries and fragments for deployment base URLs, or build the URL from parsed parts

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"}
)
Comment on lines +196 to +198

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Connecting restores old settings

If a read replica is configured, _persist_moyai_url reads settings from it and then replaces the entire settings value on the writer. An admin's recent changes can disappear if the replica has not caught up. Read from the writer and make the merge safe against concurrent updates

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)
Comment on lines +195 to +201

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Save helper reassigns settings

_persist_moyai_url initializes existing and later reassigns it. AGENTS.md forbids reassignment and requires every variable to use Final. Build existing once from the database row instead. This repository requirement must be satisfied before merging

Context Used: AGENTS.md (source)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!


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),
)
2 changes: 2 additions & 0 deletions litellm/proxy/proxy_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -770,6 +770,7 @@ def generate_feedback_box():
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,
Expand Down Expand Up @@ -20183,6 +20184,7 @@ async def get_routes():
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)
Expand Down
27 changes: 26 additions & 1 deletion litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
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

Expand Down Expand Up @@ -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"""

Expand Down Expand Up @@ -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=(
Expand Down Expand Up @@ -368,6 +392,7 @@ class UISettingsResponse(SettingsResponse):
"disable_custom_api_keys",
"disable_key_generate_for_org_admin",
"enable_chat_ui",
"moyai_url",
TEAM_ADMIN_EDITABLE_TEAM_FIELDS_SETTING,
}

Expand Down
Loading
Loading