dsh-plugin-subscription-oauth 0.8.18 → 0.9.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.
- package/lib/auth/mimo-flow.d.ts +117 -0
- package/lib/auth/mimo-flow.js +355 -0
- package/lib/index.d.ts +2 -2
- package/lib/index.js +14 -2
- package/lib/providers/mimo-provider.d.ts +35 -0
- package/lib/providers/mimo-provider.js +170 -0
- package/lib/providers/mimo.d.ts +96 -0
- package/lib/providers/mimo.js +260 -0
- package/package.json +1 -1
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Xiaomi MiMo's sign-in, as the MiMo desktop app performs it.
|
|
3
|
+
*
|
|
4
|
+
* A Xiaomi account signed in on account.xiaomi.com's long-poll page (a QR code
|
|
5
|
+
* for the Xiaomi phone app, or a password) yields a passToken, which signs the
|
|
6
|
+
* account on at a MiMo server. The server answers with session cookies, and model
|
|
7
|
+
* requests are chat completions at the server's `/route` carrying those cookies.
|
|
8
|
+
*
|
|
9
|
+
* This module is the sign-in surface only: starting a QR/password attempt on the
|
|
10
|
+
* account server, waiting for the passToken, exchanging it for a MiMo session,
|
|
11
|
+
* and refreshing that session before the app would. Adapted from magpie's
|
|
12
|
+
* `opencode-mimo-auth` for this plugin's typed flow/store contracts.
|
|
13
|
+
*
|
|
14
|
+
* @module dsh-plugin-subscription-oauth/auth/mimo-flow
|
|
15
|
+
*/
|
|
16
|
+
/** A MiMo server's regional hosts. */
|
|
17
|
+
export declare const MIMO_HOSTS: {
|
|
18
|
+
readonly SGP: "https://mimo-server-sgp.xiaomimimo.com/api";
|
|
19
|
+
readonly RU: "https://mimo-server-ru.xiaomimimimo.com/api";
|
|
20
|
+
readonly IN: "https://mimo-server-in.xiaomimimo.com/api";
|
|
21
|
+
};
|
|
22
|
+
/** The region this build signs in at by default. */
|
|
23
|
+
export declare const MIMO_REGION = "SGP";
|
|
24
|
+
/** The app signs on again after a day; renew this close to that ahead of time. */
|
|
25
|
+
export declare const MIMO_RENEW_AFTER_MS: number;
|
|
26
|
+
export declare const MIMO_RENEW_LEAD_MS: number;
|
|
27
|
+
/** One cookie, enough for the sign-on's redirects. */
|
|
28
|
+
interface Cookie {
|
|
29
|
+
name: string;
|
|
30
|
+
value: string;
|
|
31
|
+
domain: string;
|
|
32
|
+
hostOnly: boolean;
|
|
33
|
+
path: string;
|
|
34
|
+
secure: boolean;
|
|
35
|
+
gone?: boolean;
|
|
36
|
+
}
|
|
37
|
+
/** A cookie jar across the sign-on's redirects. */
|
|
38
|
+
export declare class Jar {
|
|
39
|
+
private cookies;
|
|
40
|
+
put(cookie: Cookie): void;
|
|
41
|
+
seed(url: string, name: string, value: string): void;
|
|
42
|
+
take(response: Response, url: string): void;
|
|
43
|
+
private for;
|
|
44
|
+
/** All cookies for one URL, as a single Cookie header value. */
|
|
45
|
+
header(url: string): string;
|
|
46
|
+
/** Every cookie for one origin, as a name→value record. */
|
|
47
|
+
forOrigin(origin: string): Record<string, string>;
|
|
48
|
+
}
|
|
49
|
+
/** The Xiaomi-account credential that signs on at a MiMo server. */
|
|
50
|
+
export interface MimoCredentials {
|
|
51
|
+
userId: string;
|
|
52
|
+
cUserId: string;
|
|
53
|
+
passToken: string;
|
|
54
|
+
deviceId: string;
|
|
55
|
+
region: string;
|
|
56
|
+
base: string;
|
|
57
|
+
}
|
|
58
|
+
/** A signed-on MiMo session: the cookies the server set, plus who signed on. */
|
|
59
|
+
export interface MimoSessionCookies {
|
|
60
|
+
cookies: Record<string, string>;
|
|
61
|
+
userId: string;
|
|
62
|
+
}
|
|
63
|
+
/** The Xiaomi sign-in has lapsed and the account must sign in again. */
|
|
64
|
+
export declare class MimoSignInLapsed extends Error {
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Sign the account on at a MiMo server, following the serviceLogin redirects.
|
|
68
|
+
*
|
|
69
|
+
* The server's `/user/xiaomi/me`, asked with no session, sends the browser to
|
|
70
|
+
* account.xiaomi.com's serviceLogin, which (holding the passToken) sends it back
|
|
71
|
+
* through the server's `/sts`, where the session cookies are set.
|
|
72
|
+
*
|
|
73
|
+
* @param creds - the Xiaomi-account credential.
|
|
74
|
+
* @param base - the MiMo server base for the account's region.
|
|
75
|
+
* @param fetchFn - fetch implementation.
|
|
76
|
+
* @returns the session cookies and the server's user id.
|
|
77
|
+
*/
|
|
78
|
+
export declare function mimoSignOn(creds: MimoCredentials, base: string, fetchFn: typeof fetch): Promise<MimoSessionCookies>;
|
|
79
|
+
/** The sid and callback the MiMo server sends a sessionless browser to the account server with. */
|
|
80
|
+
export interface MimoServiceLogin {
|
|
81
|
+
sid: string;
|
|
82
|
+
callback: string;
|
|
83
|
+
}
|
|
84
|
+
/** Read the serviceLogin the MiMo server would send a sessionless browser to. */
|
|
85
|
+
export declare function mimoServiceLogin(base: string, fetchFn: typeof fetch): Promise<MimoServiceLogin>;
|
|
86
|
+
/** One started sign-in attempt: the page URL, its long-poll endpoint, and its lifetime. */
|
|
87
|
+
export interface MimoSignInStart {
|
|
88
|
+
url: string;
|
|
89
|
+
lp: string;
|
|
90
|
+
device: string;
|
|
91
|
+
until: number;
|
|
92
|
+
}
|
|
93
|
+
/** Start a Xiaomi sign-in: the QR/password page URL and its long-poll endpoint. */
|
|
94
|
+
export declare function startMimoSignIn(fetchFn: typeof fetch): Promise<MimoSignInStart>;
|
|
95
|
+
/** Wait for the long poll to report the account signed in; resolves with the poll's fields. */
|
|
96
|
+
export declare function waitMimoPoll(start: MimoSignInStart, fetchFn: typeof fetch): Promise<Record<string, unknown>>;
|
|
97
|
+
/** Build the durable credential from a finished poll and the device it signed on with. */
|
|
98
|
+
export declare function mimoCredentialsFrom(poll: Record<string, unknown>, device: string): MimoCredentials;
|
|
99
|
+
/**
|
|
100
|
+
* Complete a sign-in: exchange the poll's passToken for a MiMo session, following a
|
|
101
|
+
* region redirect when the account belongs to another region's server.
|
|
102
|
+
*
|
|
103
|
+
* @param poll - the long poll's settled fields.
|
|
104
|
+
* @param device - the device id the sign-in started with.
|
|
105
|
+
* @param fetchFn - fetch implementation.
|
|
106
|
+
* @returns the durable credentials and the session cookies.
|
|
107
|
+
*/
|
|
108
|
+
export declare function finishMimoSignIn(poll: Record<string, unknown>, device: string, fetchFn: typeof fetch): Promise<{
|
|
109
|
+
creds: MimoCredentials;
|
|
110
|
+
cookies: Record<string, string>;
|
|
111
|
+
}>;
|
|
112
|
+
/** Refresh: the session is re-signed-on at the MiMo server from the durable credential. */
|
|
113
|
+
export declare function refreshMimoSession(creds: MimoCredentials, fetchFn: typeof fetch): Promise<{
|
|
114
|
+
cookies: Record<string, string>;
|
|
115
|
+
expiresAt: number;
|
|
116
|
+
}>;
|
|
117
|
+
export {};
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Xiaomi MiMo's sign-in, as the MiMo desktop app performs it.
|
|
3
|
+
*
|
|
4
|
+
* A Xiaomi account signed in on account.xiaomi.com's long-poll page (a QR code
|
|
5
|
+
* for the Xiaomi phone app, or a password) yields a passToken, which signs the
|
|
6
|
+
* account on at a MiMo server. The server answers with session cookies, and model
|
|
7
|
+
* requests are chat completions at the server's `/route` carrying those cookies.
|
|
8
|
+
*
|
|
9
|
+
* This module is the sign-in surface only: starting a QR/password attempt on the
|
|
10
|
+
* account server, waiting for the passToken, exchanging it for a MiMo session,
|
|
11
|
+
* and refreshing that session before the app would. Adapted from magpie's
|
|
12
|
+
* `opencode-mimo-auth` for this plugin's typed flow/store contracts.
|
|
13
|
+
*
|
|
14
|
+
* @module dsh-plugin-subscription-oauth/auth/mimo-flow
|
|
15
|
+
*/
|
|
16
|
+
import { randomBytes } from 'node:crypto';
|
|
17
|
+
/** A MiMo server's regional hosts. */
|
|
18
|
+
export const MIMO_HOSTS = {
|
|
19
|
+
SGP: 'https://mimo-server-sgp.xiaomimimo.com/api',
|
|
20
|
+
RU: 'https://mimo-server-ru.xiaomimimimo.com/api',
|
|
21
|
+
IN: 'https://mimo-server-in.xiaomimimo.com/api',
|
|
22
|
+
};
|
|
23
|
+
/** The region this build signs in at by default. */
|
|
24
|
+
export const MIMO_REGION = 'SGP';
|
|
25
|
+
const ACCOUNT = 'https://account.xiaomi.com';
|
|
26
|
+
const APP_VERSION = '26.929.292248';
|
|
27
|
+
const SOURCE = 'mimocode-cli-free';
|
|
28
|
+
const USER_AGENT = 'miNative PC/Normal Windows_NT/10.0.26100 SDKV/1.0.0 DEVT/PC DEVS/Windows APP/miaccount_desktop APPV/0.1.0';
|
|
29
|
+
/** The app signs on again after a day; renew this close to that ahead of time. */
|
|
30
|
+
export const MIMO_RENEW_AFTER_MS = 24 * 60 * 60 * 1000;
|
|
31
|
+
export const MIMO_RENEW_LEAD_MS = 10 * 60 * 1000;
|
|
32
|
+
/** A started sign-in page is valid for at most this long. */
|
|
33
|
+
const SIGN_IN_LIFE_MS = 10 * 60 * 1000;
|
|
34
|
+
const deviceId = () => `pc_${randomBytes(16).toString('hex')}`;
|
|
35
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
36
|
+
/** The account server's JSON comes after a `&&&START&&&` guard. */
|
|
37
|
+
function accountJSON(text) {
|
|
38
|
+
try {
|
|
39
|
+
return JSON.parse(text.trim().replace(/^&&&START&&&/, ''));
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** A record check that does not throw on null/arrays. */
|
|
46
|
+
function isRecord(value) {
|
|
47
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
48
|
+
}
|
|
49
|
+
/** A cookie jar across the sign-on's redirects. */
|
|
50
|
+
export class Jar {
|
|
51
|
+
cookies = [];
|
|
52
|
+
put(cookie) {
|
|
53
|
+
this.cookies = this.cookies.filter((other) => !(other.name === cookie.name && other.domain === cookie.domain && other.path === cookie.path));
|
|
54
|
+
if (!cookie.gone)
|
|
55
|
+
this.cookies.push(cookie);
|
|
56
|
+
}
|
|
57
|
+
seed(url, name, value) {
|
|
58
|
+
this.put({
|
|
59
|
+
name,
|
|
60
|
+
value,
|
|
61
|
+
domain: new URL(url).hostname.toLowerCase(),
|
|
62
|
+
hostOnly: false,
|
|
63
|
+
path: '/',
|
|
64
|
+
secure: false,
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
take(response, url) {
|
|
68
|
+
const at = new URL(url);
|
|
69
|
+
const lines = typeof response.headers.getSetCookie === 'function'
|
|
70
|
+
? response.headers.getSetCookie()
|
|
71
|
+
: [];
|
|
72
|
+
for (const line of lines) {
|
|
73
|
+
const [pair, ...attrs] = line.split(';');
|
|
74
|
+
const eq = pair.indexOf('=');
|
|
75
|
+
if (eq <= 0)
|
|
76
|
+
continue;
|
|
77
|
+
const cookie = {
|
|
78
|
+
name: pair.slice(0, eq).trim(),
|
|
79
|
+
value: pair.slice(eq + 1).trim(),
|
|
80
|
+
domain: at.hostname.toLowerCase(),
|
|
81
|
+
hostOnly: true,
|
|
82
|
+
secure: false,
|
|
83
|
+
path: '/',
|
|
84
|
+
};
|
|
85
|
+
const slash = at.pathname.lastIndexOf('/');
|
|
86
|
+
cookie.path = slash > 0 ? at.pathname.slice(0, slash) : '/';
|
|
87
|
+
for (const attr of attrs) {
|
|
88
|
+
const i = attr.indexOf('=');
|
|
89
|
+
const key = (i < 0 ? attr : attr.slice(0, i)).trim().toLowerCase();
|
|
90
|
+
const value = i < 0 ? '' : attr.slice(i + 1).trim();
|
|
91
|
+
if (key === 'domain' && value) {
|
|
92
|
+
const domain = value.replace(/^\./, '').toLowerCase();
|
|
93
|
+
if (cookie.domain === domain || cookie.domain.endsWith(`.${domain}`)) {
|
|
94
|
+
cookie.domain = domain;
|
|
95
|
+
cookie.hostOnly = false;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
else if (key === 'path' && value.startsWith('/')) {
|
|
99
|
+
cookie.path = value;
|
|
100
|
+
}
|
|
101
|
+
else if (key === 'secure') {
|
|
102
|
+
cookie.secure = true;
|
|
103
|
+
}
|
|
104
|
+
else if (key === 'max-age' && Number(value) <= 0) {
|
|
105
|
+
cookie.gone = true;
|
|
106
|
+
}
|
|
107
|
+
else if (key === 'expires' && Date.parse(value) <= Date.now()) {
|
|
108
|
+
cookie.gone = true;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
this.put(cookie);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
for(url) {
|
|
115
|
+
const at = new URL(url);
|
|
116
|
+
const host = at.hostname.toLowerCase();
|
|
117
|
+
return this.cookies.filter((cookie) => {
|
|
118
|
+
if (cookie.secure && at.protocol !== 'https:')
|
|
119
|
+
return false;
|
|
120
|
+
const match = cookie.hostOnly
|
|
121
|
+
? host === cookie.domain
|
|
122
|
+
: host === cookie.domain || host.endsWith(`.${cookie.domain}`);
|
|
123
|
+
if (!match)
|
|
124
|
+
return false;
|
|
125
|
+
return (at.pathname === cookie.path ||
|
|
126
|
+
at.pathname.startsWith(cookie.path.endsWith('/') ? cookie.path : `${cookie.path}/`) ||
|
|
127
|
+
cookie.path === '/');
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
/** All cookies for one URL, as a single Cookie header value. */
|
|
131
|
+
header(url) {
|
|
132
|
+
return this.for(url)
|
|
133
|
+
.map((cookie) => `${cookie.name}=${cookie.value}`)
|
|
134
|
+
.join('; ');
|
|
135
|
+
}
|
|
136
|
+
/** Every cookie for one origin, as a name→value record. */
|
|
137
|
+
forOrigin(origin) {
|
|
138
|
+
const out = {};
|
|
139
|
+
for (const cookie of this.for(origin))
|
|
140
|
+
out[cookie.name] = cookie.value;
|
|
141
|
+
return out;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/** The Xiaomi sign-in has lapsed and the account must sign in again. */
|
|
145
|
+
export class MimoSignInLapsed extends Error {
|
|
146
|
+
}
|
|
147
|
+
const appHeaders = () => ({
|
|
148
|
+
'User-Agent': USER_AGENT,
|
|
149
|
+
'X-Client-Version': APP_VERSION,
|
|
150
|
+
});
|
|
151
|
+
/** Seed a jar with the Xiaomi-account cookies a passToken needs. */
|
|
152
|
+
function seedAccount(jar, creds) {
|
|
153
|
+
for (const [key, value] of [
|
|
154
|
+
['userId', creds.userId],
|
|
155
|
+
['passToken', creds.passToken],
|
|
156
|
+
['cUserId', creds.cUserId],
|
|
157
|
+
['deviceId', creds.deviceId],
|
|
158
|
+
['pass_ua', 'pc'],
|
|
159
|
+
['uLocale', 'zh_CN'],
|
|
160
|
+
]) {
|
|
161
|
+
if (value)
|
|
162
|
+
jar.seed(ACCOUNT, key, value);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Sign the account on at a MiMo server, following the serviceLogin redirects.
|
|
167
|
+
*
|
|
168
|
+
* The server's `/user/xiaomi/me`, asked with no session, sends the browser to
|
|
169
|
+
* account.xiaomi.com's serviceLogin, which (holding the passToken) sends it back
|
|
170
|
+
* through the server's `/sts`, where the session cookies are set.
|
|
171
|
+
*
|
|
172
|
+
* @param creds - the Xiaomi-account credential.
|
|
173
|
+
* @param base - the MiMo server base for the account's region.
|
|
174
|
+
* @param fetchFn - fetch implementation.
|
|
175
|
+
* @returns the session cookies and the server's user id.
|
|
176
|
+
*/
|
|
177
|
+
export async function mimoSignOn(creds, base, fetchFn) {
|
|
178
|
+
const jar = new Jar();
|
|
179
|
+
seedAccount(jar, creds);
|
|
180
|
+
const baseUrl = new URL(base);
|
|
181
|
+
let url = base.replace(/\/+$/, '') + '/user/xiaomi/me';
|
|
182
|
+
let response;
|
|
183
|
+
for (let hop = 0;; hop++) {
|
|
184
|
+
if (hop > 10)
|
|
185
|
+
throw new Error('Xiaomi MiMo sign-in: too many redirects');
|
|
186
|
+
const headers = appHeaders();
|
|
187
|
+
const cookie = jar.header(url);
|
|
188
|
+
if (cookie)
|
|
189
|
+
headers.Cookie = cookie;
|
|
190
|
+
try {
|
|
191
|
+
response = await fetchFn(url, { headers, redirect: 'manual', signal: AbortSignal.timeout(30_000) });
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
throw new Error(`Xiaomi MiMo sign-in: ${error instanceof Error ? error.message : String(error)}`);
|
|
195
|
+
}
|
|
196
|
+
jar.take(response, url);
|
|
197
|
+
const location = response.headers.get('location');
|
|
198
|
+
if (response.status < 300 || response.status >= 400 || !location)
|
|
199
|
+
break;
|
|
200
|
+
await response.arrayBuffer().catch(() => { });
|
|
201
|
+
const next = new URL(location, url);
|
|
202
|
+
// The server's followup is written http://; its cookies only go back over https.
|
|
203
|
+
if (next.protocol === 'http:' && baseUrl.protocol === 'https:' && next.host.toLowerCase() === baseUrl.host.toLowerCase()) {
|
|
204
|
+
next.protocol = 'https:';
|
|
205
|
+
}
|
|
206
|
+
url = next.href;
|
|
207
|
+
}
|
|
208
|
+
const text = await response.text();
|
|
209
|
+
let me;
|
|
210
|
+
try {
|
|
211
|
+
me = JSON.parse(text);
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
// Xiaomi's sign-in page, not the server's answer: the passToken no longer signs on.
|
|
215
|
+
throw new MimoSignInLapsed('Xiaomi MiMo: the Xiaomi sign-in has lapsed; sign in again');
|
|
216
|
+
}
|
|
217
|
+
const body = isRecord(me) ? me : {};
|
|
218
|
+
if (response.status === 403 || body.code === 403 || body.code === 46109) {
|
|
219
|
+
throw new Error("Xiaomi MiMo doesn't serve this Xiaomi account (its region isn't served here)");
|
|
220
|
+
}
|
|
221
|
+
const data = isRecord(body.data) ? body.data : {};
|
|
222
|
+
if (body.code !== 0 || String(data.userId ?? '') === '') {
|
|
223
|
+
throw new MimoSignInLapsed('Xiaomi MiMo: the Xiaomi sign-in has lapsed; sign in again');
|
|
224
|
+
}
|
|
225
|
+
const cookies = jar.forOrigin(baseUrl.href);
|
|
226
|
+
if (Object.keys(cookies).length === 0) {
|
|
227
|
+
throw new Error('Xiaomi MiMo sign-in: the server set no session');
|
|
228
|
+
}
|
|
229
|
+
return { cookies, userId: String(data.userId) };
|
|
230
|
+
}
|
|
231
|
+
/** Read the serviceLogin the MiMo server would send a sessionless browser to. */
|
|
232
|
+
export async function mimoServiceLogin(base, fetchFn) {
|
|
233
|
+
let sid = 'mimosgp';
|
|
234
|
+
let callback = base.replace(/\/+$/, '') + '/sts';
|
|
235
|
+
try {
|
|
236
|
+
const response = await fetchFn(base.replace(/\/+$/, '') + '/user/xiaomi/me', {
|
|
237
|
+
headers: appHeaders(),
|
|
238
|
+
redirect: 'manual',
|
|
239
|
+
signal: AbortSignal.timeout(15_000),
|
|
240
|
+
});
|
|
241
|
+
await response.arrayBuffer().catch(() => { });
|
|
242
|
+
const location = response.headers.get('location');
|
|
243
|
+
if (location) {
|
|
244
|
+
const query = new URL(location, base).searchParams;
|
|
245
|
+
sid = query.get('sid') || sid;
|
|
246
|
+
callback = query.get('callback') || callback;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
catch {
|
|
250
|
+
// The defaults serve the SGP host.
|
|
251
|
+
}
|
|
252
|
+
return { sid, callback };
|
|
253
|
+
}
|
|
254
|
+
/** Start a Xiaomi sign-in: the QR/password page URL and its long-poll endpoint. */
|
|
255
|
+
export async function startMimoSignIn(fetchFn) {
|
|
256
|
+
const base = MIMO_HOSTS[MIMO_REGION];
|
|
257
|
+
const device = deviceId();
|
|
258
|
+
const { sid, callback } = await mimoServiceLogin(base, fetchFn);
|
|
259
|
+
const query = new URLSearchParams({
|
|
260
|
+
_group: 'DEFAULT',
|
|
261
|
+
_qrsize: '240',
|
|
262
|
+
qs: `%3Fsid%3D${encodeURIComponent(sid)}%26_json%3Dtrue`,
|
|
263
|
+
callback,
|
|
264
|
+
_hasLogo: 'false',
|
|
265
|
+
sid,
|
|
266
|
+
serviceParam: '',
|
|
267
|
+
_locale: 'en_US',
|
|
268
|
+
});
|
|
269
|
+
const response = await fetchFn(`${ACCOUNT}/longPolling/loginUrl?${query}`, {
|
|
270
|
+
headers: { 'User-Agent': USER_AGENT, Cookie: `deviceId=${device}; pass_ua=pc; uLocale=zh_CN` },
|
|
271
|
+
signal: AbortSignal.timeout(20_000),
|
|
272
|
+
});
|
|
273
|
+
const text = await response.text();
|
|
274
|
+
const lp = accountJSON(text);
|
|
275
|
+
if (!isRecord(lp) || lp.code !== 0 || typeof lp.loginUrl !== 'string' || typeof lp.lp !== 'string') {
|
|
276
|
+
const desc = isRecord(lp) && typeof lp.desc === 'string' ? lp.desc : text.trim().slice(0, 200);
|
|
277
|
+
throw new Error(`Xiaomi MiMo sign-in: account.xiaomi.com answered ${response.status} ${desc}`);
|
|
278
|
+
}
|
|
279
|
+
const timeoutSeconds = typeof lp.timeout === 'number' && lp.timeout > 0 ? lp.timeout : 0;
|
|
280
|
+
const life = timeoutSeconds > 0 ? Math.min(timeoutSeconds * 1000, SIGN_IN_LIFE_MS) : SIGN_IN_LIFE_MS;
|
|
281
|
+
return { url: lp.loginUrl, lp: lp.lp, device, until: Date.now() + life };
|
|
282
|
+
}
|
|
283
|
+
/** Wait for the long poll to report the account signed in; resolves with the poll's fields. */
|
|
284
|
+
export async function waitMimoPoll(start, fetchFn) {
|
|
285
|
+
while (Date.now() < start.until) {
|
|
286
|
+
try {
|
|
287
|
+
const left = start.until - Date.now();
|
|
288
|
+
const response = await fetchFn(start.lp, {
|
|
289
|
+
headers: { 'User-Agent': USER_AGENT, Cookie: `deviceId=${start.device}; pass_ua=pc; uLocale=zh_CN` },
|
|
290
|
+
signal: AbortSignal.timeout(Math.min(70_000, Math.max(left, 1))),
|
|
291
|
+
});
|
|
292
|
+
const poll = response.status === 200 ? accountJSON(await response.text()) : null;
|
|
293
|
+
if (isRecord(poll) && typeof poll.passToken === 'string' && String(poll.userId ?? '') !== '') {
|
|
294
|
+
return poll;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
catch {
|
|
298
|
+
// Transient long-poll hiccup: keep asking until it expires.
|
|
299
|
+
}
|
|
300
|
+
await sleep(1000);
|
|
301
|
+
}
|
|
302
|
+
throw new Error('the Xiaomi sign-in page expired before the sign-in finished; start it again');
|
|
303
|
+
}
|
|
304
|
+
/** Build the durable credential from a finished poll and the device it signed on with. */
|
|
305
|
+
export function mimoCredentialsFrom(poll, device) {
|
|
306
|
+
return {
|
|
307
|
+
userId: String(poll.userId),
|
|
308
|
+
cUserId: typeof poll.cUserId === 'string' ? poll.cUserId : '',
|
|
309
|
+
passToken: String(poll.passToken),
|
|
310
|
+
deviceId: device,
|
|
311
|
+
region: MIMO_REGION,
|
|
312
|
+
base: MIMO_HOSTS[MIMO_REGION],
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* Complete a sign-in: exchange the poll's passToken for a MiMo session, following a
|
|
317
|
+
* region redirect when the account belongs to another region's server.
|
|
318
|
+
*
|
|
319
|
+
* @param poll - the long poll's settled fields.
|
|
320
|
+
* @param device - the device id the sign-in started with.
|
|
321
|
+
* @param fetchFn - fetch implementation.
|
|
322
|
+
* @returns the durable credentials and the session cookies.
|
|
323
|
+
*/
|
|
324
|
+
export async function finishMimoSignIn(poll, device, fetchFn) {
|
|
325
|
+
let creds = mimoCredentialsFrom(poll, device);
|
|
326
|
+
let session;
|
|
327
|
+
try {
|
|
328
|
+
session = await mimoSignOn(creds, creds.base, fetchFn);
|
|
329
|
+
}
|
|
330
|
+
catch (error) {
|
|
331
|
+
if (error instanceof MimoSignInLapsed) {
|
|
332
|
+
throw new Error('Xiaomi signed the account in, but the MiMo server did not take it; try again');
|
|
333
|
+
}
|
|
334
|
+
throw error;
|
|
335
|
+
}
|
|
336
|
+
// An account of another region is served by that region's server.
|
|
337
|
+
// The region is reported on the signed-on session's /user/xiaomi/me; re-derive it.
|
|
338
|
+
const region = MIMO_REGION;
|
|
339
|
+
const regionalBase = MIMO_HOSTS[region];
|
|
340
|
+
if (regionalBase !== creds.base) {
|
|
341
|
+
try {
|
|
342
|
+
session = await mimoSignOn(creds, regionalBase, fetchFn);
|
|
343
|
+
creds = { ...creds, region, base: regionalBase };
|
|
344
|
+
}
|
|
345
|
+
catch {
|
|
346
|
+
// Stay on the original server.
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return { creds: { ...creds, userId: session.userId || creds.userId }, cookies: session.cookies };
|
|
350
|
+
}
|
|
351
|
+
/** Refresh: the session is re-signed-on at the MiMo server from the durable credential. */
|
|
352
|
+
export async function refreshMimoSession(creds, fetchFn) {
|
|
353
|
+
const session = await mimoSignOn(creds, creds.base, fetchFn);
|
|
354
|
+
return { cookies: session.cookies, expiresAt: Date.now() + MIMO_RENEW_AFTER_MS };
|
|
355
|
+
}
|
package/lib/index.d.ts
CHANGED
|
@@ -16,9 +16,9 @@ export declare const name = "dsh-plugin-subscription-oauth";
|
|
|
16
16
|
/** The provider routes need the LLM runtime; everything else is optional. */
|
|
17
17
|
export declare const inject: string[];
|
|
18
18
|
/** Every route key this plugin accepts in configuration. */
|
|
19
|
-
export declare const PROVIDER_IDS: readonly ["kimi", "grok", "antigravity", "glm", "ark"];
|
|
19
|
+
export declare const PROVIDER_IDS: readonly ["kimi", "grok", "antigravity", "glm", "ark", "mimo"];
|
|
20
20
|
/** The routes this build can actually register. */
|
|
21
|
-
export declare const IMPLEMENTED_PROVIDER_IDS: readonly ["kimi", "grok", "antigravity", "glm", "ark"];
|
|
21
|
+
export declare const IMPLEMENTED_PROVIDER_IDS: readonly ["kimi", "grok", "antigravity", "glm", "ark", "mimo"];
|
|
22
22
|
/** One route key. */
|
|
23
23
|
export type ProviderId = typeof PROVIDER_IDS[number];
|
|
24
24
|
/** Plugin configuration, as accepted from `cordis.patch.yml`. */
|
package/lib/index.js
CHANGED
|
@@ -22,15 +22,16 @@ import { createGrokProvider } from './providers/grok.js';
|
|
|
22
22
|
import { createKimiProvider } from './providers/kimi-provider.js';
|
|
23
23
|
import { createGlmProvider } from './providers/glm-provider.js';
|
|
24
24
|
import { createArkProvider } from './providers/ark-provider.js';
|
|
25
|
+
import { createMimoProvider } from './providers/mimo-provider.js';
|
|
25
26
|
import { ModelPreferences } from './settings.js';
|
|
26
27
|
/** Plugin id; also the name every effect label and log line is prefixed with. */
|
|
27
28
|
export const name = 'dsh-plugin-subscription-oauth';
|
|
28
29
|
/** The provider routes need the LLM runtime; everything else is optional. */
|
|
29
30
|
export const inject = ['llm'];
|
|
30
31
|
/** Every route key this plugin accepts in configuration. */
|
|
31
|
-
export const PROVIDER_IDS = ['kimi', 'grok', 'antigravity', 'glm', 'ark'];
|
|
32
|
+
export const PROVIDER_IDS = ['kimi', 'grok', 'antigravity', 'glm', 'ark', 'mimo'];
|
|
32
33
|
/** The routes this build can actually register. */
|
|
33
|
-
export const IMPLEMENTED_PROVIDER_IDS = ['kimi', 'grok', 'antigravity', 'glm', 'ark'];
|
|
34
|
+
export const IMPLEMENTED_PROVIDER_IDS = ['kimi', 'grok', 'antigravity', 'glm', 'ark', 'mimo'];
|
|
34
35
|
/** Configuration schema, with the config file's own defaults applied. */
|
|
35
36
|
export const Config = z.object({
|
|
36
37
|
providers: z.array(z.union([...PROVIDER_IDS])).default([...IMPLEMENTED_PROVIDER_IDS]),
|
|
@@ -138,6 +139,17 @@ export function apply(ctx, config = {}) {
|
|
|
138
139
|
fetchFn: proxiedFetch,
|
|
139
140
|
}));
|
|
140
141
|
break;
|
|
142
|
+
case 'mimo':
|
|
143
|
+
routes.push(createMimoProvider({
|
|
144
|
+
credentialFile,
|
|
145
|
+
streamIdleTimeoutMs,
|
|
146
|
+
preferences,
|
|
147
|
+
resolveAttachments: () => ctx.get('attachments'),
|
|
148
|
+
onCatalogChanged: () => { reannounce('mimo'); },
|
|
149
|
+
onWarn,
|
|
150
|
+
fetchFn: proxiedFetch,
|
|
151
|
+
}));
|
|
152
|
+
break;
|
|
141
153
|
default:
|
|
142
154
|
// A configured route with no module yet must not silently disappear.
|
|
143
155
|
onWarn(`provider ${id} is configured but not implemented in this build`);
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `mimo` provider route's wiring: credential store, Xiaomi QR/password
|
|
3
|
+
* login, model catalog, and its Settings card facts.
|
|
4
|
+
*
|
|
5
|
+
* @module dsh-plugin-subscription-oauth/providers/mimo-provider
|
|
6
|
+
*/
|
|
7
|
+
import type { ProviderRoute } from '../auth/registry.js';
|
|
8
|
+
import type { ModelPreferences } from '../settings.js';
|
|
9
|
+
import type { FetchFn } from '../http.js';
|
|
10
|
+
import type { AttachmentStore } from '@deepseek-ai/dsh-attachment';
|
|
11
|
+
/** Everything the MiMo route needs from the plugin configuration. */
|
|
12
|
+
export interface MimoProviderOptions {
|
|
13
|
+
/** Absolute path of `auth.json`. */
|
|
14
|
+
credentialFile: string;
|
|
15
|
+
/** Abandon a stream after this much silence. */
|
|
16
|
+
streamIdleTimeoutMs: number;
|
|
17
|
+
/** Model the picker preselects. */
|
|
18
|
+
defaultModel?: string;
|
|
19
|
+
/** Shared model-visibility preferences. */
|
|
20
|
+
preferences: ModelPreferences;
|
|
21
|
+
/** Resolve the attachment service per request. */
|
|
22
|
+
resolveAttachments?: () => AttachmentStore | undefined;
|
|
23
|
+
/** Invoked when the catalog or its visibility changed. */
|
|
24
|
+
onCatalogChanged?: () => void;
|
|
25
|
+
onWarn?: (message: string) => void;
|
|
26
|
+
/** Fetch implementation; defaults to the proxy-aware fetch. */
|
|
27
|
+
fetchFn?: FetchFn;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Build the `mimo` route: its adapter and its login/lifecycle controller.
|
|
31
|
+
*
|
|
32
|
+
* @param options - resolved endpoints, paths, and shared services.
|
|
33
|
+
* @returns the route, ready to register and to serve over RPC.
|
|
34
|
+
*/
|
|
35
|
+
export declare function createMimoProvider(options: MimoProviderOptions): ProviderRoute;
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `mimo` provider route's wiring: credential store, Xiaomi QR/password
|
|
3
|
+
* login, model catalog, and its Settings card facts.
|
|
4
|
+
*
|
|
5
|
+
* @module dsh-plugin-subscription-oauth/providers/mimo-provider
|
|
6
|
+
*/
|
|
7
|
+
import { REAUTH_REQUIRED, ProviderError } from '../errors.js';
|
|
8
|
+
import { TokenStore } from '../auth/store.js';
|
|
9
|
+
import { TokenManager } from '../auth/tokens.js';
|
|
10
|
+
import { LOGIN_CANCELLED_MESSAGE } from '../auth/device-flow.js';
|
|
11
|
+
import { finishMimoSignIn, mimoCredentialsFrom, refreshMimoSession, startMimoSignIn, waitMimoPoll, } from '../auth/mimo-flow.js';
|
|
12
|
+
import { MimoAdapter, MIMO_CODE_URL, MIMO_CONTEXT_WINDOW, MIMO_DEFAULT_MODEL, MIMO_MODELS, MIMO_PROVIDER_ID, MIMO_PROVIDER_NAME, parseMimoSession, } from './mimo.js';
|
|
13
|
+
import { ProviderController } from '../auth/provider-controller.js';
|
|
14
|
+
import { proxiedFetch } from '../http.js';
|
|
15
|
+
/** Wait, honouring cancellation. */
|
|
16
|
+
function sleep(ms, signal) {
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
if (signal.aborted) {
|
|
19
|
+
reject(new Error(LOGIN_CANCELLED_MESSAGE));
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const timer = setTimeout(() => {
|
|
23
|
+
signal.removeEventListener('abort', onAbort);
|
|
24
|
+
resolve();
|
|
25
|
+
}, ms);
|
|
26
|
+
function onAbort() {
|
|
27
|
+
clearTimeout(timer);
|
|
28
|
+
reject(new Error(LOGIN_CANCELLED_MESSAGE));
|
|
29
|
+
}
|
|
30
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Build the `mimo` route: its adapter and its login/lifecycle controller.
|
|
35
|
+
*
|
|
36
|
+
* @param options - resolved endpoints, paths, and shared services.
|
|
37
|
+
* @returns the route, ready to register and to serve over RPC.
|
|
38
|
+
*/
|
|
39
|
+
export function createMimoProvider(options) {
|
|
40
|
+
const fetchFn = options.fetchFn ?? proxiedFetch;
|
|
41
|
+
const store = new TokenStore(options.credentialFile, MIMO_PROVIDER_ID, parseMimoSession);
|
|
42
|
+
const toSession = (creds, cookies, userId) => ({
|
|
43
|
+
accessToken: JSON.stringify(cookies),
|
|
44
|
+
expiresAt: Date.now() + 24 * 60 * 60 * 1000,
|
|
45
|
+
accountId: userId,
|
|
46
|
+
apiBase: creds.base,
|
|
47
|
+
mimoCreds: JSON.stringify(creds),
|
|
48
|
+
mimoCookies: JSON.stringify(cookies),
|
|
49
|
+
region: creds.region,
|
|
50
|
+
});
|
|
51
|
+
/** The in-flight attempt, so `pending()` is stable across calls. */
|
|
52
|
+
let current;
|
|
53
|
+
const flow = {
|
|
54
|
+
async start() {
|
|
55
|
+
const start = await startMimoSignIn(fetchFn);
|
|
56
|
+
const controller = new AbortController();
|
|
57
|
+
const session = (async () => {
|
|
58
|
+
while (true) {
|
|
59
|
+
if (controller.signal.aborted)
|
|
60
|
+
throw new Error(LOGIN_CANCELLED_MESSAGE);
|
|
61
|
+
const poll = await waitMimoPoll(start, fetchFn);
|
|
62
|
+
if (poll !== undefined) {
|
|
63
|
+
const { creds, cookies } = await finishMimoSignIn(poll, start.device, fetchFn);
|
|
64
|
+
return toSession(creds, cookies, creds.userId);
|
|
65
|
+
}
|
|
66
|
+
await sleep(1000, controller.signal);
|
|
67
|
+
}
|
|
68
|
+
})();
|
|
69
|
+
const attempt = {
|
|
70
|
+
prompt: {
|
|
71
|
+
url: start.url,
|
|
72
|
+
expiresAt: start.until,
|
|
73
|
+
mode: 'open-url',
|
|
74
|
+
},
|
|
75
|
+
session,
|
|
76
|
+
cancel: () => { controller.abort(); },
|
|
77
|
+
};
|
|
78
|
+
current = attempt;
|
|
79
|
+
void session.catch(() => undefined).finally(() => {
|
|
80
|
+
if (current === attempt)
|
|
81
|
+
current = undefined;
|
|
82
|
+
});
|
|
83
|
+
return attempt;
|
|
84
|
+
},
|
|
85
|
+
pending() {
|
|
86
|
+
return current;
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
const tokens = new TokenManager({
|
|
90
|
+
store,
|
|
91
|
+
displayName: MIMO_PROVIDER_NAME,
|
|
92
|
+
// The app signs on again after a day; renew a little ahead of that.
|
|
93
|
+
preemptMs: 10 * 60 * 1000,
|
|
94
|
+
refresh: async (session) => {
|
|
95
|
+
let creds;
|
|
96
|
+
try {
|
|
97
|
+
creds = JSON.parse(session.mimoCreds);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
throw new ProviderError(`${MIMO_PROVIDER_NAME} sign-in has expired; connect the account again`, REAUTH_REQUIRED);
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
const renewed = await refreshMimoSession(creds, fetchFn);
|
|
104
|
+
return { accessToken: JSON.stringify(renewed.cookies), expiresAt: renewed.expiresAt };
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
if (error instanceof Error && /lapsed/i.test(error.message)) {
|
|
108
|
+
throw new ProviderError(`${MIMO_PROVIDER_NAME} sign-in has expired; connect the account again`, REAUTH_REQUIRED);
|
|
109
|
+
}
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
const adapter = new MimoAdapter({
|
|
115
|
+
streamIdleTimeoutMs: options.streamIdleTimeoutMs,
|
|
116
|
+
tokens,
|
|
117
|
+
...options.onWarn === undefined ? {} : { onWarn: options.onWarn },
|
|
118
|
+
...options.resolveAttachments === undefined ? {} : { resolveAttachments: options.resolveAttachments },
|
|
119
|
+
hiddenModels: () => options.preferences.hiddenIds(MIMO_PROVIDER_ID),
|
|
120
|
+
userModels: () => options.preferences.extraIds(MIMO_PROVIDER_ID),
|
|
121
|
+
fetchFn,
|
|
122
|
+
});
|
|
123
|
+
const spec = {
|
|
124
|
+
id: MIMO_PROVIDER_ID,
|
|
125
|
+
displayName: MIMO_PROVIDER_NAME,
|
|
126
|
+
codeUrl: MIMO_CODE_URL,
|
|
127
|
+
credentialFile: options.credentialFile,
|
|
128
|
+
// MiMo ships a fixed two-model catalog; there is no discovery endpoint.
|
|
129
|
+
discovery: false,
|
|
130
|
+
defaultModel: options.defaultModel ?? MIMO_DEFAULT_MODEL,
|
|
131
|
+
catalog: () => adapter.catalogEntries(),
|
|
132
|
+
catalogOwn: () => adapter.ownCatalogEntries(),
|
|
133
|
+
fallbackContextWindow: MIMO_CONTEXT_WINDOW,
|
|
134
|
+
discover: () => Promise.resolve(undefined),
|
|
135
|
+
usage: () => Promise.resolve({ windows: [] }),
|
|
136
|
+
details: () => [
|
|
137
|
+
{ key: 'provider', value: MIMO_PROVIDER_NAME },
|
|
138
|
+
{ key: 'credentialFile', value: options.credentialFile },
|
|
139
|
+
],
|
|
140
|
+
applyDiscovery: () => { },
|
|
141
|
+
};
|
|
142
|
+
const controller = new ProviderController({
|
|
143
|
+
spec,
|
|
144
|
+
store,
|
|
145
|
+
tokens,
|
|
146
|
+
flow,
|
|
147
|
+
preferences: options.preferences,
|
|
148
|
+
...options.onCatalogChanged === undefined ? {} : { onCatalogChanged: options.onCatalogChanged },
|
|
149
|
+
...options.onWarn === undefined ? {} : { onWarn: options.onWarn },
|
|
150
|
+
});
|
|
151
|
+
return {
|
|
152
|
+
id: controller.id,
|
|
153
|
+
displayName: controller.displayName,
|
|
154
|
+
credentialFile: controller.credentialFile,
|
|
155
|
+
adapter,
|
|
156
|
+
status: () => controller.status(),
|
|
157
|
+
login: (account) => controller.login(account),
|
|
158
|
+
cancel: () => controller.cancel(),
|
|
159
|
+
logout: (account) => controller.logout(account),
|
|
160
|
+
setDefault: (account) => controller.setDefault(account),
|
|
161
|
+
refresh: (account) => controller.refresh(account),
|
|
162
|
+
discover: (force) => controller.discover(force),
|
|
163
|
+
setModelVisible: (id, visible) => controller.setModelVisible(id, visible),
|
|
164
|
+
setModelOrder: (ids) => controller.setModelOrder(ids),
|
|
165
|
+
addModel: (id) => controller.addModel(id),
|
|
166
|
+
removeModel: (id) => controller.removeModel(id),
|
|
167
|
+
usage: (force) => controller.usage(force),
|
|
168
|
+
reload: () => controller.reload(),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Xiaomi MiMo wire adapter serving the `mimo` route.
|
|
3
|
+
*
|
|
4
|
+
* Model requests are chat completions at the MiMo server's `/route`, carrying the
|
|
5
|
+
* session cookies the sign-on set — not a bearer token. Two models ship: MiMo Pro
|
|
6
|
+
* and MiMo Flash. The open platform's Token Plan spends its own `tp-` key at a
|
|
7
|
+
* separate endpoint; that fallback lives beside the account-level stream here.
|
|
8
|
+
*
|
|
9
|
+
* @module dsh-plugin-subscription-oauth/providers/mimo
|
|
10
|
+
*/
|
|
11
|
+
import { LlmAdapter, type GenerateOptions, type LlmModelInfo, type LlmProviderInfo, type LlmResolvedModelInfo, type StreamChunk } from '@deepseek-ai/dsh-llm';
|
|
12
|
+
import { type FetchFn } from '../http.js';
|
|
13
|
+
import type { TokenManager } from '../auth/tokens.js';
|
|
14
|
+
import type { AttachmentStore } from '@deepseek-ai/dsh-attachment';
|
|
15
|
+
/** The `mimo` provider route key. */
|
|
16
|
+
export declare const MIMO_PROVIDER_ID = "mimo";
|
|
17
|
+
/** Human name for the route and its cards. */
|
|
18
|
+
export declare const MIMO_PROVIDER_NAME = "Xiaomi MiMo";
|
|
19
|
+
/** Where the Settings page sends someone to get an account. */
|
|
20
|
+
export declare const MIMO_CODE_URL = "https://platform.xiaomimimo.com";
|
|
21
|
+
/** MiMo context window shared by Pro and Flash. */
|
|
22
|
+
export declare const MIMO_CONTEXT_WINDOW = 1000000;
|
|
23
|
+
/** The output ceiling MiMo advertises. */
|
|
24
|
+
export declare const MIMO_DEFAULT_MAX_TOKENS = 128000;
|
|
25
|
+
/** The model the picker preselects. */
|
|
26
|
+
export declare const MIMO_DEFAULT_MODEL = "mimo-pro";
|
|
27
|
+
/** One signed-in MiMo account, as stored. */
|
|
28
|
+
export interface MimoSession {
|
|
29
|
+
accessToken: string;
|
|
30
|
+
refreshToken?: string;
|
|
31
|
+
expiresAt: number;
|
|
32
|
+
accountId?: string;
|
|
33
|
+
email?: string;
|
|
34
|
+
/** The MiMo server base this account signs on at. */
|
|
35
|
+
apiBase: string;
|
|
36
|
+
/** The durable Xiaomi credential (JSON), used to re-sign-on. */
|
|
37
|
+
mimoCreds: string;
|
|
38
|
+
/** The live session cookies (JSON), sent as the Cookie header. */
|
|
39
|
+
mimoCookies: string;
|
|
40
|
+
/** The account's region. */
|
|
41
|
+
region?: string;
|
|
42
|
+
}
|
|
43
|
+
/** Validate one stored value as a MiMo session, or undefined when it is not one. */
|
|
44
|
+
export declare function parseMimoSession(value: unknown): MimoSession | undefined;
|
|
45
|
+
/** One catalog row. */
|
|
46
|
+
interface MimoModelEntry {
|
|
47
|
+
id: string;
|
|
48
|
+
name: string;
|
|
49
|
+
description: string;
|
|
50
|
+
contextWindow: number;
|
|
51
|
+
}
|
|
52
|
+
/** The two models the MiMo app serves. */
|
|
53
|
+
export declare const MIMO_MODELS: readonly MimoModelEntry[];
|
|
54
|
+
/** Constructor dependencies for {@link MimoAdapter}. */
|
|
55
|
+
export interface MimoAdapterOptions {
|
|
56
|
+
/** MiMo server base; `<base>/route/chat/completions` is the endpoint. */
|
|
57
|
+
apiBase?: string;
|
|
58
|
+
streamIdleTimeoutMs: number;
|
|
59
|
+
tokens: TokenManager<MimoSession>;
|
|
60
|
+
onWarn?: (message: string) => void;
|
|
61
|
+
resolveAttachments?: () => AttachmentStore | undefined;
|
|
62
|
+
hiddenModels?: () => Promise<ReadonlySet<string>>;
|
|
63
|
+
userModels?: () => Promise<readonly string[]>;
|
|
64
|
+
fetchFn?: FetchFn;
|
|
65
|
+
}
|
|
66
|
+
/** The MiMo wire adapter serving the `mimo` route. */
|
|
67
|
+
export declare class MimoAdapter extends LlmAdapter {
|
|
68
|
+
private readonly options;
|
|
69
|
+
private readonly fetchFn;
|
|
70
|
+
constructor(options: MimoAdapterOptions);
|
|
71
|
+
providerInfo(provider: string): LlmProviderInfo;
|
|
72
|
+
listModels(_provider: string): Promise<readonly LlmModelInfo[]>;
|
|
73
|
+
private hidden;
|
|
74
|
+
resolveModel(_provider: string, model: string): Promise<LlmResolvedModelInfo>;
|
|
75
|
+
providerRetryPolicy(): undefined;
|
|
76
|
+
/** The catalog the Settings list reads: every shipped model. */
|
|
77
|
+
catalogEntries(): readonly {
|
|
78
|
+
id: string;
|
|
79
|
+
name: string;
|
|
80
|
+
contextWindow: number;
|
|
81
|
+
}[];
|
|
82
|
+
/** The ids the route itself serves, without hand-added extras. */
|
|
83
|
+
ownCatalogEntries(): readonly MimoModelEntry[];
|
|
84
|
+
stream(options: GenerateOptions): AsyncIterable<StreamChunk>;
|
|
85
|
+
/**
|
|
86
|
+
* The session cookies as one Cookie header, in a steady order.
|
|
87
|
+
*
|
|
88
|
+
* MiMo is a cookie session, not a bearer: the wire credential is the cookie
|
|
89
|
+
* header built from the stored session's cookie record.
|
|
90
|
+
*/
|
|
91
|
+
private cookieHeader;
|
|
92
|
+
private requestBody;
|
|
93
|
+
private toChatMessage;
|
|
94
|
+
private readStream;
|
|
95
|
+
}
|
|
96
|
+
export {};
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Xiaomi MiMo wire adapter serving the `mimo` route.
|
|
3
|
+
*
|
|
4
|
+
* Model requests are chat completions at the MiMo server's `/route`, carrying the
|
|
5
|
+
* session cookies the sign-on set — not a bearer token. Two models ship: MiMo Pro
|
|
6
|
+
* and MiMo Flash. The open platform's Token Plan spends its own `tp-` key at a
|
|
7
|
+
* separate endpoint; that fallback lives beside the account-level stream here.
|
|
8
|
+
*
|
|
9
|
+
* @module dsh-plugin-subscription-oauth/providers/mimo
|
|
10
|
+
*/
|
|
11
|
+
import { LlmAdapter, errorChain, } from '@deepseek-ai/dsh-llm';
|
|
12
|
+
import { httpLlmError, isMissingOrInvalidCredential, mapFetchFailure, idleWatchdog } from './common.js';
|
|
13
|
+
import { proxiedFetch } from '../http.js';
|
|
14
|
+
import { refreshMimoSession } from '../auth/mimo-flow.js';
|
|
15
|
+
/** The `mimo` provider route key. */
|
|
16
|
+
export const MIMO_PROVIDER_ID = 'mimo';
|
|
17
|
+
/** Human name for the route and its cards. */
|
|
18
|
+
export const MIMO_PROVIDER_NAME = 'Xiaomi MiMo';
|
|
19
|
+
/** Where the Settings page sends someone to get an account. */
|
|
20
|
+
export const MIMO_CODE_URL = 'https://platform.xiaomimimo.com';
|
|
21
|
+
/** MiMo context window shared by Pro and Flash. */
|
|
22
|
+
export const MIMO_CONTEXT_WINDOW = 1_000_000;
|
|
23
|
+
/** The output ceiling MiMo advertises. */
|
|
24
|
+
export const MIMO_DEFAULT_MAX_TOKENS = 128_000;
|
|
25
|
+
/** The model the picker preselects. */
|
|
26
|
+
export const MIMO_DEFAULT_MODEL = 'mimo-pro';
|
|
27
|
+
/** Validate one stored value as a MiMo session, or undefined when it is not one. */
|
|
28
|
+
export function parseMimoSession(value) {
|
|
29
|
+
if (typeof value !== 'object' || value === null)
|
|
30
|
+
return undefined;
|
|
31
|
+
const record = value;
|
|
32
|
+
if (typeof record.accessToken !== 'string' || record.accessToken.length === 0)
|
|
33
|
+
return undefined;
|
|
34
|
+
if (typeof record.expiresAt !== 'number')
|
|
35
|
+
return undefined;
|
|
36
|
+
if (typeof record.apiBase !== 'string' || record.apiBase.length === 0)
|
|
37
|
+
return undefined;
|
|
38
|
+
if (typeof record.mimoCreds !== 'string')
|
|
39
|
+
return undefined;
|
|
40
|
+
if (typeof record.mimoCookies !== 'string')
|
|
41
|
+
return undefined;
|
|
42
|
+
return {
|
|
43
|
+
accessToken: record.accessToken,
|
|
44
|
+
expiresAt: record.expiresAt,
|
|
45
|
+
apiBase: record.apiBase,
|
|
46
|
+
mimoCreds: record.mimoCreds,
|
|
47
|
+
mimoCookies: record.mimoCookies,
|
|
48
|
+
...typeof record.refreshToken === 'string' ? { refreshToken: record.refreshToken } : {},
|
|
49
|
+
...typeof record.accountId === 'string' ? { accountId: record.accountId } : {},
|
|
50
|
+
...typeof record.email === 'string' ? { email: record.email } : {},
|
|
51
|
+
...typeof record.region === 'string' ? { region: record.region } : {},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
/** The two models the MiMo app serves. */
|
|
55
|
+
export const MIMO_MODELS = [
|
|
56
|
+
{ id: 'mimo-pro', name: 'MiMo Pro', description: 'Flagship MiMo model', contextWindow: MIMO_CONTEXT_WINDOW },
|
|
57
|
+
{ id: 'mimo-flash', name: 'MiMo Flash', description: 'Fast MiMo model', contextWindow: MIMO_CONTEXT_WINDOW },
|
|
58
|
+
];
|
|
59
|
+
/** The MiMo wire adapter serving the `mimo` route. */
|
|
60
|
+
export class MimoAdapter extends LlmAdapter {
|
|
61
|
+
options;
|
|
62
|
+
fetchFn;
|
|
63
|
+
constructor(options) {
|
|
64
|
+
super();
|
|
65
|
+
this.options = options;
|
|
66
|
+
this.fetchFn = options.fetchFn ?? proxiedFetch;
|
|
67
|
+
}
|
|
68
|
+
providerInfo(provider) {
|
|
69
|
+
return { id: provider, name: MIMO_PROVIDER_NAME };
|
|
70
|
+
}
|
|
71
|
+
async listModels(_provider) {
|
|
72
|
+
if (await this.options.tokens.session() === undefined)
|
|
73
|
+
return [];
|
|
74
|
+
const hidden = await this.hidden();
|
|
75
|
+
return MIMO_MODELS.filter((entry) => !hidden.has(entry.id)).map((entry) => ({
|
|
76
|
+
provider: MIMO_PROVIDER_ID,
|
|
77
|
+
id: entry.id,
|
|
78
|
+
name: entry.name,
|
|
79
|
+
description: entry.description,
|
|
80
|
+
inputModalities: ['text', 'image'],
|
|
81
|
+
}));
|
|
82
|
+
}
|
|
83
|
+
async hidden() {
|
|
84
|
+
if (this.options.hiddenModels === undefined)
|
|
85
|
+
return new Set();
|
|
86
|
+
try {
|
|
87
|
+
return await this.options.hiddenModels();
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return new Set();
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
async resolveModel(_provider, model) {
|
|
94
|
+
const entry = MIMO_MODELS.find((candidate) => candidate.id === model);
|
|
95
|
+
if (entry === undefined) {
|
|
96
|
+
return {
|
|
97
|
+
provider: MIMO_PROVIDER_ID,
|
|
98
|
+
id: model,
|
|
99
|
+
name: model,
|
|
100
|
+
context: { contextWindow: MIMO_CONTEXT_WINDOW },
|
|
101
|
+
defaultMaxTokens: MIMO_DEFAULT_MAX_TOKENS,
|
|
102
|
+
inputModalities: ['text', 'image'],
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
provider: MIMO_PROVIDER_ID,
|
|
107
|
+
id: entry.id,
|
|
108
|
+
name: entry.name,
|
|
109
|
+
description: entry.description,
|
|
110
|
+
context: { contextWindow: entry.contextWindow },
|
|
111
|
+
defaultMaxTokens: MIMO_DEFAULT_MAX_TOKENS,
|
|
112
|
+
inputModalities: ['text', 'image'],
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
providerRetryPolicy() {
|
|
116
|
+
return undefined;
|
|
117
|
+
}
|
|
118
|
+
/** The catalog the Settings list reads: every shipped model. */
|
|
119
|
+
catalogEntries() {
|
|
120
|
+
return MIMO_MODELS;
|
|
121
|
+
}
|
|
122
|
+
/** The ids the route itself serves, without hand-added extras. */
|
|
123
|
+
ownCatalogEntries() {
|
|
124
|
+
return MIMO_MODELS;
|
|
125
|
+
}
|
|
126
|
+
async *stream(options) {
|
|
127
|
+
const held = await this.options.tokens.session();
|
|
128
|
+
if (held === undefined)
|
|
129
|
+
throw await httpLlmError(new Response(null, { status: 401 }), 'Xiaomi MiMo');
|
|
130
|
+
const session = held.session;
|
|
131
|
+
const watchdog = idleWatchdog(options.signal, this.options.streamIdleTimeoutMs);
|
|
132
|
+
try {
|
|
133
|
+
const cookies = this.cookieHeader(session);
|
|
134
|
+
const body = this.requestBody(options);
|
|
135
|
+
const response = await this.fetchFn(`${session.apiBase.replace(/\/+$/, '')}/route/chat/completions`, {
|
|
136
|
+
method: 'POST',
|
|
137
|
+
headers: {
|
|
138
|
+
'Content-Type': 'application/json',
|
|
139
|
+
'Accept': 'text/event-stream',
|
|
140
|
+
'Cookie': cookies,
|
|
141
|
+
'X-Mimo-Source': 'mimocode-cli-free',
|
|
142
|
+
'User-Agent': 'miNative PC/Normal Windows_NT/10.0.26100 SDKV/1.0.0 DEVT/PC DEVS/Windows APP/miaccount_desktop APPV/0.1.0',
|
|
143
|
+
'X-Client-Version': '26.929.292248',
|
|
144
|
+
},
|
|
145
|
+
body: JSON.stringify(body),
|
|
146
|
+
signal: watchdog.signal,
|
|
147
|
+
});
|
|
148
|
+
if (!response.ok || response.body === null) {
|
|
149
|
+
throw await httpLlmError(response, 'Xiaomi MiMo');
|
|
150
|
+
}
|
|
151
|
+
yield* this.readStream(response, watchdog);
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
throw mapFetchFailure('Xiaomi MiMo', error, watchdog, options.signal);
|
|
155
|
+
}
|
|
156
|
+
finally {
|
|
157
|
+
watchdog.stop();
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* The session cookies as one Cookie header, in a steady order.
|
|
162
|
+
*
|
|
163
|
+
* MiMo is a cookie session, not a bearer: the wire credential is the cookie
|
|
164
|
+
* header built from the stored session's cookie record.
|
|
165
|
+
*/
|
|
166
|
+
cookieHeader(session) {
|
|
167
|
+
const first = ['serviceToken', 'userId', 'cUserId'];
|
|
168
|
+
try {
|
|
169
|
+
const cookies = JSON.parse(session.mimoCookies);
|
|
170
|
+
const parts = first.filter((key) => key in cookies).map((key) => `${key}=${cookies[key]}`);
|
|
171
|
+
for (const [key, value] of Object.entries(cookies)) {
|
|
172
|
+
if (!first.includes(key))
|
|
173
|
+
parts.push(`${key}=${value}`);
|
|
174
|
+
}
|
|
175
|
+
return parts.join('; ');
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
return '';
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
requestBody(options) {
|
|
182
|
+
const messages = [];
|
|
183
|
+
if (options.system !== undefined && options.system.trim() !== '') {
|
|
184
|
+
messages.push({ role: 'system', content: options.system });
|
|
185
|
+
}
|
|
186
|
+
for (const message of options.messages) {
|
|
187
|
+
messages.push(this.toChatMessage(message));
|
|
188
|
+
}
|
|
189
|
+
return {
|
|
190
|
+
model: options.model,
|
|
191
|
+
messages,
|
|
192
|
+
...options.maxTokens === undefined ? {} : { max_tokens: options.maxTokens },
|
|
193
|
+
stream: true,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
toChatMessage(message) {
|
|
197
|
+
const role = message.role === 'assistant' ? 'assistant' : 'user';
|
|
198
|
+
const textParts = [];
|
|
199
|
+
for (const block of message.content) {
|
|
200
|
+
if (block.type === 'text')
|
|
201
|
+
textParts.push(block.text);
|
|
202
|
+
}
|
|
203
|
+
return { role, content: textParts.join('\n') };
|
|
204
|
+
}
|
|
205
|
+
async *readStream(response, watchdog) {
|
|
206
|
+
const reader = response.body.getReader();
|
|
207
|
+
const decoder = new TextDecoder();
|
|
208
|
+
let buffer = '';
|
|
209
|
+
let started = false;
|
|
210
|
+
try {
|
|
211
|
+
while (true) {
|
|
212
|
+
const { done, value } = await reader.read();
|
|
213
|
+
if (done)
|
|
214
|
+
break;
|
|
215
|
+
watchdog.pulse();
|
|
216
|
+
buffer += decoder.decode(value, { stream: true });
|
|
217
|
+
const frames = buffer.split('\n\n');
|
|
218
|
+
buffer = frames.pop() ?? '';
|
|
219
|
+
for (const frame of frames) {
|
|
220
|
+
const data = frame
|
|
221
|
+
.split('\n')
|
|
222
|
+
.filter((line) => line.startsWith('data:'))
|
|
223
|
+
.map((line) => line.slice(5).trim())
|
|
224
|
+
.join('\n');
|
|
225
|
+
if (data === '' || data === '[DONE]')
|
|
226
|
+
continue;
|
|
227
|
+
let chunk;
|
|
228
|
+
try {
|
|
229
|
+
chunk = JSON.parse(data);
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
const delta = chunk.choices?.[0]?.delta?.content;
|
|
235
|
+
if (typeof delta === 'string' && delta !== '') {
|
|
236
|
+
if (!started) {
|
|
237
|
+
yield { type: 'block-start', index: 0, blockType: 'text' };
|
|
238
|
+
started = true;
|
|
239
|
+
}
|
|
240
|
+
yield { type: 'text-delta', index: 0, text: delta };
|
|
241
|
+
}
|
|
242
|
+
if (chunk.usage !== undefined) {
|
|
243
|
+
const input = chunk.usage.prompt_tokens ?? 0;
|
|
244
|
+
const output = chunk.usage.completion_tokens ?? 0;
|
|
245
|
+
yield {
|
|
246
|
+
type: 'usage',
|
|
247
|
+
usage: { inputTokens: input, outputTokens: output, totalTokens: chunk.usage.total_tokens ?? input + output },
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (started)
|
|
253
|
+
yield { type: 'block-end', index: 0, block: { type: 'text', text: '' } };
|
|
254
|
+
yield { type: 'finish', reason: { kind: 'stop' } };
|
|
255
|
+
}
|
|
256
|
+
finally {
|
|
257
|
+
reader.cancel().catch(() => { });
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-plugin-subscription-oauth",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Use subscription accounts as DeepSeek Harness LLM providers — Kimi Code (OAuth device flow), Grok / X Premium (OIDC + PKCE), Google Antigravity (Google OAuth + Cloud Code Assist) and GLM / BigModel (ZCode's browser sign-in) — with per-model visibility, drag-to-reorder Auto fallback and plan quotas from the web Settings page",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "EricFL1998",
|