dsh-plugin-subscription-oauth 0.8.18 → 0.10.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.
@@ -0,0 +1,265 @@
1
+ /**
2
+ * WorkBuddy China's sign-in and token handling.
3
+ *
4
+ * A WorkBuddy account signs in in the browser (Tencent's page, polled for the
5
+ * token as WorkBuddy's desktop app does). Chats are chat completions at
6
+ * `<endpoint>/v2`, streamed only, and the API answers `{code, msg, data}` where a
7
+ * non-zero code is an error. Adapted from magpie's `workbuddy` for this plugin's
8
+ * typed flow/store contracts.
9
+ *
10
+ * @module dsh-plugin-subscription-oauth/auth/workbuddy-flow
11
+ */
12
+ /** WorkBuddy China build's endpoints. */
13
+ export const WORKBUDDY_ENDPOINT = 'https://copilot.tencent.com';
14
+ export const WORKBUDDY_PLATFORM = 'workbuddy';
15
+ /** The version the sign-in page is opened with. */
16
+ export const WORKBUDDY_APP_VERSION = '2.0.0';
17
+ /** The User-Agent version the chat API accepts; another is refused (10085). */
18
+ export const WORKBUDDY_UA_VERSION = '5.5.6';
19
+ /** A token this close to its end is refreshed before a request. */
20
+ export const WORKBUDDY_EARLY_MS = 60_000;
21
+ /** A sign-in attempt is abandoned after this long. */
22
+ export const WORKBUDDY_SIGN_IN_MS = 5 * 60 * 1000;
23
+ const POLL_MS = 1000;
24
+ /** Validate one stored value as a WorkBuddy session, or undefined when it is not one. */
25
+ export function parseWorkBuddySession(value) {
26
+ if (typeof value !== 'object' || value === null)
27
+ return undefined;
28
+ const record = value;
29
+ if (typeof record.accessToken !== 'string' || record.accessToken.length === 0)
30
+ return undefined;
31
+ if (typeof record.expiresAt !== 'number')
32
+ return undefined;
33
+ return {
34
+ accessToken: record.accessToken,
35
+ expiresAt: record.expiresAt,
36
+ ...typeof record.refreshToken === 'string' ? { refreshToken: record.refreshToken } : {},
37
+ ...typeof record.accountId === 'string' ? { accountId: record.accountId } : {},
38
+ ...typeof record.email === 'string' ? { email: record.email } : {},
39
+ ...typeof record.uid === 'string' ? { uid: record.uid } : {},
40
+ ...typeof record.enterpriseId === 'string' ? { enterpriseId: record.enterpriseId } : {},
41
+ ...typeof record.domain === 'string' ? { domain: record.domain } : {},
42
+ ...typeof record.tokenType === 'string' ? { tokenType: record.tokenType } : {},
43
+ ...typeof record.refreshExpiresAt === 'number' ? { refreshExpiresAt: record.refreshExpiresAt } : {},
44
+ };
45
+ }
46
+ /** A WorkBuddy API failure, carrying the wire code and magpie's phrasing. */
47
+ export class WBError extends Error {
48
+ code;
49
+ status;
50
+ said;
51
+ constructor(message, code, status, said) {
52
+ super(message);
53
+ this.code = code;
54
+ this.status = status;
55
+ this.said = said;
56
+ }
57
+ }
58
+ const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
59
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
60
+ /** The chat completions endpoint for the China build. */
61
+ export const workbuddyApi = (endpoint) => `${endpoint}/v2`;
62
+ /**
63
+ * Make one request of WorkBuddy's plugin API, which answers `{code, msg, data}`.
64
+ * A non-zero `code`, or a non-2xx status, is an error.
65
+ *
66
+ * @param endpoint - the site base, e.g. `https://copilot.tencent.com`.
67
+ * @param method - HTTP method.
68
+ * @param path - path beneath the base.
69
+ * @param headers - extra headers.
70
+ * @param body - JSON body, when present.
71
+ * @param fetchFn - fetch implementation.
72
+ * @returns the response's `data`.
73
+ */
74
+ export async function workbuddyCall(endpoint, method, path, headers, body, fetchFn) {
75
+ const response = await fetchFn(endpoint + path, {
76
+ method,
77
+ headers: {
78
+ 'Content-Type': 'application/json',
79
+ 'Accept': 'application/json',
80
+ 'User-Agent': `WorkBuddy/${WORKBUDDY_UA_VERSION}`,
81
+ ...headers,
82
+ },
83
+ ...body === undefined ? {} : { body: JSON.stringify(body) },
84
+ signal: AbortSignal.timeout(30_000),
85
+ });
86
+ const text = await response.text();
87
+ let env = {};
88
+ try {
89
+ env = JSON.parse(text);
90
+ }
91
+ catch {
92
+ env = {};
93
+ }
94
+ const code = Number.parseInt(String(env.code ?? ''), 10) || 0;
95
+ if (!response.ok) {
96
+ const why = String(env.msg ?? env.message ?? (isRecord(env.error) ? env.error.message : '') ?? '').trim() ||
97
+ text.slice(0, 200) || response.statusText;
98
+ const said = code ? String(env.msg ?? `error ${code}`) : String(env.msg ?? `${response.status}`);
99
+ throw new WBError(`WorkBuddy: ${why} (HTTP ${response.status}${env.code ? `, code ${String(env.code)}` : ''})`, env.code, response.status, said);
100
+ }
101
+ if (code !== 0) {
102
+ throw new WBError(`WorkBuddy: ${String(env.msg ?? 'error')} (${String(env.code)})`, env.code, response.status, String(env.msg ?? `error ${code}`));
103
+ }
104
+ return env.data;
105
+ }
106
+ /** Start a browser sign-in: the page URL to open, and the state to poll for the token. */
107
+ export async function startWorkBuddySignIn(fetchFn) {
108
+ const data = await workbuddyCall(WORKBUDDY_ENDPOINT, 'POST', `/v2/plugin/auth/state?platform=${encodeURIComponent(WORKBUDDY_PLATFORM)}`, {
109
+ 'X-No-Authorization': 'true',
110
+ 'X-No-User-Id': 'true',
111
+ 'X-No-Enterprise-Id': 'true',
112
+ 'X-No-Department-Info': 'true',
113
+ }, {}, fetchFn);
114
+ if (!isRecord(data) || typeof data.state !== 'string' || typeof data.authUrl !== 'string') {
115
+ throw new Error('WorkBuddy gave no sign-in page');
116
+ }
117
+ const url = new URL(data.authUrl);
118
+ if (url.protocol !== 'https:')
119
+ throw new Error("WorkBuddy's sign-in page isn't https");
120
+ url.searchParams.set('version', WORKBUDDY_APP_VERSION);
121
+ url.searchParams.set('loginSessionId', randomHex());
122
+ return { state: data.state, url: url.toString(), expiresAt: Date.now() + WORKBUDDY_SIGN_IN_MS };
123
+ }
124
+ const randomHex = () => {
125
+ const bytes = new Uint8Array(16);
126
+ crypto.getRandomValues(bytes);
127
+ return [...bytes].map((byte) => byte.toString(16).padStart(2, '0')).join('');
128
+ };
129
+ /** Ask a path until it answers data, retrying the codes that mean "not yet". */
130
+ async function workbuddyPoll(path, headers, retry, deadline, fetchFn) {
131
+ for (;;) {
132
+ if (Date.now() > deadline)
133
+ throw new Error('WorkBuddy sign-in timed out');
134
+ try {
135
+ const data = await workbuddyCall(WORKBUDDY_ENDPOINT, 'GET', path, headers, undefined, fetchFn);
136
+ if (isRecord(data) && Object.keys(data).length > 0)
137
+ return data;
138
+ }
139
+ catch (error) {
140
+ if (!(error instanceof WBError && (retry.includes(Number(error.code)) || error.status === 408 || error.status === 429))) {
141
+ throw error;
142
+ }
143
+ }
144
+ await sleep(POLL_MS);
145
+ }
146
+ }
147
+ /** Merge a token WorkBuddy gave into the stored sign-in's fields. */
148
+ export function mergeWorkBuddyToken(base, got) {
149
+ const now = Date.now();
150
+ const out = { ...base, accessToken: String(got.accessToken ?? '') };
151
+ if (typeof got.refreshToken === 'string' && got.refreshToken !== '')
152
+ out.refreshToken = got.refreshToken;
153
+ if (typeof got.domain === 'string' && got.domain !== '')
154
+ out.domain = got.domain;
155
+ if (typeof got.tokenType === 'string' && got.tokenType !== '')
156
+ out.tokenType = got.tokenType;
157
+ const expiresAt = Number(got.expiresAt);
158
+ const expiresIn = Number(got.expiresIn);
159
+ if (expiresAt > 0)
160
+ out.expiresAt = expiresAt;
161
+ else if (expiresIn > 0)
162
+ out.expiresAt = now + expiresIn * 1000;
163
+ const refreshExpiresAt = Number(got.refreshExpiresAt);
164
+ const refreshExpiresIn = Number(got.refreshExpiresIn);
165
+ if (refreshExpiresAt > 0)
166
+ out.refreshExpiresAt = refreshExpiresAt;
167
+ else if (refreshExpiresIn > 0)
168
+ out.refreshExpiresAt = now + refreshExpiresIn * 1000;
169
+ return out;
170
+ }
171
+ /** Poll for the token a finished browser sign-in produced. */
172
+ export async function finishWorkBuddySignIn(start, fetchFn) {
173
+ const deadline = start.expiresAt;
174
+ const query = encodeURIComponent(start.state);
175
+ const tokenData = await workbuddyPoll(`/v2/plugin/auth/token?state=${query}`, {}, [11217], deadline, fetchFn);
176
+ if (typeof tokenData.accessToken !== 'string' || tokenData.accessToken === '') {
177
+ throw new Error('WorkBuddy sent back no token');
178
+ }
179
+ const token = mergeWorkBuddyToken({ accessToken: '', expiresAt: 0 }, tokenData);
180
+ const who = await workbuddyPoll(`/v2/plugin/login/account?state=${query}`, {
181
+ 'Authorization': `Bearer ${token.accessToken}`,
182
+ 'X-Domain': workbuddyDomain(token),
183
+ 'X-No-User-Id': 'true',
184
+ 'X-No-Enterprise-Id': 'true',
185
+ }, [12151], deadline, fetchFn);
186
+ token.uid = typeof who.uid === 'string' ? who.uid : '';
187
+ token.enterpriseId = await workbuddyEnterprise(token, who, fetchFn);
188
+ const name = String(who.nickname ?? who.phoneNumber ?? who.uid ?? '');
189
+ return { token, name };
190
+ }
191
+ /** The domain a token names, else the API host. */
192
+ export function workbuddyDomain(token) {
193
+ return token.domain || new URL(workbuddyApi(WORKBUDDY_ENDPOINT)).host;
194
+ }
195
+ /** The enterprise an account signed in as, when it isn't a personal account. */
196
+ async function workbuddyEnterprise(token, who, fetchFn) {
197
+ const own = typeof who.enterpriseId === 'string' ? who.enterpriseId.trim() : '';
198
+ if (own)
199
+ return own;
200
+ const type = String(who.type ?? '').trim().toLowerCase();
201
+ if (type === '' || type === 'personal')
202
+ return '';
203
+ let list;
204
+ try {
205
+ list = await workbuddyCall(WORKBUDDY_ENDPOINT, 'GET', '/v2/plugin/accounts', { 'Authorization': `Bearer ${token.accessToken}`, 'X-Domain': workbuddyDomain(token) }, undefined, fetchFn);
206
+ }
207
+ catch {
208
+ return '';
209
+ }
210
+ const accounts = isRecord(list) && Array.isArray(list.accounts) ? list.accounts.filter(isRecord) : [];
211
+ const mine = accounts.filter((entry) => entry.uid === who.uid && typeof entry.enterpriseId === 'string' && entry.enterpriseId.trim() !== '');
212
+ const same = mine.filter((entry) => String(entry.type ?? '').trim().toLowerCase() === type);
213
+ const last = mine.filter((entry) => entry.lastLogin);
214
+ const pick = same.length === 1 ? same[0] : last.length === 1 ? last[0] : mine.length === 1 ? mine[0] : undefined;
215
+ return pick && typeof pick.enterpriseId === 'string' ? pick.enterpriseId.trim() : '';
216
+ }
217
+ /** The headers WorkBuddy's desktop app sends with a chat and its account. */
218
+ export function workbuddyHeaders(token) {
219
+ const headers = {
220
+ 'Authorization': `Bearer ${token.accessToken}`,
221
+ 'X-User-Id': token.uid ?? '',
222
+ 'X-Domain': workbuddyDomain(token),
223
+ 'X-Product': 'SaaS',
224
+ 'X-IDE-Type': 'WorkBuddy',
225
+ 'User-Agent': `WorkBuddy/${WORKBUDDY_UA_VERSION}`,
226
+ 'X-Requested-With': 'XMLHttpRequest',
227
+ 'X-Agent-Intent': 'craft',
228
+ 'X-Agent-Purpose': 'conversation',
229
+ 'X-IDE-Name': 'WorkBuddy',
230
+ 'X-IDE-Version': WORKBUDDY_UA_VERSION,
231
+ };
232
+ const enterprise = typeof token.enterpriseId === 'string' ? token.enterpriseId.trim() : '';
233
+ if (enterprise !== '') {
234
+ headers['X-Enterprise-Id'] = enterprise;
235
+ headers['X-Tenant-Id'] = enterprise;
236
+ }
237
+ return headers;
238
+ }
239
+ /** Whether a token is close enough to its end that a request should refresh it. */
240
+ export function workbuddyStale(token) {
241
+ return token.expiresAt > 0 && Date.now() >= token.expiresAt - WORKBUDDY_EARLY_MS;
242
+ }
243
+ /** Whether a token can still be refreshed. */
244
+ export function workbuddyRefreshable(token) {
245
+ if (typeof token.refreshToken !== 'string' || token.refreshToken === '')
246
+ return false;
247
+ return !(token.refreshExpiresAt !== undefined && Date.now() >= token.refreshExpiresAt);
248
+ }
249
+ /** Spend a refresh token for a new access token. */
250
+ export async function refreshWorkBuddyToken(token, fetchFn) {
251
+ const data = await workbuddyCall(WORKBUDDY_ENDPOINT, 'POST', '/v2/plugin/auth/token/refresh', {
252
+ 'X-Refresh-Token': token.refreshToken ?? '',
253
+ 'X-Auth-Refresh-Source': 'plugin',
254
+ 'X-Domain': workbuddyDomain(token),
255
+ ...workbuddyEnterpriseHeaders(token),
256
+ }, {}, fetchFn);
257
+ if (!isRecord(data) || typeof data.accessToken !== 'string' || data.accessToken === '') {
258
+ throw new Error('WorkBuddy gave no refreshed token');
259
+ }
260
+ return mergeWorkBuddyToken(token, data);
261
+ }
262
+ function workbuddyEnterpriseHeaders(token) {
263
+ const enterprise = typeof token.enterpriseId === 'string' ? token.enterpriseId.trim() : '';
264
+ return enterprise === '' ? {} : { 'X-Enterprise-Id': enterprise, 'X-Tenant-Id': enterprise };
265
+ }
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", "workbuddy"];
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", "workbuddy"];
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,17 @@ 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';
26
+ import { createWorkBuddyProvider } from './providers/workbuddy-provider.js';
25
27
  import { ModelPreferences } from './settings.js';
26
28
  /** Plugin id; also the name every effect label and log line is prefixed with. */
27
29
  export const name = 'dsh-plugin-subscription-oauth';
28
30
  /** The provider routes need the LLM runtime; everything else is optional. */
29
31
  export const inject = ['llm'];
30
32
  /** Every route key this plugin accepts in configuration. */
31
- export const PROVIDER_IDS = ['kimi', 'grok', 'antigravity', 'glm', 'ark'];
33
+ export const PROVIDER_IDS = ['kimi', 'grok', 'antigravity', 'glm', 'ark', 'mimo', 'workbuddy'];
32
34
  /** The routes this build can actually register. */
33
- export const IMPLEMENTED_PROVIDER_IDS = ['kimi', 'grok', 'antigravity', 'glm', 'ark'];
35
+ export const IMPLEMENTED_PROVIDER_IDS = ['kimi', 'grok', 'antigravity', 'glm', 'ark', 'mimo', 'workbuddy'];
34
36
  /** Configuration schema, with the config file's own defaults applied. */
35
37
  export const Config = z.object({
36
38
  providers: z.array(z.union([...PROVIDER_IDS])).default([...IMPLEMENTED_PROVIDER_IDS]),
@@ -138,6 +140,27 @@ export function apply(ctx, config = {}) {
138
140
  fetchFn: proxiedFetch,
139
141
  }));
140
142
  break;
143
+ case 'mimo':
144
+ routes.push(createMimoProvider({
145
+ credentialFile,
146
+ streamIdleTimeoutMs,
147
+ preferences,
148
+ resolveAttachments: () => ctx.get('attachments'),
149
+ onCatalogChanged: () => { reannounce('mimo'); },
150
+ onWarn,
151
+ fetchFn: proxiedFetch,
152
+ }));
153
+ break;
154
+ case 'workbuddy':
155
+ routes.push(createWorkBuddyProvider({
156
+ credentialFile,
157
+ streamIdleTimeoutMs,
158
+ preferences,
159
+ onCatalogChanged: () => { reannounce('workbuddy'); },
160
+ onWarn,
161
+ fetchFn: proxiedFetch,
162
+ }));
163
+ break;
141
164
  default:
142
165
  // A configured route with no module yet must not silently disappear.
143
166
  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 {};