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:
@@ -0,0 +1,7 @@
|
||||
.env
|
||||
.venv
|
||||
venv
|
||||
__pycache__
|
||||
*.py[cod]
|
||||
.git
|
||||
.agents
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
.env
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.venv/
|
||||
venv/
|
||||
+12
@@ -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"]
|
||||
@@ -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
@@ -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")),
|
||||
)
|
||||
@@ -0,0 +1,4 @@
|
||||
fastapi
|
||||
uvicorn
|
||||
httpx
|
||||
python-dotenv
|
||||
@@ -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."
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user