@magpie-community/opencode-mimo-auth 0.1.0 → 0.1.1

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 (2) hide show
  1. package/index.mjs +128 -0
  2. package/package.json +1 -1
package/index.mjs CHANGED
@@ -4,6 +4,7 @@
4
4
  // MiMo server, which answers with session cookies. Model requests are
5
5
  // chat completions at the server's /route, carrying those cookies.
6
6
  import { randomBytes } from "node:crypto"
7
+ import { STATUS_CODES } from "node:http"
7
8
 
8
9
  const ID = "mimo-app"
9
10
  const HOSTS = {
@@ -266,6 +267,60 @@ async function signedInWith(p, device) {
266
267
  return { creds: { ...creds, userId: String(s.me?.userId ?? "") || creds.userId }, cookies: s.cookies }
267
268
  }
268
269
 
270
+ // ---- usage ------------------------------------------------------------------
271
+ //
272
+ // What the account says of its allowance, as magpie's built-in MiMo
273
+ // account shows it (internal/provider/mimo_usage.go), from the pages the
274
+ // app's "Usage & billing" reads: /user/usage, how much of the week's
275
+ // allowance is left (percent remaining, and the day it resets; no reset
276
+ // date is no plan), and /user/xiaomi/subscription/self, the plan in force
277
+ // and when it ends. An account with no plan is on MiMo's free offer.
278
+
279
+ // the app's names for its plans' tiers
280
+ const TIERS = { 1: "Starter", 2: "Plus", 3: "Pro", 4: "Ultra" }
281
+
282
+ // SignInGone is an account Xiaomi no longer signs in: it must be signed in
283
+ // again.
284
+ class SignInGone extends Error {}
285
+
286
+ // statusLine is a status as Go's HTTP client names it, "502 Bad Gateway".
287
+ const statusLine = (status) => `${status} ${STATUS_CODES[status] ?? ""}`.trim()
288
+
289
+ // vendorError is the message in an error body as magpie reads it: its
290
+ // {error: {message}} or {error}, {message}, {msg}, {detail}, else the body
291
+ // cut short after the status.
292
+ function vendorError(text, fallback) {
293
+ let v
294
+ try {
295
+ v = JSON.parse(text)
296
+ } catch {}
297
+ if (v && typeof v === "object" && !Array.isArray(v)) {
298
+ const err = v.detail !== undefined && v.detail !== null ? v.detail : v.error
299
+ if (Array.isArray(v.errors) && typeof v.errors[0]?.message === "string" && v.errors[0].message) return v.errors[0].message
300
+ if (err && typeof err === "object" && typeof err.message === "string" && err.message) return err.message
301
+ if (typeof err === "string" && err) return err
302
+ const m = (typeof v.message === "string" && v.message) || (typeof v.msg === "string" && v.msg)
303
+ if (m) return m
304
+ }
305
+ const s = String(text ?? "").split(/\s+/).filter(Boolean).join(" ")
306
+ if (!s || s.startsWith("<")) return fallback
307
+ const r = [...s]
308
+ return fallback + ": " + (r.length > 300 ? r.slice(0, 300).join("") + "…" : s)
309
+ }
310
+
311
+ // serverTime reads one of the server's times: "2026-10-01T00:00:00", or a
312
+ // day alone. They carry no zone, and the app shows them as they come,
313
+ // which is Beijing's (and Singapore's) time.
314
+ function serverTime(v) {
315
+ const s = String(v ?? "").trim()
316
+ let iso = ""
317
+ if (/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d+)?(Z|[+-]\d\d:\d\d)$/i.test(s)) iso = s
318
+ else if (/^\d{4}-\d\d-\d\d[T ]\d\d:\d\d:\d\d(\.\d+)?$/.test(s)) iso = s.replace(" ", "T") + "+08:00"
319
+ else if (/^\d{4}-\d\d-\d\d$/.test(s)) iso = s + "T00:00:00+08:00"
320
+ const t = iso ? Date.parse(iso) : NaN
321
+ return isNaN(t) ? undefined : new Date(t).toISOString()
322
+ }
323
+
269
324
  // ---- the plugin -------------------------------------------------------------
270
325
 
271
326
  export const MimoAuthPlugin = async ({ client }) => {
@@ -303,6 +358,75 @@ export const MimoAuthPlugin = async ({ client }) => {
303
358
  return renewing
304
359
  }
305
360
 
361
+ // page asks the MiMo server for one of the account's pages, signing on
362
+ // again once when it is turned away, and gives its data.
363
+ const page = async (getAuth, path) => {
364
+ for (let t = 0; ; t++) {
365
+ let s
366
+ try {
367
+ s = await fresh(getAuth, t > 0)
368
+ } catch (e) {
369
+ if (e instanceof Lapsed) throw new SignInGone(`${fromAuth(await getAuth())?.creds.userId}: the Xiaomi MiMo sign-in has expired — sign in again`)
370
+ throw e
371
+ }
372
+ const res = await fetch(s.creds.base.replace(/\/+$/, "") + path, {
373
+ headers: { Cookie: cookieHeader(s.cookies), ...appHeaders() },
374
+ signal: AbortSignal.timeout(20_000),
375
+ })
376
+ const text = await res.text()
377
+ let env
378
+ try {
379
+ env = JSON.parse(text)
380
+ } catch {}
381
+ const bad = !env || typeof env !== "object" || Array.isArray(env) || (env.code != null && !Number.isInteger(env.code)) || (env.msg != null && typeof env.msg !== "string")
382
+ if (res.status === 401 || (res.status === 200 && bad)) {
383
+ // a session gone stale, or a redirect to Xiaomi's sign-in
384
+ if (t === 0) continue
385
+ throw new SignInGone(`${s.creds.userId}: the Xiaomi MiMo sign-in has expired — sign in again`)
386
+ }
387
+ if (res.status !== 200 || bad) throw new Error(`Xiaomi MiMo ${path}: ${vendorError(text, statusLine(res.status))}`)
388
+ if ((env.code ?? 0) !== 0) throw new Error(`Xiaomi MiMo ${path}: code ${env.code} ${env.msg ?? ""}`)
389
+ return env.data
390
+ }
391
+ }
392
+
393
+ // usage is the plan and the week's allowance, magpie's own hook
394
+ const usage = async (getAuth) => {
395
+ let self
396
+ try {
397
+ self = await page(getAuth, "/user/xiaomi/subscription/self")
398
+ } catch (e) {
399
+ return { error: e?.message ?? String(e) }
400
+ }
401
+ const c = self?.current
402
+ const out = { plan: "Free" }
403
+ if (c && typeof c === "object") {
404
+ const text = (v) => (typeof v === "string" ? v.trim() : "")
405
+ out.plan = text(c.title) || TIERS[c.planTier] || text(c.planCode) || "MiMo"
406
+ const until = serverTime(c.endTime)
407
+ if (until) out.until = until
408
+ if (c.renewalMode === "MONTHLY" || c.renewalMode === "YEARLY") out.renew = "auto"
409
+ else if (c.renewalMode === "ONE_TIME") out.renew = "off"
410
+ }
411
+ let use
412
+ try {
413
+ use = await page(getAuth, "/user/usage")
414
+ } catch (e) {
415
+ if (e instanceof SignInGone) out.error = e.message
416
+ // the plan alone, when the week's allowance can't be read
417
+ return out
418
+ }
419
+ const percent = use?.percent
420
+ const reset = use?.resetDate
421
+ // no plan: the free offer shows no allowance
422
+ if (typeof percent !== "number" || typeof reset !== "string") return out
423
+ const w = { name: "7 days", used: Math.max(0, Math.min(100, 100 - percent)), span: 7 * 24 * 3600 }
424
+ const at = serverTime(reset)
425
+ if (at) w.resetsAt = at
426
+ out.windows = [w]
427
+ return out
428
+ }
429
+
306
430
  return {
307
431
  config: async (config) => {
308
432
  config.provider ??= {}
@@ -317,6 +441,7 @@ export const MimoAuthPlugin = async ({ client }) => {
317
441
  },
318
442
  auth: {
319
443
  provider: ID,
444
+ usage,
320
445
  loader: async (getAuth) => {
321
446
  const a = fromAuth(await getAuth())
322
447
  if (!a) return {}
@@ -379,3 +504,6 @@ export const MimoAuthPlugin = async ({ client }) => {
379
504
  },
380
505
  }
381
506
  }
507
+
508
+ // for tests
509
+ export const _internal = { serverTime, vendorError }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@magpie-community/opencode-mimo-auth",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "OpenCode provider plugin for Xiaomi MiMo: the MiMo app's Xiaomi-account sign-in, MiMo Pro and MiMo Flash",
5
5
  "type": "module",
6
6
  "main": "./index.mjs",