dsh-plugin-subscription-oauth 0.16.3 → 0.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.dev.md +1 -1
  2. package/README.md +7 -6
  3. package/README.zh.dev.md +1 -1
  4. package/README.zh.md +7 -6
  5. package/lib/client/FastModeButton.d.ts +0 -1
  6. package/lib/client/FastModeButton.js +48 -11
  7. package/lib/client.js +32 -9
  8. package/lib/client.js.map +1 -1
  9. package/lib/index.js +21 -0
  10. package/lib/protocol.d.ts +9 -0
  11. package/lib/providers/antigravity.d.ts +34 -4
  12. package/lib/providers/antigravity.js +100 -28
  13. package/lib/providers/ark-provider.d.ts +10 -0
  14. package/lib/providers/ark-provider.js +16 -2
  15. package/lib/providers/ark.d.ts +104 -10
  16. package/lib/providers/ark.js +186 -48
  17. package/lib/providers/catalog.d.ts +92 -0
  18. package/lib/providers/catalog.js +191 -0
  19. package/lib/providers/common.d.ts +14 -0
  20. package/lib/providers/common.js +23 -0
  21. package/lib/providers/glm-provider.d.ts +10 -0
  22. package/lib/providers/glm-provider.js +16 -2
  23. package/lib/providers/glm.d.ts +100 -16
  24. package/lib/providers/glm.js +182 -85
  25. package/lib/providers/grok.d.ts +114 -27
  26. package/lib/providers/grok.js +330 -129
  27. package/lib/providers/kimi-provider.d.ts +10 -0
  28. package/lib/providers/kimi-provider.js +16 -4
  29. package/lib/providers/kimi.d.ts +122 -32
  30. package/lib/providers/kimi.js +243 -122
  31. package/lib/providers/mimo-provider.d.ts +14 -1
  32. package/lib/providers/mimo-provider.js +17 -6
  33. package/lib/providers/mimo.d.ts +164 -19
  34. package/lib/providers/mimo.js +318 -19
  35. package/lib/providers/model-cache.d.ts +57 -0
  36. package/lib/providers/model-cache.js +105 -0
  37. package/lib/providers/responses-stream.d.ts +8 -0
  38. package/lib/providers/responses-stream.js +21 -5
  39. package/lib/providers/workbuddy-provider.d.ts +17 -0
  40. package/lib/providers/workbuddy-provider.js +14 -6
  41. package/lib/providers/workbuddy.d.ts +140 -13
  42. package/lib/providers/workbuddy.js +501 -59
  43. package/package.json +1 -1
@@ -2,14 +2,34 @@
2
2
  * The Xiaomi MiMo wire adapter serving the `mimo` route.
3
3
  *
4
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.
5
+ * session cookies the sign-on set — not a bearer token. The open platform's Token
6
+ * Plan spends its own `tp-` key at a separate endpoint; that fallback lives beside
7
+ * the account-level stream here.
8
+ *
9
+ * The model table this route publishes is MiMo's own listing, read once and kept in
10
+ * this plugin's cache (see `hydrate` and `fetchCatalog`), so a model released, renamed
11
+ * or retired server-side reaches the picker without a plugin update and without a
12
+ * request per refresh. `MIMO_MODELS` is the cold-start seed used while no listing has
13
+ * been read, the metadata overlay a live row is merged against, and the fallback a
14
+ * listing that does not answer leaves the picker with — never the authority on which
15
+ * models exist.
16
+ *
17
+ * Two bases are involved, and they are not the same surface:
18
+ * - the app surface, `<session.apiBase>/route/chat/completions`, which the stored
19
+ * session's cookies sign; and
20
+ * - the documented open-platform API at {@link MIMO_PUBLIC_API_BASE}, whose
21
+ * `/v1/models` route is documented to answer an OpenAI-style listing.
22
+ * The listing is therefore best-effort: it may name the public ids (`mimo-v2.5-pro`, …)
23
+ * rather than the app's (`mimo-pro`, …), and it may refuse the session credential
24
+ * outright. Either way the roster the seed describes stays until a listing answers.
25
+ * The endpoint is documented rather than probed from this machine — no MiMo account is
26
+ * stored here — so the first real read is the verification.
8
27
  *
9
28
  * @module dsh-plugin-subscription-oauth/providers/mimo
10
29
  */
11
30
  import { LlmAdapter, type GenerateOptions, type LlmModelInfo, type LlmProviderInfo, type LlmResolvedModelInfo, type StreamChunk } from '@deepseek-ai/dsh-llm';
12
31
  import { type FetchFn } from '../http.js';
32
+ import type { CatalogEntry } from './catalog.js';
13
33
  import type { TokenManager } from '../auth/tokens.js';
14
34
  import type { AttachmentStore } from '@deepseek-ai/dsh-attachment';
15
35
  /** The `mimo` provider route key. */
@@ -24,6 +44,17 @@ export declare const MIMO_CONTEXT_WINDOW = 1000000;
24
44
  export declare const MIMO_DEFAULT_MAX_TOKENS = 128000;
25
45
  /** The model the picker preselects. */
26
46
  export declare const MIMO_DEFAULT_MODEL = "mimo-pro";
47
+ /**
48
+ * The documented open-platform API base.
49
+ *
50
+ * This is the host Xiaomi documents its List Models endpoint on, and it is *not* the
51
+ * app surface a stored session chats on (`{session.apiBase}/route/chat/completions`,
52
+ * per account and per region). The adapter's `apiBase` option overrides it, so a
53
+ * listing that turns out to live on another host needs no code change.
54
+ */
55
+ export declare const MIMO_PUBLIC_API_BASE = "https://api.xiaomimimo.com";
56
+ /** Path appended to the base to list the models an account can reach. */
57
+ export declare const MIMO_MODELS_PATH = "/v1/models";
27
58
  /** One signed-in MiMo account, as stored. */
28
59
  export interface MimoSession {
29
60
  accessToken: string;
@@ -42,45 +73,160 @@ export interface MimoSession {
42
73
  }
43
74
  /** Validate one stored value as a MiMo session, or undefined when it is not one. */
44
75
  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. */
76
+ /** One catalog row exposed as a selectable model. */
77
+ export type MimoModelEntry = CatalogEntry;
78
+ /**
79
+ * The MiMo *app* surface's models, as this build last knew them.
80
+ *
81
+ * A cold-start seed and a metadata overlay, not the catalog. It decides membership
82
+ * only while no listing has been read, it supplies the name, window and description a
83
+ * live row is silent about (`liveEntry` — a figure the listing states always wins),
84
+ * and it is what a listing that does not answer falls back to, so a refused probe can
85
+ * never empty the picker.
86
+ *
87
+ * Its ids (`mimo-pro`, …) belong to the app surface reached at `{session.apiBase}/route`.
88
+ * The documented public API is a different surface and may name different ids
89
+ * (`mimo-v2.5-pro`, …); a listing that returns those replaces this table rather than
90
+ * adding to it, which is the whole point of reading the listing at all.
91
+ */
53
92
  export declare const MIMO_MODELS: readonly MimoModelEntry[];
54
93
  /** Constructor dependencies for {@link MimoAdapter}. */
55
94
  export interface MimoAdapterOptions {
56
- /** MiMo server base; `<base>/route/chat/completions` is the endpoint. */
95
+ /**
96
+ * Base the model listing is read from; defaults to {@link MIMO_PUBLIC_API_BASE}.
97
+ *
98
+ * The chat call does not use it: that one goes to the signed-in account's own
99
+ * `apiBase`, which is a per-region app host rather than the documented API.
100
+ */
57
101
  apiBase?: string;
102
+ /** Consult MiMo's own live model listing. Defaults to on. */
103
+ discovery?: boolean;
58
104
  streamIdleTimeoutMs: number;
59
105
  tokens: TokenManager<MimoSession>;
60
106
  onWarn?: (message: string) => void;
61
107
  resolveAttachments?: () => AttachmentStore | undefined;
62
108
  hiddenModels?: () => Promise<ReadonlySet<string>>;
63
109
  userModels?: () => Promise<readonly string[]>;
110
+ /**
111
+ * MiMo's last live listing, from this plugin's cache.
112
+ *
113
+ * Read once per session, before the first catalog query, so the picker offers real
114
+ * models without asking the endpoint anything. A route that fetched once and cached
115
+ * the answer has no reason to fetch again on every refresh; the Settings "refresh
116
+ * models" action is what re-reads it.
117
+ */
118
+ cachedListing?: () => Promise<unknown>;
119
+ /** Record what a live listing just produced, replacing the cached table. */
120
+ saveListing?: (payload: unknown) => Promise<void>;
64
121
  fetchFn?: FetchFn;
65
122
  }
66
123
  /** The MiMo wire adapter serving the `mimo` route. */
67
124
  export declare class MimoAdapter extends LlmAdapter {
68
125
  private readonly options;
69
126
  private readonly fetchFn;
127
+ /**
128
+ * The catalog this route serves: entries derived from MiMo's own listing, either
129
+ * the one just read or the one the cache kept from the last read.
130
+ *
131
+ * Undefined means no listing has been read (yet), so {@link MIMO_MODELS} decides
132
+ * membership and the picker is never empty.
133
+ */
134
+ private live;
135
+ /** Whether the cached listing has been read; it is read at most once per session. */
136
+ private hydrated;
137
+ /** Ids the user added by hand, cached so the sync catalog can name them. */
138
+ private userAddedIds;
139
+ /**
140
+ * Listing failures already reported, so one reason is stated once per session.
141
+ *
142
+ * A listing that does not answer is expected — the documented public API and the
143
+ * app surface are different hosts — and saying so on every refresh would drown out
144
+ * the warnings a user can act on.
145
+ */
146
+ private readonly reportedListingFailures;
70
147
  constructor(options: MimoAdapterOptions);
148
+ /**
149
+ * Adopt the rows of one live listing as this route's catalog.
150
+ *
151
+ * Everything the picker shows is derived here from MiMo's own answer: the ids it
152
+ * names, in its own order, with the name, window, modalities and ladder it states.
153
+ * {@link MIMO_MODELS} is consulted only as the metadata overlay for a figure the
154
+ * row is silent about, so a live figure is never overridden by a shipped guess.
155
+ *
156
+ * @param rows - decoded rows of the listing, in the listing's order.
157
+ * @returns the entries to publish, in picker order.
158
+ */
159
+ private adoptListing;
160
+ /**
161
+ * Read the cached listing, once, so the picker works without asking the endpoint.
162
+ *
163
+ * Called before the first catalog query. A read failure is a cache miss, never a
164
+ * failure: the route simply has no listing until one is fetched, and the seed roster
165
+ * is what the picker shows in the meantime.
166
+ */
167
+ hydrate(): Promise<void>;
71
168
  providerInfo(provider: string): LlmProviderInfo;
72
169
  listModels(_provider: string): Promise<readonly LlmModelInfo[]>;
73
170
  private hidden;
74
171
  resolveModel(_provider: string, model: string): Promise<LlmResolvedModelInfo>;
75
172
  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
- }[];
173
+ /** The catalog the Settings list reads: the route's own entries plus hand-added ids. */
174
+ catalogEntries(): readonly MimoModelEntry[];
82
175
  /** The ids the route itself serves, without hand-added extras. */
83
176
  ownCatalogEntries(): readonly MimoModelEntry[];
177
+ /**
178
+ * The catalog served: the membership the listing named — or the seed roster while no
179
+ * listing has been read — each merged with the metadata of the source that described
180
+ * it, then the ids the user added by hand.
181
+ */
182
+ private catalog;
183
+ /**
184
+ * What the route itself serves, without the ids added by hand.
185
+ *
186
+ * No model is injected here: the configured default is an app-surface id, and the
187
+ * listing may come from the public API, where that id does not exist. What the
188
+ * listing names is exactly what the picker offers; a configured default that the
189
+ * listing does not name stays dispatchable through {@link resolveModel}.
190
+ */
191
+ private ownCatalog;
192
+ /** Entries for ids the user added by hand that the route does not already serve. */
193
+ private addedEntries;
194
+ /** Re-read the ids the user added by hand. */
195
+ private syncUserModels;
196
+ /**
197
+ * Adopt the ids a caller reports as the served set, without a listing.
198
+ *
199
+ * {@link fetchCatalog} already derived the entries from the rows it read, and the
200
+ * controller reports those same ids back here; the entries it recorded are kept, so
201
+ * a reported id keeps the metadata the listing gave it. A caller that only has ids (a
202
+ * test, another discovery path) gets a conservative entry overlaid with the seed.
203
+ *
204
+ * @param ids - model ids the caller reports.
205
+ */
206
+ discover(ids: readonly string[]): void;
207
+ /**
208
+ * Read MiMo's model listing and adopt it as this route's catalog.
209
+ *
210
+ * The documented endpoint is `GET <apiBase>/v1/models`, answering an OpenAI-style
211
+ * `{ object: 'list', data: [{ id, object, owned_by }] }`. Xiaomi documents either an
212
+ * `api-key` header or Bearer authentication; this sends the bearer for the account
213
+ * the caller resolved, plus the client identity the chat call uses.
214
+ *
215
+ * The read is best-effort by design. The documented public API is not the app surface
216
+ * the chat call goes to, so the answer may be a refusal, a body in another shape, or
217
+ * rows naming ids the app does not serve; in every one of those cases the roster
218
+ * already published stays exactly as it was, the failure is stated once through
219
+ * {@link MimoAdapterOptions.onWarn}, and the picker is never emptied by a probe.
220
+ *
221
+ * @param accessToken - bearer for the account, as the controller resolved it.
222
+ * @param signal - optional cancellation.
223
+ * @returns the model ids now served, or undefined when the listing is unavailable.
224
+ */
225
+ fetchCatalog(accessToken: string, signal?: AbortSignal): Promise<readonly string[] | undefined>;
226
+ /** The base the listing is read from, without a trailing slash. */
227
+ private listingBase;
228
+ /** State one listing failure, once per distinct reason. */
229
+ private warnListingFailure;
84
230
  stream(options: GenerateOptions): AsyncIterable<StreamChunk>;
85
231
  /** The app's names for its models, as the Token Plan's endpoint names them. */
86
232
  private static readonly TP_MODELS;
@@ -103,4 +249,3 @@ export declare class MimoAdapter extends LlmAdapter {
103
249
  private toChatMessage;
104
250
  private readStream;
105
251
  }
106
- export {};
@@ -2,15 +2,35 @@
2
2
  * The Xiaomi MiMo wire adapter serving the `mimo` route.
3
3
  *
4
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.
5
+ * session cookies the sign-on set — not a bearer token. The open platform's Token
6
+ * Plan spends its own `tp-` key at a separate endpoint; that fallback lives beside
7
+ * the account-level stream here.
8
+ *
9
+ * The model table this route publishes is MiMo's own listing, read once and kept in
10
+ * this plugin's cache (see `hydrate` and `fetchCatalog`), so a model released, renamed
11
+ * or retired server-side reaches the picker without a plugin update and without a
12
+ * request per refresh. `MIMO_MODELS` is the cold-start seed used while no listing has
13
+ * been read, the metadata overlay a live row is merged against, and the fallback a
14
+ * listing that does not answer leaves the picker with — never the authority on which
15
+ * models exist.
16
+ *
17
+ * Two bases are involved, and they are not the same surface:
18
+ * - the app surface, `<session.apiBase>/route/chat/completions`, which the stored
19
+ * session's cookies sign; and
20
+ * - the documented open-platform API at {@link MIMO_PUBLIC_API_BASE}, whose
21
+ * `/v1/models` route is documented to answer an OpenAI-style listing.
22
+ * The listing is therefore best-effort: it may name the public ids (`mimo-v2.5-pro`, …)
23
+ * rather than the app's (`mimo-pro`, …), and it may refuse the session credential
24
+ * outright. Either way the roster the seed describes stays until a listing answers.
25
+ * The endpoint is documented rather than probed from this machine — no MiMo account is
26
+ * stored here — so the first real read is the verification.
8
27
  *
9
28
  * @module dsh-plugin-subscription-oauth/providers/mimo
10
29
  */
11
30
  import { LlmAdapter, errorChain, } from '@deepseek-ai/dsh-llm';
12
31
  import { httpLlmError, isMissingOrInvalidCredential, mapFetchFailure, idleWatchdog } from './common.js';
13
32
  import { proxiedFetch } from '../http.js';
33
+ import { composeCatalog, fallbackEntry, findEntry, liveEntry, liveModelFacts, resolveCatalog } from './catalog.js';
14
34
  import { readMimoPlanKey, refreshMimoSession } from '../auth/mimo-flow.js';
15
35
  /** The `mimo` provider route key. */
16
36
  export const MIMO_PROVIDER_ID = 'mimo';
@@ -24,6 +44,30 @@ export const MIMO_CONTEXT_WINDOW = 1_000_000;
24
44
  export const MIMO_DEFAULT_MAX_TOKENS = 128_000;
25
45
  /** The model the picker preselects. */
26
46
  export const MIMO_DEFAULT_MODEL = 'mimo-pro';
47
+ /**
48
+ * The documented open-platform API base.
49
+ *
50
+ * This is the host Xiaomi documents its List Models endpoint on, and it is *not* the
51
+ * app surface a stored session chats on (`{session.apiBase}/route/chat/completions`,
52
+ * per account and per region). The adapter's `apiBase` option overrides it, so a
53
+ * listing that turns out to live on another host needs no code change.
54
+ */
55
+ export const MIMO_PUBLIC_API_BASE = 'https://api.xiaomimimo.com';
56
+ /** Path appended to the base to list the models an account can reach. */
57
+ export const MIMO_MODELS_PATH = '/v1/models';
58
+ /**
59
+ * Client identity the MiMo app sends, shared by the chat call and the listing read.
60
+ *
61
+ * The app gateway recognizes its own client; sending the same words on the listing
62
+ * costs nothing and keeps the two requests from drifting apart.
63
+ */
64
+ const MIMO_CLIENT_HEADERS = {
65
+ 'X-Mimo-Source': 'mimocode-cli-free',
66
+ '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',
67
+ 'X-Client-Version': '26.929.292248',
68
+ };
69
+ /** Description shown for a model only the listing names. */
70
+ const MIMO_LISTING_DESCRIPTION = 'Reported by the MiMo model list';
27
71
  /** Validate one stored value as a MiMo session, or undefined when it is not one. */
28
72
  export function parseMimoSession(value) {
29
73
  if (typeof value !== 'object' || value === null)
@@ -51,33 +95,120 @@ export function parseMimoSession(value) {
51
95
  ...typeof record.region === 'string' ? { region: record.region } : {},
52
96
  };
53
97
  }
54
- /** The two models the MiMo app serves. */
98
+ /**
99
+ * The MiMo *app* surface's models, as this build last knew them.
100
+ *
101
+ * A cold-start seed and a metadata overlay, not the catalog. It decides membership
102
+ * only while no listing has been read, it supplies the name, window and description a
103
+ * live row is silent about (`liveEntry` — a figure the listing states always wins),
104
+ * and it is what a listing that does not answer falls back to, so a refused probe can
105
+ * never empty the picker.
106
+ *
107
+ * Its ids (`mimo-pro`, …) belong to the app surface reached at `{session.apiBase}/route`.
108
+ * The documented public API is a different surface and may name different ids
109
+ * (`mimo-v2.5-pro`, …); a listing that returns those replaces this table rather than
110
+ * adding to it, which is the whole point of reading the listing at all.
111
+ */
55
112
  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 },
113
+ {
114
+ id: 'mimo-pro',
115
+ name: 'MiMo Pro',
116
+ description: 'Flagship MiMo model',
117
+ contextWindow: MIMO_CONTEXT_WINDOW,
118
+ inputModalities: ['text', 'image'],
119
+ },
120
+ {
121
+ id: 'mimo-flash',
122
+ name: 'MiMo Flash',
123
+ description: 'Fast MiMo model',
124
+ contextWindow: MIMO_CONTEXT_WINDOW,
125
+ inputModalities: ['text', 'image'],
126
+ },
58
127
  ];
59
128
  /** The MiMo wire adapter serving the `mimo` route. */
60
129
  export class MimoAdapter extends LlmAdapter {
61
130
  options;
62
131
  fetchFn;
132
+ /**
133
+ * The catalog this route serves: entries derived from MiMo's own listing, either
134
+ * the one just read or the one the cache kept from the last read.
135
+ *
136
+ * Undefined means no listing has been read (yet), so {@link MIMO_MODELS} decides
137
+ * membership and the picker is never empty.
138
+ */
139
+ live;
140
+ /** Whether the cached listing has been read; it is read at most once per session. */
141
+ hydrated = false;
142
+ /** Ids the user added by hand, cached so the sync catalog can name them. */
143
+ userAddedIds = [];
144
+ /**
145
+ * Listing failures already reported, so one reason is stated once per session.
146
+ *
147
+ * A listing that does not answer is expected — the documented public API and the
148
+ * app surface are different hosts — and saying so on every refresh would drown out
149
+ * the warnings a user can act on.
150
+ */
151
+ reportedListingFailures = new Set();
63
152
  constructor(options) {
64
153
  super();
65
154
  this.options = options;
66
155
  this.fetchFn = options.fetchFn ?? proxiedFetch;
67
156
  }
157
+ /**
158
+ * Adopt the rows of one live listing as this route's catalog.
159
+ *
160
+ * Everything the picker shows is derived here from MiMo's own answer: the ids it
161
+ * names, in its own order, with the name, window, modalities and ladder it states.
162
+ * {@link MIMO_MODELS} is consulted only as the metadata overlay for a figure the
163
+ * row is silent about, so a live figure is never overridden by a shipped guess.
164
+ *
165
+ * @param rows - decoded rows of the listing, in the listing's order.
166
+ * @returns the entries to publish, in picker order.
167
+ */
168
+ adoptListing(rows) {
169
+ return rows
170
+ .map((row) => liveModelFacts(row))
171
+ .filter((facts) => facts !== undefined)
172
+ .map((facts) => liveEntry(facts, MIMO_MODELS, MIMO_CONTEXT_WINDOW, MIMO_LISTING_DESCRIPTION));
173
+ }
174
+ /**
175
+ * Read the cached listing, once, so the picker works without asking the endpoint.
176
+ *
177
+ * Called before the first catalog query. A read failure is a cache miss, never a
178
+ * failure: the route simply has no listing until one is fetched, and the seed roster
179
+ * is what the picker shows in the meantime.
180
+ */
181
+ async hydrate() {
182
+ if (this.hydrated)
183
+ return;
184
+ this.hydrated = true;
185
+ if (this.live !== undefined || this.options.cachedListing === undefined)
186
+ return;
187
+ try {
188
+ const rows = listingRows(await this.options.cachedListing());
189
+ // A live read that landed while the cache was being read is newer than the cache.
190
+ if (rows.length > 0 && this.live === undefined)
191
+ this.live = this.adoptListing(rows);
192
+ }
193
+ catch (error) {
194
+ this.options.onWarn?.(`cached MiMo model catalog unavailable (${errorChain(error)})`);
195
+ }
196
+ }
68
197
  providerInfo(provider) {
69
198
  return { id: provider, name: MIMO_PROVIDER_NAME };
70
199
  }
71
200
  async listModels(_provider) {
72
201
  if (await this.options.tokens.session() === undefined)
73
202
  return [];
203
+ await this.hydrate();
204
+ await this.syncUserModels();
74
205
  const hidden = await this.hidden();
75
- return MIMO_MODELS.filter((entry) => !hidden.has(entry.id)).map((entry) => ({
206
+ return resolveCatalog(this.catalog(), [], hidden).entries.map((entry) => ({
76
207
  provider: MIMO_PROVIDER_ID,
77
208
  id: entry.id,
78
209
  name: entry.name,
79
- description: entry.description,
80
- inputModalities: ['text', 'image'],
210
+ ...entry.description === undefined ? {} : { description: entry.description },
211
+ ...entry.inputModalities === undefined ? {} : { inputModalities: entry.inputModalities },
81
212
  }));
82
213
  }
83
214
  async hidden() {
@@ -91,8 +222,13 @@ export class MimoAdapter extends LlmAdapter {
91
222
  }
92
223
  }
93
224
  async resolveModel(_provider, model) {
94
- const entry = MIMO_MODELS.find((candidate) => candidate.id === model);
225
+ await this.hydrate();
226
+ await this.syncUserModels();
227
+ const entry = findEntry(this.catalog(), model);
95
228
  if (entry === undefined) {
229
+ // An id this build has never heard of is still dispatchable: the endpoint is the
230
+ // authority on what it serves, and a model released after this build should not
231
+ // be refused. It inherits the advertised window and the app's modalities.
96
232
  return {
97
233
  provider: MIMO_PROVIDER_ID,
98
234
  id: model,
@@ -106,22 +242,162 @@ export class MimoAdapter extends LlmAdapter {
106
242
  provider: MIMO_PROVIDER_ID,
107
243
  id: entry.id,
108
244
  name: entry.name,
109
- description: entry.description,
245
+ ...entry.description === undefined ? {} : { description: entry.description },
110
246
  context: { contextWindow: entry.contextWindow },
111
247
  defaultMaxTokens: MIMO_DEFAULT_MAX_TOKENS,
112
- inputModalities: ['text', 'image'],
248
+ ...entry.inputModalities === undefined ? {} : { inputModalities: entry.inputModalities },
113
249
  };
114
250
  }
115
251
  providerRetryPolicy() {
116
252
  return undefined;
117
253
  }
118
- /** The catalog the Settings list reads: every shipped model. */
254
+ /** The catalog the Settings list reads: the route's own entries plus hand-added ids. */
119
255
  catalogEntries() {
120
- return MIMO_MODELS;
256
+ return this.catalog();
121
257
  }
122
258
  /** The ids the route itself serves, without hand-added extras. */
123
259
  ownCatalogEntries() {
124
- return MIMO_MODELS;
260
+ return this.ownCatalog();
261
+ }
262
+ /**
263
+ * The catalog served: the membership the listing named — or the seed roster while no
264
+ * listing has been read — each merged with the metadata of the source that described
265
+ * it, then the ids the user added by hand.
266
+ */
267
+ catalog() {
268
+ return [...this.ownCatalog(), ...this.addedEntries()];
269
+ }
270
+ /**
271
+ * What the route itself serves, without the ids added by hand.
272
+ *
273
+ * No model is injected here: the configured default is an app-surface id, and the
274
+ * listing may come from the public API, where that id does not exist. What the
275
+ * listing names is exactly what the picker offers; a configured default that the
276
+ * listing does not name stays dispatchable through {@link resolveModel}.
277
+ */
278
+ ownCatalog() {
279
+ return composeCatalog({
280
+ curated: MIMO_MODELS,
281
+ ...this.live === undefined ? {} : { live: this.live },
282
+ fallbackContextWindow: MIMO_CONTEXT_WINDOW,
283
+ liveDescription: MIMO_LISTING_DESCRIPTION,
284
+ });
285
+ }
286
+ /** Entries for ids the user added by hand that the route does not already serve. */
287
+ addedEntries() {
288
+ if (this.userAddedIds.length === 0)
289
+ return [];
290
+ const known = new Set(this.ownCatalog().map((entry) => entry.id));
291
+ return this.userAddedIds
292
+ .filter((id) => !known.has(id))
293
+ .map((id) => fallbackEntry(id, 'Added by hand in Settings', MIMO_CONTEXT_WINDOW));
294
+ }
295
+ /** Re-read the ids the user added by hand. */
296
+ async syncUserModels() {
297
+ if (this.options.userModels === undefined)
298
+ return;
299
+ try {
300
+ this.userAddedIds = await this.options.userModels();
301
+ }
302
+ catch (error) {
303
+ this.options.onWarn?.(`user-added models unavailable (${errorChain(error)})`);
304
+ this.userAddedIds = [];
305
+ }
306
+ }
307
+ /**
308
+ * Adopt the ids a caller reports as the served set, without a listing.
309
+ *
310
+ * {@link fetchCatalog} already derived the entries from the rows it read, and the
311
+ * controller reports those same ids back here; the entries it recorded are kept, so
312
+ * a reported id keeps the metadata the listing gave it. A caller that only has ids (a
313
+ * test, another discovery path) gets a conservative entry overlaid with the seed.
314
+ *
315
+ * @param ids - model ids the caller reports.
316
+ */
317
+ discover(ids) {
318
+ const known = new Map((this.live ?? []).map((entry) => [entry.id, entry]));
319
+ this.live = ids.map((id) => known.get(id)
320
+ ?? liveEntry({ id }, MIMO_MODELS, MIMO_CONTEXT_WINDOW, MIMO_LISTING_DESCRIPTION));
321
+ }
322
+ /**
323
+ * Read MiMo's model listing and adopt it as this route's catalog.
324
+ *
325
+ * The documented endpoint is `GET <apiBase>/v1/models`, answering an OpenAI-style
326
+ * `{ object: 'list', data: [{ id, object, owned_by }] }`. Xiaomi documents either an
327
+ * `api-key` header or Bearer authentication; this sends the bearer for the account
328
+ * the caller resolved, plus the client identity the chat call uses.
329
+ *
330
+ * The read is best-effort by design. The documented public API is not the app surface
331
+ * the chat call goes to, so the answer may be a refusal, a body in another shape, or
332
+ * rows naming ids the app does not serve; in every one of those cases the roster
333
+ * already published stays exactly as it was, the failure is stated once through
334
+ * {@link MimoAdapterOptions.onWarn}, and the picker is never emptied by a probe.
335
+ *
336
+ * @param accessToken - bearer for the account, as the controller resolved it.
337
+ * @param signal - optional cancellation.
338
+ * @returns the model ids now served, or undefined when the listing is unavailable.
339
+ */
340
+ async fetchCatalog(accessToken, signal) {
341
+ if (this.options.discovery === false)
342
+ return undefined;
343
+ try {
344
+ const response = await this.fetchFn(`${this.listingBase()}${MIMO_MODELS_PATH}`, {
345
+ headers: {
346
+ 'authorization': `Bearer ${accessToken}`,
347
+ 'accept': 'application/json',
348
+ ...MIMO_CLIENT_HEADERS,
349
+ },
350
+ ...signal === undefined ? {} : { signal },
351
+ });
352
+ if (!response.ok) {
353
+ this.warnListingFailure(`Xiaomi MiMo model list unavailable (HTTP ${response.status}); keeping the built-in roster`);
354
+ return undefined;
355
+ }
356
+ let payload;
357
+ try {
358
+ payload = await response.json();
359
+ }
360
+ catch (error) {
361
+ this.warnListingFailure(`Xiaomi MiMo model list could not be read (${errorChain(error)}); keeping the built-in roster`);
362
+ return undefined;
363
+ }
364
+ const rows = listingRows(payload);
365
+ const entries = this.adoptListing(rows);
366
+ if (entries.length === 0) {
367
+ this.warnListingFailure('Xiaomi MiMo model list named no usable models; keeping the built-in roster');
368
+ return undefined;
369
+ }
370
+ this.live = entries;
371
+ // The cached table is the endpoint's answer, kept so the next session does not ask
372
+ // again. A cache failure never fails the read that produced it.
373
+ if (this.options.saveListing !== undefined) {
374
+ try {
375
+ await this.options.saveListing(rows);
376
+ }
377
+ catch (error) {
378
+ this.options.onWarn?.(`model catalog cache write failed (${errorChain(error)})`);
379
+ }
380
+ }
381
+ return entries.map((entry) => entry.id);
382
+ }
383
+ catch (error) {
384
+ if (signal?.aborted === true)
385
+ return undefined;
386
+ this.warnListingFailure(`Xiaomi MiMo model list unavailable; keeping the built-in roster (${errorChain(error)})`);
387
+ return undefined;
388
+ }
389
+ }
390
+ /** The base the listing is read from, without a trailing slash. */
391
+ listingBase() {
392
+ const configured = this.options.apiBase?.trim();
393
+ return (configured === undefined || configured === '' ? MIMO_PUBLIC_API_BASE : configured).replace(/\/+$/, '');
394
+ }
395
+ /** State one listing failure, once per distinct reason. */
396
+ warnListingFailure(message) {
397
+ if (this.reportedListingFailures.has(message))
398
+ return;
399
+ this.reportedListingFailures.add(message);
400
+ this.options.onWarn?.(message);
125
401
  }
126
402
  async *stream(options) {
127
403
  const held = await this.options.tokens.session();
@@ -138,9 +414,7 @@ export class MimoAdapter extends LlmAdapter {
138
414
  'Content-Type': 'application/json',
139
415
  'Accept': 'text/event-stream',
140
416
  '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',
417
+ ...MIMO_CLIENT_HEADERS,
144
418
  },
145
419
  body: JSON.stringify(body),
146
420
  signal: watchdog.signal,
@@ -271,6 +545,10 @@ export class MimoAdapter extends LlmAdapter {
271
545
  const decoder = new TextDecoder();
272
546
  let buffer = '';
273
547
  let started = false;
548
+ // `block-end` carries the *assembled* block, and the harness takes that one as the
549
+ // block's final content - it does not keep what the deltas accumulated. An empty text
550
+ // there renders the whole answer as a blank body, whatever streamed before it.
551
+ let text = '';
274
552
  try {
275
553
  while (true) {
276
554
  const { done, value } = await reader.read();
@@ -301,6 +579,7 @@ export class MimoAdapter extends LlmAdapter {
301
579
  yield { type: 'block-start', index: 0, blockType: 'text' };
302
580
  started = true;
303
581
  }
582
+ text += delta;
304
583
  yield { type: 'text-delta', index: 0, text: delta };
305
584
  }
306
585
  const usage = chunk.usage;
@@ -314,8 +593,10 @@ export class MimoAdapter extends LlmAdapter {
314
593
  }
315
594
  }
316
595
  }
596
+ // Close the text block with what streamed into it: `block-end` is the block the
597
+ // harness keeps, so an empty one would drop the whole answer.
317
598
  if (started)
318
- yield { type: 'block-end', index: 0, block: { type: 'text', text: '' } };
599
+ yield { type: 'block-end', index: 0, block: { type: 'text', text } };
319
600
  yield { type: 'finish', reason: { kind: 'stop' } };
320
601
  }
321
602
  finally {
@@ -323,3 +604,21 @@ export class MimoAdapter extends LlmAdapter {
323
604
  }
324
605
  }
325
606
  }
607
+ /**
608
+ * The rows of one listing payload.
609
+ *
610
+ * MiMo's documented answer is an OpenAI-style envelope (`{ object: 'list', data: [...] }`);
611
+ * a bare array is read too, so a cache written in either shape — or a host that answers
612
+ * the rows directly — cannot silently empty the picker.
613
+ *
614
+ * @param payload - a listing response body or a cached listing.
615
+ * @returns the rows to adopt, or an empty list when the payload names none.
616
+ */
617
+ function listingRows(payload) {
618
+ if (Array.isArray(payload))
619
+ return payload;
620
+ const data = typeof payload === 'object' && payload !== null
621
+ ? payload.data
622
+ : undefined;
623
+ return Array.isArray(data) ? data : [];
624
+ }