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 <noreply@anthropic.com>
This commit is contained in:
Ilias
2026-07-12 16:39:19 -07:00
co-authored by Claude Sonnet 4.5
commit 733beecaf6
9 changed files with 435 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
.env
.venv
venv
__pycache__
*.py[cod]
.git
.agents
+4
View File
@@ -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
+5
View File
@@ -0,0 +1,5 @@
.env
__pycache__/
*.py[cod]
.venv/
venv/
+12
View File
@@ -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"]
+124
View File
@@ -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.
+204
View File
@@ -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")),
)
+4
View File
@@ -0,0 +1,4 @@
fastapi
uvicorn
httpx
python-dotenv
+38
View File
@@ -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."
+37
View File
@@ -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"