dsh-plugin-subscription-oauth 0.8.17 → 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.
@@ -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
+ }
@@ -47,9 +47,9 @@ export function FastModeButton({ call, isOwnProvider, modelDirectory, useProject
47
47
  .then((result) => {
48
48
  if (!live)
49
49
  return;
50
- setPair(result?.fast === undefined
51
- ? undefined
52
- : { wireId: result.fast, ...result.fastBase === undefined ? {} : { baseId: result.fastBase } });
50
+ // The rung is the provider's own word (xAI `lightspeed`, Kimi `highspeed`), so
51
+ // the picker reads a name the model's own docs use, not a generic "Fast".
52
+ setPair(result?.fast === undefined ? undefined : { rung: result.fastRung ?? 'fast' });
53
53
  })
54
54
  .catch(() => { if (live)
55
55
  setPair(undefined); });
@@ -59,10 +59,9 @@ export function FastModeButton({ call, isOwnProvider, modelDirectory, useProject
59
59
  useEffect(() => { setFailure(undefined); }, [model]);
60
60
  if (pair === undefined || provider === undefined || model === undefined || modelDirectory?.select === undefined)
61
61
  return null;
62
- // Fast mode is the row's `fast` reasoning rung, so the model id never changes and the
62
+ // Fast mode is the row's fast reasoning rung, so the model id never changes and the
63
63
  // picker always shows the row the user can switch back to.
64
- const active = (selection?.next ?? selection?.lastUsed)?.reasoningEffort === 'fast';
65
- // The row the rung belongs to: what the picker selected, or the pair's own base.
64
+ const active = (selection?.next ?? selection?.lastUsed)?.reasoningEffort === pair.rung;
66
65
  const target = model;
67
66
  const base = active
68
67
  ? 'Fast mode is on; press to go back to the standard model'
@@ -76,7 +75,7 @@ export function FastModeButton({ call, isOwnProvider, modelDirectory, useProject
76
75
  setFailure(undefined);
77
76
  // Turning it off drops the rung, which the host then reads as the model's own
78
77
  // default. The model id is never swapped, so this cannot strand the selection.
79
- const next = active ? { provider, model: target } : { provider, model: target, reasoningEffort: 'fast' };
78
+ const next = active ? { provider, model: target } : { provider, model: target, reasoningEffort: pair.rung };
80
79
  void Promise.resolve(modelDirectory.select?.(next))
81
80
  .then((result) => {
82
81
  // The directory reports its own outcome: a refused selection carries why.
package/lib/client.js CHANGED
@@ -2796,10 +2796,7 @@ function FastModeButton({ call, isOwnProvider, modelDirectory, useProjection })
2796
2796
  id: model
2797
2797
  }).then((result) => {
2798
2798
  if (!live) return;
2799
- setPair(result?.fast === void 0 ? void 0 : {
2800
- wireId: result.fast,
2801
- ...result.fastBase === void 0 ? {} : { baseId: result.fastBase }
2802
- });
2799
+ setPair(result?.fast === void 0 ? void 0 : { rung: result.fastRung ?? "fast" });
2803
2800
  }).catch(() => {
2804
2801
  if (live) setPair(void 0);
2805
2802
  });
@@ -2816,7 +2813,7 @@ function FastModeButton({ call, isOwnProvider, modelDirectory, useProjection })
2816
2813
  setFailure(void 0);
2817
2814
  }, [model]);
2818
2815
  if (pair === void 0 || provider === void 0 || model === void 0 || modelDirectory?.select === void 0) return null;
2819
- const active = (selection?.next ?? selection?.lastUsed)?.reasoningEffort === "fast";
2816
+ const active = (selection?.next ?? selection?.lastUsed)?.reasoningEffort === pair.rung;
2820
2817
  const target = model;
2821
2818
  const base = active ? "Fast mode is on; press to go back to the standard model" : "Fast mode: switch to this model’s fast variant";
2822
2819
  const title = failure === void 0 ? base : `${base}\nThe switch was refused — ${`${failure.code ?? ""} ${failure.message}`.trim()}`;
@@ -2840,7 +2837,7 @@ function FastModeButton({ call, isOwnProvider, modelDirectory, useProjection })
2840
2837
  } : {
2841
2838
  provider,
2842
2839
  model: target,
2843
- reasoningEffort: "fast"
2840
+ reasoningEffort: pair.rung
2844
2841
  };
2845
2842
  Promise.resolve(modelDirectory.select?.(next)).then((result) => {
2846
2843
  const outcome = result;