@magpie-community/opencode-mimo-auth 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +51 -0
  2. package/index.mjs +381 -0
  3. package/package.json +11 -0
package/README.md ADDED
@@ -0,0 +1,51 @@
1
+ # @magpie-community/opencode-mimo-auth
2
+
3
+ Signs in to **Xiaomi MiMo** with a Xiaomi account, the way the MiMo desktop
4
+ app does, and makes its model requests. Provider id: `mimo-app`.
5
+
6
+ ## Sign-in
7
+
8
+ One way, **Xiaomi account**. The plugin opens Xiaomi's sign-in page
9
+ (account.xiaomi.com's long-poll ticket). Sign in there, or scan its QR code
10
+ with the Xiaomi phone app. The plugin waits on the ticket until Xiaomi says
11
+ the account is in, so there's nothing to paste back.
12
+
13
+ Xiaomi's passToken then signs the account on at the MiMo server. The server
14
+ sends the browser through account.xiaomi.com and back through its `/sts`,
15
+ which sets the session cookies (`serviceToken`, `userId`, `cUserId`). An
16
+ account from Russia or India is moved to that region's server
17
+ (`mimo-server-ru` / `mimo-server-in`). Everyone else stays on
18
+ `mimo-server-sgp`.
19
+
20
+ ## Requests
21
+
22
+ Chat completions go to `https://mimo-server-<region>.xiaomimimo.com/api/route/chat/completions`
23
+ (`@ai-sdk/openai-compatible`). Each request carries:
24
+
25
+ - the session cookies, with no `Authorization` header
26
+ - `X-Mimo-Source: mimocode-cli-free`
27
+ - `X-Client-Version: 26.929.292248`
28
+ - the app's User-Agent
29
+
30
+ `mimo-auto`, the app's default model, is requested as `mimo-pro`.
31
+
32
+ The session is renewed with the passToken a day after it was issued. It is
33
+ also renewed once when the server turns a request away: a 401, or a Xiaomi
34
+ sign-in page where an answer was expected. If the passToken no longer works,
35
+ sign in again.
36
+
37
+ ## Where the sign-in is kept
38
+
39
+ In OpenCode's `auth.json`, or magpie's `plugin-auth.json`, as an OAuth sign-in:
40
+
41
+ - `refresh`: the Xiaomi account as JSON (`userId`, `cUserId`, `passToken`, `deviceId`, `region`, `base`)
42
+ - `access`: the session cookies as JSON
43
+ - `expires`: when the session is renewed
44
+ - `accountId`: the Xiaomi user id
45
+
46
+ ## Models
47
+
48
+ | Model | Name | Context | Output | Input |
49
+ |---|---|---|---|---|
50
+ | `mimo-pro` | MiMo Pro | 1,000,000 | 128,000 | text, image |
51
+ | `mimo-flash` | MiMo Flash | 1,000,000 | 128,000 | text, image |
package/index.mjs ADDED
@@ -0,0 +1,381 @@
1
+ // Xiaomi MiMo, as the MiMo desktop app signs in to it: a Xiaomi account
2
+ // signed in on account.xiaomi.com's long-poll page (a QR code for the
3
+ // Xiaomi phone app, or the password), whose passToken signs it on at the
4
+ // MiMo server, which answers with session cookies. Model requests are
5
+ // chat completions at the server's /route, carrying those cookies.
6
+ import { randomBytes } from "node:crypto"
7
+
8
+ const ID = "mimo-app"
9
+ const HOSTS = {
10
+ SGP: "https://mimo-server-sgp.xiaomimimo.com/api",
11
+ RU: "https://mimo-server-ru.xiaomimimo.com/api",
12
+ IN: "https://mimo-server-in.xiaomimimo.com/api",
13
+ }
14
+ const REGION = "SGP"
15
+ const ACCOUNT = "https://account.xiaomi.com"
16
+ const APP_VERSION = "26.929.292248"
17
+ const SOURCE = "mimocode-cli-free"
18
+ const UA = "miNative PC/Normal Windows_NT/10.0.26100 SDKV/1.0.0 DEVT/PC DEVS/Windows APP/miaccount_desktop APPV/0.1.0"
19
+ const RENEW_AFTER = 24 * 60 * 60 * 1000 // the app signs on again after a day
20
+ const SIGN_IN_LIFE = 10 * 60 * 1000
21
+
22
+ const MODEL = { release_date: "2026-07-01", attachment: true, tool_call: true, limit: { context: 1_000_000, output: 128_000 }, modalities: { input: ["text", "image"], output: ["text"] } }
23
+ const MODELS = {
24
+ "mimo-pro": { name: "MiMo Pro", ...MODEL },
25
+ "mimo-flash": { name: "MiMo Flash", ...MODEL },
26
+ }
27
+
28
+ const baseOf = (region) => HOSTS[String(region ?? "").trim().toUpperCase()] ?? ""
29
+ const deviceId = () => "pc_" + randomBytes(16).toString("hex")
30
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
31
+
32
+ // the account server's JSON comes after a "&&&START&&&" guard
33
+ function accountJSON(text) {
34
+ try {
35
+ return JSON.parse(text.trim().replace(/^&&&START&&&/, ""))
36
+ } catch {
37
+ return null
38
+ }
39
+ }
40
+
41
+ // ---- a cookie jar, enough for the sign-on's redirects -----------------------
42
+
43
+ class Jar {
44
+ constructor() {
45
+ this.cookies = []
46
+ }
47
+ put(c) {
48
+ this.cookies = this.cookies.filter((o) => !(o.name === c.name && o.domain === c.domain && o.path === c.path))
49
+ if (!c.gone) this.cookies.push(c)
50
+ }
51
+ seed(url, name, value) {
52
+ this.put({ name, value, domain: new URL(url).hostname.toLowerCase(), hostOnly: false, path: "/", secure: false })
53
+ }
54
+ take(res, url) {
55
+ const u = new URL(url)
56
+ const lines = typeof res.headers.getSetCookie === "function" ? res.headers.getSetCookie() : []
57
+ for (const line of lines) {
58
+ const [pair, ...attrs] = line.split(";")
59
+ const eq = pair.indexOf("=")
60
+ if (eq <= 0) continue
61
+ const c = { name: pair.slice(0, eq).trim(), value: pair.slice(eq + 1).trim(), domain: u.hostname.toLowerCase(), hostOnly: true, secure: false }
62
+ const dir = u.pathname.lastIndexOf("/")
63
+ c.path = dir > 0 ? u.pathname.slice(0, dir) : "/"
64
+ for (const a of attrs) {
65
+ const i = a.indexOf("=")
66
+ const k = (i < 0 ? a : a.slice(0, i)).trim().toLowerCase()
67
+ const v = i < 0 ? "" : a.slice(i + 1).trim()
68
+ if (k === "domain" && v) {
69
+ const d = v.replace(/^\./, "").toLowerCase()
70
+ if (c.domain === d || c.domain.endsWith("." + d)) Object.assign(c, { domain: d, hostOnly: false })
71
+ } else if (k === "path" && v.startsWith("/")) c.path = v
72
+ else if (k === "secure") c.secure = true
73
+ else if (k === "max-age" && Number(v) <= 0) c.gone = true
74
+ else if (k === "expires" && Date.parse(v) <= Date.now()) c.gone = true
75
+ }
76
+ this.put(c)
77
+ }
78
+ }
79
+ for(url) {
80
+ const u = new URL(url)
81
+ const host = u.hostname.toLowerCase()
82
+ return this.cookies.filter((c) => {
83
+ if (c.secure && u.protocol !== "https:") return false
84
+ if (c.hostOnly ? host !== c.domain : host !== c.domain && !host.endsWith("." + c.domain)) return false
85
+ return u.pathname === c.path || u.pathname.startsWith(c.path.endsWith("/") ? c.path : c.path + "/") || c.path === "/"
86
+ })
87
+ }
88
+ header(url) {
89
+ return this.for(url).map((c) => `${c.name}=${c.value}`).join("; ")
90
+ }
91
+ }
92
+
93
+ // ---- the session ------------------------------------------------------------
94
+
95
+ class Lapsed extends Error {}
96
+
97
+ const appHeaders = () => ({ "User-Agent": UA, "X-Client-Version": APP_VERSION })
98
+
99
+ // session signs the account on at a MiMo server as the app's window does:
100
+ // the server's /user/xiaomi/me, asked with no session, sends the browser to
101
+ // account.xiaomi.com's serviceLogin, which (holding the passToken) sends it
102
+ // back through the server's /sts, where the session cookies are set, to
103
+ // /user/xiaomi/me again. The server's cookies are the session.
104
+ async function session(creds, base) {
105
+ const jar = new Jar()
106
+ for (const [k, v] of [["userId", creds.userId], ["passToken", creds.passToken], ["cUserId", creds.cUserId],
107
+ ["deviceId", creds.deviceId], ["pass_ua", "pc"], ["uLocale", "zh_CN"]]) {
108
+ if (v) jar.seed(ACCOUNT, k, v)
109
+ }
110
+ const bu = new URL(base)
111
+ const signal = AbortSignal.timeout(30_000)
112
+ let url = base.replace(/\/+$/, "") + "/user/xiaomi/me"
113
+ let res
114
+ for (let hop = 0; ; hop++) {
115
+ if (hop > 10) throw new Error("Xiaomi MiMo sign-in: too many redirects")
116
+ const headers = appHeaders()
117
+ const cookie = jar.header(url)
118
+ if (cookie) headers.Cookie = cookie
119
+ res = await fetch(url, { headers, redirect: "manual", signal })
120
+ jar.take(res, url)
121
+ const loc = res.headers.get("location")
122
+ if (res.status < 300 || res.status >= 400 || !loc) break
123
+ await res.arrayBuffer().catch(() => {})
124
+ const next = new URL(loc, url)
125
+ // the server's followup is written http://; its session cookies are
126
+ // only sent back over https
127
+ if (next.protocol === "http:" && bu.protocol === "https:" && next.host.toLowerCase() === bu.host.toLowerCase()) next.protocol = "https:"
128
+ url = next.href
129
+ }
130
+ const text = await res.text()
131
+ let me
132
+ try {
133
+ me = JSON.parse(text)
134
+ } catch {
135
+ // Xiaomi's sign-in page, not the server's answer: the passToken no
136
+ // longer signs the account on
137
+ throw new Lapsed("Xiaomi MiMo: the Xiaomi sign-in has lapsed; sign in again")
138
+ }
139
+ if (res.status === 403 || me?.code === 403 || me?.code === 46109) {
140
+ throw new Error("Xiaomi MiMo doesn't serve this Xiaomi account (its region isn't served here)")
141
+ }
142
+ if (me?.code !== 0 || !String(me?.data?.userId ?? "")) throw new Lapsed("Xiaomi MiMo: the Xiaomi sign-in has lapsed; sign in again")
143
+ const cookies = {}
144
+ for (const c of jar.for(bu.href)) cookies[c.name] = c.value
145
+ if (!Object.keys(cookies).length) throw new Error("Xiaomi MiMo sign-in: the server set no session")
146
+ return { cookies, me: me.data }
147
+ }
148
+
149
+ // cookieHeader is a session as a Cookie header, in a steady order
150
+ function cookieHeader(cookies) {
151
+ const first = ["serviceToken", "userId", "cUserId"]
152
+ const parts = first.filter((k) => k in cookies).map((k) => `${k}=${cookies[k]}`)
153
+ for (const [k, v] of Object.entries(cookies)) if (!first.includes(k)) parts.push(`${k}=${v}`)
154
+ return parts.join("; ")
155
+ }
156
+
157
+ // The sign-in is kept as OpenCode keeps an OAuth one: refresh is the
158
+ // Xiaomi account (what signs it on again), access the session's cookies,
159
+ // expires when the app would sign on again.
160
+ function toAuth(creds, cookies, issued) {
161
+ return {
162
+ type: "oauth",
163
+ refresh: JSON.stringify(creds),
164
+ access: JSON.stringify(cookies),
165
+ expires: issued + RENEW_AFTER,
166
+ accountId: creds.userId,
167
+ }
168
+ }
169
+
170
+ function fromAuth(auth) {
171
+ if (auth?.type !== "oauth") return null
172
+ try {
173
+ const creds = JSON.parse(auth.refresh)
174
+ let cookies = {}
175
+ try {
176
+ cookies = JSON.parse(auth.access || "{}") ?? {}
177
+ } catch {}
178
+ if (!creds?.passToken || !creds?.userId) return null
179
+ creds.base ||= baseOf(creds.region) || HOSTS[REGION]
180
+ return { creds, cookies, expires: Number(auth.expires) || 0 }
181
+ } catch {
182
+ return null
183
+ }
184
+ }
185
+
186
+ // ---- the sign-in ------------------------------------------------------------
187
+
188
+ // serviceOf is the sid and callback the MiMo server sends a browser with
189
+ // no session to account.xiaomi.com with
190
+ async function serviceOf(base) {
191
+ let sid = "mimosgp"
192
+ let callback = base.replace(/\/+$/, "") + "/sts"
193
+ try {
194
+ const res = await fetch(base.replace(/\/+$/, "") + "/user/xiaomi/me", { headers: appHeaders(), redirect: "manual", signal: AbortSignal.timeout(15_000) })
195
+ await res.arrayBuffer().catch(() => {})
196
+ const loc = res.headers.get("location")
197
+ if (loc) {
198
+ const q = new URL(loc, base).searchParams
199
+ sid = q.get("sid") || sid
200
+ callback = q.get("callback") || callback
201
+ }
202
+ } catch {}
203
+ return { sid, callback }
204
+ }
205
+
206
+ async function accountGet(url, device, signal) {
207
+ const res = await fetch(url, { headers: { "User-Agent": UA, Cookie: `deviceId=${device}; pass_ua=pc; uLocale=zh_CN` }, signal })
208
+ return { status: res.status, text: await res.text() }
209
+ }
210
+
211
+ async function startSignIn() {
212
+ const base = HOSTS[REGION]
213
+ const device = deviceId()
214
+ const { sid, callback } = await serviceOf(base)
215
+ const q = new URLSearchParams({
216
+ _group: "DEFAULT",
217
+ _qrsize: "240",
218
+ qs: "%3Fsid%3D" + encodeURIComponent(sid) + "%26_json%3Dtrue",
219
+ callback,
220
+ _hasLogo: "false",
221
+ sid,
222
+ serviceParam: "",
223
+ _locale: "en_US",
224
+ })
225
+ const { status, text } = await accountGet(`${ACCOUNT}/longPolling/loginUrl?${q}`, device, AbortSignal.timeout(20_000))
226
+ const lp = accountJSON(text)
227
+ if (!lp || lp.code !== 0 || !lp.loginUrl || !lp.lp) {
228
+ throw new Error(`Xiaomi MiMo sign-in: account.xiaomi.com answered ${status} ${lp?.desc || text.trim().slice(0, 200)}`)
229
+ }
230
+ const life = lp.timeout > 0 ? Math.min(lp.timeout * 1000, SIGN_IN_LIFE) : SIGN_IN_LIFE
231
+ return { url: lp.loginUrl, lp: lp.lp, device, until: Date.now() + life }
232
+ }
233
+
234
+ // waitPoll asks the ticket's long poll until Xiaomi says the account is in
235
+ async function waitPoll(t) {
236
+ while (Date.now() < t.until) {
237
+ try {
238
+ const left = t.until - Date.now()
239
+ const { status, text } = await accountGet(t.lp, t.device, AbortSignal.timeout(Math.min(70_000, Math.max(left, 1))))
240
+ const p = status === 200 ? accountJSON(text) : null
241
+ if (p?.passToken && String(p.userId ?? "")) return p
242
+ } catch {}
243
+ await sleep(1000)
244
+ }
245
+ throw new Error("the Xiaomi sign-in page expired before the sign-in finished; start it again")
246
+ }
247
+
248
+ async function signedInWith(p, device) {
249
+ let creds = { userId: String(p.userId), cUserId: p.cUserId ?? "", passToken: p.passToken, deviceId: device, region: REGION, base: HOSTS[REGION] }
250
+ let s
251
+ try {
252
+ s = await session(creds, creds.base)
253
+ } catch (e) {
254
+ if (e instanceof Lapsed) throw new Error("Xiaomi signed the account in, but the MiMo server didn't take it; try again")
255
+ throw e
256
+ }
257
+ // an account of another region is served by that region's server
258
+ const r = String(s.me?.region ?? "").trim().toUpperCase()
259
+ const nb = baseOf(r)
260
+ if (r && r !== creds.region && nb && nb !== creds.base) {
261
+ try {
262
+ s = await session(creds, nb)
263
+ creds = { ...creds, region: r, base: nb }
264
+ } catch {}
265
+ }
266
+ return { creds: { ...creds, userId: String(s.me?.userId ?? "") || creds.userId }, cookies: s.cookies }
267
+ }
268
+
269
+ // ---- the plugin -------------------------------------------------------------
270
+
271
+ export const MimoAuthPlugin = async ({ client }) => {
272
+ let renewing = null
273
+
274
+ const save = async (auth) => {
275
+ try {
276
+ await client?.auth?.set?.({ path: { id: ID }, body: auth })
277
+ } catch {}
278
+ }
279
+
280
+ // fresh is the account with a live session, signed on again when it is
281
+ // older than the app lets one get, or when force is set (the server
282
+ // turned the last one away)
283
+ const fresh = async (getAuth, force) => {
284
+ const a = fromAuth(await getAuth())
285
+ if (!a) throw new Error("Xiaomi MiMo: not signed in")
286
+ if (!force && Object.keys(a.cookies).length && Date.now() < a.expires) return a
287
+ if (!renewing) {
288
+ renewing = (async () => {
289
+ try {
290
+ const s = await session(a.creds, a.creds.base)
291
+ const issued = Date.now()
292
+ await save(toAuth(a.creds, s.cookies, issued))
293
+ return { creds: a.creds, cookies: s.cookies, expires: issued + RENEW_AFTER }
294
+ } catch (e) {
295
+ // a hiccup: the session in hand may still do
296
+ if (!(e instanceof Lapsed) && !force && Object.keys(a.cookies).length) return a
297
+ throw e
298
+ }
299
+ })().finally(() => {
300
+ renewing = null
301
+ })
302
+ }
303
+ return renewing
304
+ }
305
+
306
+ return {
307
+ config: async (config) => {
308
+ config.provider ??= {}
309
+ const was = config.provider[ID] ?? {}
310
+ config.provider[ID] = {
311
+ name: "Xiaomi MiMo",
312
+ npm: "@ai-sdk/openai-compatible",
313
+ api: HOSTS[REGION] + "/route",
314
+ ...was,
315
+ models: { ...MODELS, ...(was.models ?? {}) },
316
+ }
317
+ },
318
+ auth: {
319
+ provider: ID,
320
+ loader: async (getAuth) => {
321
+ const a = fromAuth(await getAuth())
322
+ if (!a) return {}
323
+ return {
324
+ baseURL: a.creds.base.replace(/\/+$/, "") + "/route",
325
+ apiKey: "mimo", // the engine's placeholder; the app strips it
326
+ async fetch(input, init = {}) {
327
+ const req = input instanceof Request ? input : null
328
+ const url = req ? req.url : String(input)
329
+ let body = init.body ?? (req ? await req.text() : undefined)
330
+ // mimo-auto, the app's default, is asked as the model it stands for
331
+ if (typeof body === "string" && body.includes('"mimo-auto"')) {
332
+ try {
333
+ const j = JSON.parse(body)
334
+ if (j?.model === "mimo-auto") body = JSON.stringify({ ...j, model: "mimo-pro" })
335
+ } catch {}
336
+ }
337
+ const send = async (force) => {
338
+ const s = await fresh(getAuth, force)
339
+ const headers = new Headers(init.headers ?? req?.headers)
340
+ headers.delete("authorization")
341
+ headers.set("Cookie", cookieHeader(s.cookies))
342
+ headers.set("X-Mimo-Source", SOURCE)
343
+ headers.set("User-Agent", UA)
344
+ headers.set("X-Client-Version", APP_VERSION)
345
+ return fetch(url, { ...init, method: init.method ?? req?.method ?? "POST", headers, body })
346
+ }
347
+ const res = await send(false)
348
+ // a session gone stale, or a redirect to Xiaomi's sign-in page
349
+ const stale = res.status === 401 || (res.status === 200 && /text\/html/i.test(res.headers.get("content-type") ?? ""))
350
+ if (!stale || (body != null && typeof body !== "string")) return res
351
+ await res.arrayBuffer().catch(() => {})
352
+ return send(true)
353
+ },
354
+ }
355
+ },
356
+ methods: [
357
+ {
358
+ type: "oauth",
359
+ label: "Xiaomi account",
360
+ authorize: async () => {
361
+ const t = await startSignIn()
362
+ return {
363
+ url: t.url,
364
+ instructions: "Sign in to your Xiaomi account on the page that opens (or scan its QR code with the Xiaomi app). The sign-in finishes here by itself.",
365
+ method: "auto",
366
+ callback: async () => {
367
+ try {
368
+ const p = await waitPoll(t)
369
+ const { creds, cookies } = await signedInWith(p, t.device)
370
+ return { ...toAuth(creds, cookies, Date.now()), type: "success" }
371
+ } catch {
372
+ return { type: "failed" }
373
+ }
374
+ },
375
+ }
376
+ },
377
+ },
378
+ ],
379
+ },
380
+ }
381
+ }
package/package.json ADDED
@@ -0,0 +1,11 @@
1
+ {
2
+ "name": "@magpie-community/opencode-mimo-auth",
3
+ "version": "0.1.0",
4
+ "description": "OpenCode provider plugin for Xiaomi MiMo: the MiMo app's Xiaomi-account sign-in, MiMo Pro and MiMo Flash",
5
+ "type": "module",
6
+ "main": "./index.mjs",
7
+ "exports": "./index.mjs",
8
+ "files": ["index.mjs", "README.md"],
9
+ "keywords": ["opencode", "opencode-plugin", "xiaomi", "mimo"],
10
+ "license": "MIT"
11
+ }