From 733beecaf6c791c30e36aa51945e13db2886a893 Mon Sep 17 00:00:00 2001 From: Ilias Date: Sun, 12 Jul 2026 16:39:19 -0700 Subject: [PATCH] Initial commit: cross-platform MiMo-to-Anthropic local gateway Adds a FastAPI-based local proxy that remaps Claude model IDs to Xiaomi MiMo and forwards requests to MiMo's Anthropic-compatible API. Includes launchers for Windows (start.ps1), macOS/Linux (start.sh), and Docker. Co-Authored-By: Claude Sonnet 4.5 --- .dockerignore | 7 ++ .env.example | 4 + .gitignore | 5 ++ Dockerfile | 12 +++ README.md | 124 ++++++++++++++++++++++++++++ mimo_gateway.py | 204 +++++++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 4 + start.ps1 | 38 +++++++++ start.sh | 37 +++++++++ 9 files changed, 435 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 mimo_gateway.py create mode 100644 requirements.txt create mode 100644 start.ps1 create mode 100644 start.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..772d6e6 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +.env +.venv +venv +__pycache__ +*.py[cod] +.git +.agents diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..541477e --- /dev/null +++ b/.env.example @@ -0,0 +1,4 @@ +MIMO_API_KEY=your_xiaomi_mimo_api_key_here +MIMO_BASE_URL=https://api.xiaomimimo.com/anthropic +HOST=127.0.0.1 +PORT=8787 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cba5811 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.env +__pycache__/ +*.py[cod] +.venv/ +venv/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..5a4667e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.13-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY mimo_gateway.py . + +EXPOSE 8787 + +CMD ["uvicorn", "mimo_gateway:app", "--host", "0.0.0.0", "--port", "8787"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..8e8ccae --- /dev/null +++ b/README.md @@ -0,0 +1,124 @@ +# MiMo Local Gateway for Claude Desktop + +A small local compatibility gateway that presents Anthropic-style model names to Claude Desktop and forwards requests to Xiaomi MiMo's Anthropic-compatible API. + +## Install + +Python 3.11 or newer is required. + +### Windows (PowerShell) + +```powershell +python -m venv .venv +.\.venv\Scripts\Activate.ps1 +pip install -r requirements.txt +Copy-Item .env.example .env +``` + +### macOS / Linux (bash) + +```bash +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +cp .env.example .env +``` + +### Docker + +No local Python setup needed. + +```bash +docker build -t mimo-gateway . +``` + +### Configure the API key + +Open `.env` and replace the placeholder with your Xiaomi MiMo API key: + +```dotenv +MIMO_API_KEY=your_new_xiaomi_key_here +MIMO_BASE_URL=https://api.xiaomimimo.com/anthropic +``` + +The API key is loaded locally, is never logged, and `.env` is excluded from Git. + +## Run + +### Windows (PowerShell) + +```powershell +.\start.ps1 +``` + +### macOS / Linux (bash) + +```bash +chmod +x start.sh # first time only +./start.sh +``` + +### Docker + +```bash +docker run --rm -p 8787:8787 --env-file .env mimo-gateway +``` + +By default, the gateway listens only at `http://127.0.0.1:8787`. + +## Test + +```bash +curl -i http://127.0.0.1:8787/v1/models +curl -i http://127.0.0.1:8787/health +``` + +To test a message: + +```bash +curl -s http://127.0.0.1:8787/v1/messages \ + -H "Authorization: Bearer dummy" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "claude-sonnet-4-5", + "max_tokens": 32, + "messages": [{"role": "user", "content": [{"type": "text", "text": "Reply with only OK."}]}], + "stream": false + }' +``` + +## Configure Claude Desktop + +Use these third-party inference settings: + +| Setting | Value | +|---|---| +| Gateway base URL | `http://127.0.0.1:8787` | +| Gateway API key | `dummy` | +| Auth scheme | `bearer` | +| Model ID | `claude-sonnet-4-5` | +| Display name | `MiMo 2.5 Pro` | +| Tier alias | `sonnet` | +| Offer 1M-context variant | Off | + +For the explicit 1M model, use: + +| Setting | Value | +|---|---| +| Model ID | `claude-sonnet-4-5-1m` | +| Display name | `MiMo 2.5 Pro 1M` | +| Tier alias | `sonnet` | +| Offer 1M-context variant | Off | + +## Model mapping + +The gateway maps both bare and `anthropic/`-prefixed Claude-style names: + +| Incoming model | Xiaomi MiMo model | +|---|---| +| `claude-sonnet-4-5` | `mimo-v2.5-pro` | +| `anthropic/claude-sonnet-4-5` | `mimo-v2.5-pro` | +| `claude-sonnet-4-5-1m` | `mimo-v2.5-pro[1m]` | +| `anthropic/claude-sonnet-4-5-1m` | `mimo-v2.5-pro[1m]` | + +Token counting is forwarded when Xiaomi supports it; otherwise the gateway returns a safe local approximation. To change the bind address or port, set `HOST` or `PORT` in `.env`. Keep `HOST=127.0.0.1` unless you intentionally want other devices to reach the gateway. diff --git a/mimo_gateway.py b/mimo_gateway.py new file mode 100644 index 0000000..296d41c --- /dev/null +++ b/mimo_gateway.py @@ -0,0 +1,204 @@ +import json +import logging +import os +from collections.abc import AsyncIterator +from typing import Any + +import httpx +from dotenv import load_dotenv +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse, StreamingResponse + +load_dotenv() + +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(message)s", +) +logger = logging.getLogger("mimo-gateway") + +app = FastAPI(title="MiMo Local Gateway") + +MODEL_MAP = { + "claude-sonnet-4-5": "mimo-v2.5-pro", + "anthropic/claude-sonnet-4-5": "mimo-v2.5-pro", + "claude-sonnet-4-5-1m": "mimo-v2.5-pro[1m]", + "anthropic/claude-sonnet-4-5-1m": "mimo-v2.5-pro[1m]", +} +DEFAULT_MODEL = "claude-sonnet-4-5" + + +def base_url() -> str: + return os.getenv( + "MIMO_BASE_URL", "https://api.xiaomimimo.com/anthropic" + ).rstrip("/") + + +def api_key() -> str | None: + return os.getenv("MIMO_API_KEY") + + +def remap_model(body: dict[str, Any]) -> str: + incoming_model = body.get("model") or DEFAULT_MODEL + mapped_model = MODEL_MAP.get(incoming_model, incoming_model) + body["model"] = mapped_model + return mapped_model + + +def upstream_headers(key: str) -> dict[str, str]: + return {"api-key": key, "Content-Type": "application/json"} + + +async def read_json_body(request: Request) -> dict[str, Any] | JSONResponse: + try: + body = await request.json() + except (json.JSONDecodeError, UnicodeDecodeError): + return JSONResponse({"error": "Request body must be valid JSON"}, status_code=400) + if not isinstance(body, dict): + return JSONResponse({"error": "Request body must be a JSON object"}, status_code=400) + return body + + +def response_from_upstream(response: httpx.Response) -> JSONResponse: + try: + content = response.json() + except (json.JSONDecodeError, UnicodeDecodeError): + content = {"raw": response.text} + return JSONResponse(content=content, status_code=response.status_code) + + +@app.get("/v1/models") +async def list_models() -> dict[str, Any]: + logger.info("path=/v1/models") + return { + "object": "list", + "data": [ + { + "id": "claude-sonnet-4-5", + "object": "model", + "display_name": "MiMo 2.5 Pro", + }, + { + "id": "claude-sonnet-4-5-1m", + "object": "model", + "display_name": "MiMo 2.5 Pro 1M", + }, + ], + } + + +@app.get("/health") +async def health() -> dict[str, Any]: + logger.info("path=/health") + return {"ok": True, "gateway": "mimo-local-gateway", "upstream": base_url()} + + +@app.post("/v1/messages") +async def messages(request: Request): + key = api_key() + if not key: + return JSONResponse( + {"error": "Missing MIMO_API_KEY environment variable"}, status_code=500 + ) + + body = await read_json_body(request) + if isinstance(body, JSONResponse): + return body + mapped_model = remap_model(body) + logger.info("path=/v1/messages mapped_model=%s", mapped_model) + url = f"{base_url()}/v1/messages" + + if body.get("stream") is True: + client = httpx.AsyncClient(timeout=None) + try: + upstream = await client.send( + client.build_request("POST", url, headers=upstream_headers(key), json=body), + stream=True, + ) + except httpx.RequestError as exc: + await client.aclose() + logger.warning("path=/v1/messages upstream_error=%s", type(exc).__name__) + return JSONResponse({"error": "Unable to reach MiMo upstream"}, status_code=502) + + logger.info( + "path=/v1/messages mapped_model=%s upstream_status=%s", + mapped_model, + upstream.status_code, + ) + + async def chunks() -> AsyncIterator[bytes]: + try: + async for chunk in upstream.aiter_bytes(): + yield chunk + finally: + await upstream.aclose() + await client.aclose() + + return StreamingResponse( + chunks(), status_code=upstream.status_code, media_type="text/event-stream" + ) + + try: + async with httpx.AsyncClient(timeout=120.0) as client: + upstream = await client.post(url, headers=upstream_headers(key), json=body) + except httpx.RequestError as exc: + logger.warning("path=/v1/messages upstream_error=%s", type(exc).__name__) + return JSONResponse({"error": "Unable to reach MiMo upstream"}, status_code=502) + + logger.info( + "path=/v1/messages mapped_model=%s upstream_status=%s", + mapped_model, + upstream.status_code, + ) + return response_from_upstream(upstream) + + +@app.post("/v1/messages/count_tokens") +async def count_tokens(request: Request): + body = await read_json_body(request) + if isinstance(body, JSONResponse): + return body + mapped_model = remap_model(body) + logger.info("path=/v1/messages/count_tokens mapped_model=%s", mapped_model) + + def fallback() -> JSONResponse: + text = json.dumps(body, ensure_ascii=False, separators=(",", ":")) + return JSONResponse({"input_tokens": max(1, len(text) // 4)}) + + key = api_key() + if not key: + logger.warning("path=/v1/messages/count_tokens missing_api_key using_fallback=true") + return fallback() + + try: + async with httpx.AsyncClient(timeout=120.0) as client: + upstream = await client.post( + f"{base_url()}/v1/messages/count_tokens", + headers=upstream_headers(key), + json=body, + ) + except httpx.RequestError as exc: + logger.warning( + "path=/v1/messages/count_tokens upstream_error=%s using_fallback=true", + type(exc).__name__, + ) + return fallback() + + logger.info( + "path=/v1/messages/count_tokens mapped_model=%s upstream_status=%s", + mapped_model, + upstream.status_code, + ) + if upstream.status_code == 404: + return fallback() + return response_from_upstream(upstream) + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run( + "mimo_gateway:app", + host=os.getenv("HOST", "127.0.0.1"), + port=int(os.getenv("PORT", "8787")), + ) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..3263361 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +fastapi +uvicorn +httpx +python-dotenv diff --git a/start.ps1 b/start.ps1 new file mode 100644 index 0000000..f56d17e --- /dev/null +++ b/start.ps1 @@ -0,0 +1,38 @@ +$ErrorActionPreference = "Stop" + +if (Test-Path -LiteralPath ".env") { + Get-Content -LiteralPath ".env" | ForEach-Object { + $line = $_.Trim() + if ($line -and -not $line.StartsWith("#") -and $line.Contains("=")) { + $name, $value = $line -split "=", 2 + [Environment]::SetEnvironmentVariable($name.Trim(), $value.Trim(), "Process") + } + } +} + +if (-not $env:MIMO_API_KEY) { + Write-Error "MIMO_API_KEY is not set. Copy .env.example to .env and add your Xiaomi MiMo API key." +} + +$hostAddress = if ($env:HOST) { $env:HOST } else { "127.0.0.1" } +$portNumber = if ($env:PORT) { $env:PORT } else { "8787" } + +$installedPython = Join-Path $env:LOCALAPPDATA "Programs\Python\Python313\python.exe" +if (Test-Path -LiteralPath $installedPython) { + & $installedPython -m uvicorn mimo_gateway:app --host $hostAddress --port $portNumber + exit $LASTEXITCODE +} + +$python = Get-Command python -ErrorAction SilentlyContinue +if ($python -and $python.Source -notlike "*WindowsApps*") { + & $python.Source -m uvicorn mimo_gateway:app --host $hostAddress --port $portNumber + exit $LASTEXITCODE +} + +$pyLauncher = Get-Command py -ErrorAction SilentlyContinue +if ($pyLauncher) { + & $pyLauncher.Source -3 -m uvicorn mimo_gateway:app --host $hostAddress --port $portNumber + exit $LASTEXITCODE +} + +Write-Error "Python 3.11 or newer was not found. Install Python, then run this script again." diff --git a/start.sh b/start.sh new file mode 100644 index 0000000..ebc8617 --- /dev/null +++ b/start.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Load .env into the environment (skip comments and blank lines) +if [ -f .env ]; then + while IFS= read -r line; do + line="${line%%#*}" # strip inline comments + line="$(echo "$line" | xargs)" # trim whitespace + [ -z "$line" ] && continue + case "$line" in *=*) ;; *) continue ;; esac + key="${line%%=*}" + val="${line#*=}" + val="${val%\"}" + val="${val#\"}" + export "$key=$val" + done < .env +fi + +if [ -z "${MIMO_API_KEY:-}" ]; then + echo "Error: MIMO_API_KEY is not set. Copy .env.example to .env and add your Xiaomi MiMo API key." >&2 + exit 1 +fi + +HOST="${HOST:-127.0.0.1}" +PORT="${PORT:-8787}" + +# Prefer python3, fall back to python +if command -v python3 >/dev/null 2>&1; then + PYTHON=python3 +elif command -v python >/dev/null 2>&1; then + PYTHON=python +else + echo "Error: Python 3.11 or newer was not found. Install Python, then run this script again." >&2 + exit 1 +fi + +exec "$PYTHON" -m uvicorn mimo_gateway:app --host "$HOST" --port "$PORT"