Turn the model quota of your Loomy (iFlytek / 科大讯飞 desktop AI assistant) account into a plain, self-hosted OpenAI- and Anthropic-compatible API — with a multi-account pool for rotation, failover and automatic session renewal.
- Zero dependencies — pure Python standard library (3.9+). No
pip installneeded beyond the project itself. - No desktop client required — the gateway logs in server-side with a phone number + password and keeps the 14-day session renewed on its own.
- Multi-account — add as many accounts as you like; requests are routed by balance / round-robin / LRU, dead sessions are cooled down and retried on the next account automatically.
- Both API dialects —
/v1/chat/completionsfor OpenAI clients and/v1/messagesfor Claude Code & friends (streaming, tools and reasoning all translated).
中文说明 / Chinese README · Reversed protocol reference
| OpenAI API | POST /v1/chat/completions (stream + non-stream), GET /v1/models, POST /v1/embeddings, POST /v1/images/generations |
| Anthropic API | POST /v1/messages (stream + non-stream), thinking blocks, tool use / tool results |
| Web panel | http://127.0.0.1:17890/panel — quota per account, add / remove / disable, force renew, rebind device identity, live log tail |
| Accounts | pool with balance · round_robin · lru strategies, per-account cooldown, automatic retry on another account, quota tracking |
| Identity | every account gets its own bound device identity (devid + promotions device id) generated at first login and reused on every renewal |
| Sessions | password login, SMS login, desktop-client session import, automatic renewal before the 14-day expiry |
| Ops | /health, /v1/points, /v1/admin/accounts, request log with model / tokens / points_consumed / latency |
| Security | optional API-key gate for the gateway itself; secrets stay out of git |
- Python 3.9+ (tested on 3.9 / 3.11 / 3.13, Windows and Linux)
- A Loomy account (phone number + a password set in the iFlytek account centre)
- Outbound HTTPS to
account.xfinfr.comandloomyad.xunfei.cn
git clone https://github.com/Patrick130306/loomy2api.git
cd loomy2api
cp config.example.json config.json # optional, defaults are sane
cp accounts.example.json accounts.json # put your accounts here
# add an account and log it in (writes the session into accounts.json)
python -m loomy2api add main --phone 13800000000 --password 'your-password'
python -m loomy2api accounts # check sessions + quota
python -m loomy2api serve # http://127.0.0.1:17890Point any OpenAI-compatible client at it:
curl http://127.0.0.1:17890/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{"model": "deepseek-v4-flash-0731",
"messages": [{"role": "user", "content": "hello"}]}'from openai import OpenAI
client = OpenAI(base_url="http://127.0.0.1:17890/v1", api_key="not-needed")
print(client.chat.completions.create(
model="deepseek-v4-flash-0731", # see GET /v1/models
messages=[{"role": "user", "content": "hi"}],
).choices[0].message.content)Claude Code / any Anthropic client:
export ANTHROPIC_BASE_URL=http://127.0.0.1:17890
export ANTHROPIC_API_KEY=not-neededThe base URL for the Anthropic dialect is the bare host — the gateway serves
/v1/messagesat the root, just like the real API.
accounts.json (gitignored) looks like this:
{
"accounts": [
{ "name": "main", "loginid": "13800000000", "password": "…", "enabled": true },
{ "name": "backup", "loginid": "13900000000", "password": "…", "enabled": true },
{ "name": "shared", "session": "<32-char session>", "userid": "…", "expireAt": 0 }
]
}- Password accounts renew themselves. A background thread checks every
quota_refresh_minutesand re-logs in whenever a session has less thansession_renew_before_days(default 3) left — a pool runs unattended. - Session-only accounts work too (handy for sharing accounts with someone
else), but they expire after 14 days and need
loomy2api loginagain. - Client import — if the Loomy desktop app is installed and logged in, its
session is picked up automatically as an extra account
(
sessions_from_client: true).
Routing:
| strategy | behaviour |
|---|---|
balance (default) |
use the account with the most available points |
round_robin |
cycle in order |
lru |
least recently used first |
On 401/403 the session is dropped, on 402/quota exhaustion the account is
cooled down for cooldown_seconds, and the request is retried on the next
account (max_retries). Everything is visible in GET /v1/admin/accounts.
Open http://127.0.0.1:17890/panel (the bare host also serves it):
- every account with its points (
available = balance + daily grant), session days left, request/points counters, cooldown state and last error - add an account (phone + password → logged in immediately, or paste a session)
- force renew, disable/enable, delete
- rebind device identity — see below
- live tail of the gateway log, auto-refresh
If api_keys is set, the panel's JSON API requires it (the page itself stays
public so you can enter the key); the key is kept in localStorage.
At first login each account is given its own device identity and that
identity is bound to the account in accounts.json:
"identity": {
"devid": "web-0ca44246df704952",
"ua": "Loomy|Desktop|Electron|macOS",
"modelid": "Web", "version": "1.0.0",
"campus_device_id": "loomy-campus-0cbce23d-6ec2-4ee5-9591-f43408d23896",
"created_at": 1790471588
}Every later login and every request reuses it, so one account always looks like
one consistent device instead of every account announcing devid: web.
Be clear about what this is and is not: the protocol only carries four
device-ish fields (devid, ua, modelid/version plus a per-request random
traceid), and the promotions device id is only sent with
/points/activation and /points/first-login bodies — never with chat
requests. Binding a distinct identity keeps accounts separated in those fields;
it does not change your network origin (IP), which is what most risk
control actually keys on. Treat it as account isolation, not as a ban
guarantee.
identity_mode in config.json:
| value | behaviour |
|---|---|
per_account (default) |
distinct devid (web-<16 hex>) and campus device id per account; ua/modelid/version stay the real client's values |
client |
mirror the shipped client byte-for-byte (devid: web) |
Rebind from the panel ("换标识") or with
POST /api/panel/accounts/identity {"name": "...", "regenerate": true}, which
generates a fresh identity and logs the account in again under it.
Whatever the upstream returns from /models is exposed as-is — model ids are
used verbatim, there is no alias/mapping table (only a provider/ prefix is
stripped). Verified behaviour to be aware of: the upstream silently falls back
to deepseek-v4-flash-0731 when it does not recognise an id — gpt-4o-mini
answers HTTP 200 as deepseek-v4-flash-0731 — so always read the model field
of the response. Typical catalogue (the multiplier is the points cost factor —
spark-x at x0.1 is by far the cheapest):
| id | multiplier | notes |
|---|---|---|
spark-x |
x0.1 | Spark X2.5, text + reasoning |
GLM-5.3-Flash |
x0.8 | vision / video / tools |
qwen3.8-flash |
x0.8 | vision / video / tools |
deepseek-v4-flash-0731 |
x3.0 | 1M context, tools |
mimo-v2.5 |
x3.3 | audio / image / video |
MiniMax-M3 |
x4.0 | vision / video |
Kimi-k2.6 |
x6.5 | vision / video |
qwen-3.8-max |
x12.0 | strongest, priciest |
Hy-Image-3.5-preview, doubao-seedream-5-lite, qwen-image-3.0-pro |
— | image generation |
config.json (all keys optional) — see config.example.json for comments.
Environment variables override it:
| env | meaning |
|---|---|
LOOMY_HOST / LOOMY_PORT |
listen address |
LOOMY_UPSTREAM |
model gateway base URL |
LOOMY_ACCOUNT_BASE |
account service base URL |
LOOMY_AK_ID / LOOMY_AK_SECRET |
override the client-shipped signing keys |
LOOMY_API_KEYS |
comma-separated gateway keys ([] = no auth) |
LOOMY_DEFAULT_MODEL |
fallback model |
LOOMY_ACCOUNTS_FILE / LOOMY_LOG_DIR |
state locations |
LOOMY_PROXY |
e.g. http://127.0.0.1:7877 (default: direct) |
LOOMY_STRATEGY |
balance / round_robin / lru |
{ "api_keys": ["sk-local-whatever"] }Clients then send Authorization: Bearer sk-local-whatever or
x-api-key: sk-local-whatever. /health stays public for probes.
docker build -t loomy2api .
docker run -d --name loomy2api -p 17890:17890 -v $PWD/data:/data loomy2api
# put accounts.json in ./data (mounted as /data/accounts.json)or with Compose:
mkdir -p data && cp accounts.example.json data/accounts.json
# edit data/accounts.json, then:
docker compose up -d --build
docker compose logs -fThe desktop client has no "set password" screen — set it once in the iFlytek account centre (web or mobile), then this project can log in unattended forever. No password? Use the SMS path instead:
python -m loomy2api sms 13800000000 # sends a code
python -m loomy2api verify main 13800000000 <code> <msgid>git clone https://github.com/Patrick130306/loomy2api.git
cd loomy2api
cp config.example.json config.json # optional — defaults work
cp accounts.example.json accounts.json # your accounts go here
chmod 600 accounts.json config.json # it holds passwords, keep it private
python -m loomy2api add main --phone 13800000000 --password 'your-password'
python -m loomy2api accounts # verify: quota + session days left
python -m loomy2api serve # http://127.0.0.1:17890- Python 3.9+, no dependencies (pure standard library).
--port 9000overrides the port;-c /path/config.jsonpicks another config.- Panel: http://127.0.0.1:17890/panel
# /etc/systemd/system/loomy2api.service
[Unit]
Description=loomy2api gateway
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=loomy
WorkingDirectory=/opt/loomy2api
ExecStart=/usr/bin/python3 -m loomy2api serve
Restart=always
RestartSec=5
UMask=0077 # accounts.json holds plaintext passwords
Environment=LOOMY_HOST=127.0.0.1
[Install]
WantedBy=multi-user.targetsudo systemctl daemon-reload
sudo systemctl enable --now loomy2api
journalctl -u loomy2api -f # follow the logTask Scheduler (built in, no extra software):
# start.cmd
@echo off
cd /d D:\loomy2api
python -m loomy2api serve >> logs\console.log 2>&1
schtasks /create /tn loomy2api /sc onstart /rl highest /tr "D:\loomy2api\start.cmd" /f
schtasks /run /tn loomy2apior NSSM (installs it as a real Windows service):
nssm install loomy2api "C:\Python312\python.exe" "-m loomy2api serve"
nssm set loomy2api AppDirectory D:\loomy2api
nssm set loomy2api AppStdout D:\loomy2api\logs\service.log
nssm start loomy2apimkdir -p data && cp accounts.example.json data/accounts.json # then edit it
docker compose up -d --build
docker compose logs -fThe image is python:3.12-slim plus your source — nothing else to install.
State (accounts.json, logs/) lives in ./data, so upgrades are
git pull && docker compose up -d --build.
By default the gateway binds 127.0.0.1 — only your machine can reach it.
To share it on your LAN or the internet:
-
Set an API key first. Without
api_keys, anyone who can reach the port spends your account's points:{ "api_keys": ["sk-something-long-and-random"] } -
Then
LOOMY_HOST=0.0.0.0(or"host": "0.0.0.0"inconfig.json). -
Put a reverse proxy with TLS in front if it's public. Caddy example:
api.example.com { reverse_proxy 127.0.0.1:17890 }
Clients then use https://api.example.com/v1 as the base URL.
Prefer a VPN/Tailscale/WireGuard over public exposure. This endpoint has no rate limiting of its own, and the upstream account is yours to lose.
| client | setting |
|---|---|
| OpenAI SDK / LangChain | base_url="http://127.0.0.1:17890/v1", any api_key (or the key you set) |
| Cherry Studio / LobeChat / NextChat / Open WebUI | provider "OpenAI compatible", base URL above |
| 沉浸式翻译 / bilingual reader | custom OpenAI endpoint, same base URL |
| Claude Code | ANTHROPIC_BASE_URL=http://127.0.0.1:17890 ANTHROPIC_API_KEY=<any> |
| curl | see the quick start above |
Model names: use the ids from /v1/models (deepseek-v4-flash-0731,
spark-x, Kimi-k2.6, …). The gateway does not remap anything; the upstream
falls back to its default model for an unknown id, so check the model field
in the response if something looks off.
git pull && sudo systemctl restart loomy2api # or: docker compose up -d --buildBack up accounts.json — it holds the accounts, their sessions and (if you
added them) the passwords, plus the bound device identity of each account.
config.json holds your settings. Both are gitignored.
curl -s http://127.0.0.1:17890/health | python -m json.tool # status + panel URL
python -m loomy2api accounts # quota per account
tail -f logs/gateway.log # model/tokens/points per callThe panel (http://127.0.0.1:17890/panel) shows the same live, with per-account quota and buttons for renew / rebind identity / disable / delete.
loomy2api serve start the gateway
loomy2api accounts pool status: quota, session days left, cooldown
loomy2api add <name> --phone … --password …
loomy2api remove <name>
loomy2api login [name …] log in / force-refresh sessions
loomy2api sms <phone> send an SMS code (SMS login path)
loomy2api verify <name> <phone> <code> <msgid>
loomy2api identity <name> [--rebind] show / rebind the device identity
loomy2api models list the upstream catalogue
loomy2api quota per-account points
loomy2api chat "prompt" one-shot request through a pooled account
The Loomy desktop client is an Electron app whose main-process sources ship
unencrypted in resources/app.asar.unpacked/electron/, and whose provider is
configured with useSessionAuth: true — i.e. it stores no API key and simply
sends the login session as the bearer token. The iFlytek account service is a
plain signed HTTP API (HMAC-SHA1), and its password login turns out to be fully
scriptable because the rcode handed out with the RSA key is a server nonce,
not a CAPTCHA.
So this project is: log in over the account service → hold the 14-day session → forward OpenAI/Anthropic requests to the model gateway with that session.
The complete write-up (endpoints, signing string, error fingerprints, quota ledger, client config encryption) is in docs/PROTOCOL.md.
python -m unittest discover -s tests -t . -vHermetic: a local fake upstream stands in for both the account service and the model gateway, so the suite never touches a real account and burns no points. CI runs it on Linux and Windows across Python 3.9/3.11/3.13.
| Symptom | Cause / fix |
|---|---|
账号池里没有可用账号 |
accounts.json empty, all accounts disabled, or all in cooldown |
... session 不可用且没有账号密码 |
add loginid + password, or run loomy2api sms + loomy2api verify |
getPuKey ... HMAC signature does not match |
your access_key_secret is wrong/truncated — leave it empty to use the built-in client constants |
| upstream hangs until timeout | something stripped the traceparent header |
402 / point exhaustion |
top the account up, or add another account to the pool |
| two instances fighting over one port (Windows) | Windows allows duplicate SO_REUSEADDR binds — check netstat -ano | findstr 17890 and kill the stale PID |
- Every request is billed to the account's points (
balance+ a daily grant), exactly like the official client. WatchGET /v1/points. - The upstream account system is real: don't hammer the login endpoint, and avoid scripts that retry logins aggressively.
- Use it on accounts you own. Sharing one account across many people increases the chance of rate limiting or a ban.
This project is unaffiliated with iFlytek / 科大讯飞. It exists for
interoperability and personal use with your own account. The signing constants
in loomy2api/constants.py are the ones the official client ships in every
installation; they are used only to talk to the account service. Do not use
this project to abuse, resell or overload the upstream service.