dsh-plugin-subscription-oauth 0.16.3 → 0.17.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.
Files changed (38) 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/index.js +21 -0
  6. package/lib/providers/antigravity.d.ts +34 -4
  7. package/lib/providers/antigravity.js +100 -28
  8. package/lib/providers/ark-provider.d.ts +10 -0
  9. package/lib/providers/ark-provider.js +6 -0
  10. package/lib/providers/ark.d.ts +96 -10
  11. package/lib/providers/ark.js +176 -48
  12. package/lib/providers/catalog.d.ts +91 -0
  13. package/lib/providers/catalog.js +190 -0
  14. package/lib/providers/common.d.ts +14 -0
  15. package/lib/providers/common.js +23 -0
  16. package/lib/providers/glm-provider.d.ts +10 -0
  17. package/lib/providers/glm-provider.js +6 -0
  18. package/lib/providers/glm.d.ts +92 -16
  19. package/lib/providers/glm.js +172 -85
  20. package/lib/providers/grok.d.ts +106 -27
  21. package/lib/providers/grok.js +312 -128
  22. package/lib/providers/kimi-provider.d.ts +10 -0
  23. package/lib/providers/kimi-provider.js +8 -3
  24. package/lib/providers/kimi.d.ts +104 -32
  25. package/lib/providers/kimi.js +198 -129
  26. package/lib/providers/mimo-provider.d.ts +14 -1
  27. package/lib/providers/mimo-provider.js +17 -6
  28. package/lib/providers/mimo.d.ts +164 -19
  29. package/lib/providers/mimo.js +318 -19
  30. package/lib/providers/model-cache.d.ts +57 -0
  31. package/lib/providers/model-cache.js +105 -0
  32. package/lib/providers/responses-stream.d.ts +8 -0
  33. package/lib/providers/responses-stream.js +21 -5
  34. package/lib/providers/workbuddy-provider.d.ts +17 -0
  35. package/lib/providers/workbuddy-provider.js +14 -6
  36. package/lib/providers/workbuddy.d.ts +140 -13
  37. package/lib/providers/workbuddy.js +501 -59
  38. package/package.json +1 -1
package/README.dev.md CHANGED
@@ -70,7 +70,7 @@ quota, which is read per subscription period and per model's free allowance.
70
70
  | --- | --- |
71
71
  | `ctx.llm` | the `kimi`, `grok`, `antigravity`, `glm` and `auto` provider routes (models, reasoning efforts, streaming) |
72
72
  | Settings | a **Subscriptions** section: one card per provider — sign in, sign out, default account, token refresh, plan quota, model catalog, manage dialog |
73
- | Model picker | each provider's own models, plus **`auto` as its own route** spanning them — Kimi ships `kimi-for-coding`, `k3`, `k3[1m]`, `k3-256k`; Grok seeds `grok-4.6`, `grok-4.5` and adds whatever its live catalog reports; Antigravity lists the agent-callable subset of what its endpoint reports (`fetchAvailableModels`), filtering out tabs, commit-message and transcription surfaces; GLM ships the `glm-5.3` family (`glm-5.3`, `glm-5.3-flash`, `glm-5.2`, `glm-5.1`, `glm-5`) and adds whatever its coding host lists |
73
+ | Model picker | each provider's own models, plus **`auto` as its own route** spanning them. Membership comes from the provider's own model list, read once and cached as that route's table (`~/.dsh/plugins/kimi-oauth/model-catalog.json`), so the picker never spends a request and a release or retirement server-side needs no plugin update. Kimi serves `kimi-for-coding`, `k3` (1M) and `k3-256k`; Grok adds whatever its live catalog reports on top of `grok-4.6`/`grok-4.5`; Antigravity lists the agent-callable subset of what its endpoint reports (`fetchAvailableModels`), filtering out tabs, commit-message and transcription surfaces; GLM adds whatever its coding host lists on top of the `glm-5.3` family |
74
74
 
75
75
  Credentials live at `~/.dsh/plugins/kimi-oauth/auth.json` (mode `0600`,
76
76
  written atomically, one section per provider). The directory keeps the name this
package/README.md CHANGED
@@ -13,12 +13,12 @@ Sign in once per provider from **Settings → Subscriptions** and that provider'
13
13
 
14
14
  | Provider | What you get | Sign-in |
15
15
  | --- | --- | --- |
16
- | **Kimi Code** | `kimi-for-coding`, `k3`, `k3[1m]`, `k3-256k` + live catalog | OAuth device flow (show code, confirm in browser) |
17
- | **Grok (X Premium)** | `grok-4.7`, `grok-4.6`, … + live catalog, **⚡ lightspeed** fast variant | Browser OAuth (auto-redirect) |
18
- | **Google Antigravity** | Claude Sonnet/Opus, Gemini Flash, GPT-OSS, with reasoning tiers | Google account OAuth |
19
- | **GLM (BigModel)** | `glm-5.3`, `glm-5.3-flash`, `glm-5.2`, `glm-5.1`, `glm-5` | ZCode browser sign-in (polled) |
20
- | **Volcengine Ark** | Ark coding models + live catalog | Volcengine SSO |
21
- | **Xiaomi MiMo** | MiMo Pro, MiMo Flash | Xiaomi account (QR / password) |
16
+ | **Kimi Code** | `kimi-for-coding`, `k3` (1M), `k3-256k` — read from the endpoint's own model list | OAuth device flow (show code, confirm in browser) |
17
+ | **Grok (X Premium)** | whatever the account's own model list reports (e.g. `grok-4.7`), **⚡ lightspeed** fast variant | Browser OAuth (auto-redirect) |
18
+ | **Google Antigravity** | Claude Sonnet/Opus, Gemini Flash, GPT-OSS, with reasoning tiers — read from the account's own list | Google account OAuth |
19
+ | **GLM (BigModel)** | whatever the coding host lists (e.g. `glm-5.3`, `glm-5.3-flash`) | ZCode browser sign-in (polled) |
20
+ | **Volcengine Ark** | the plan's own coding models | Volcengine SSO |
21
+ | **Xiaomi MiMo** | MiMo's own model list, with the app's two-model set as the fallback | Xiaomi account (QR / password) |
22
22
  | **WorkBuddy (国内版)** | Hy, GLM, Kimi, DeepSeek, MiniMax models | Browser OAuth (Tencent) |
23
23
 
24
24
  Every provider also feeds the **`auto`** route, which spans them all: pick `auto` and the harness walks your enabled models in order until one answers.
@@ -28,6 +28,7 @@ Every provider also feeds the **`auto`** route, which spans them all: pick `auto
28
28
  - **One sign-in per provider** — no API keys to find, paste, or rotate. The plugin runs the provider's own OAuth/device flow and keeps the token on disk (`~/.dsh/plugins/kimi-oauth/auth.json`, mode `0600`).
29
29
  - **Live plan usage** — the Settings card and the composer chip show each account's remaining quota (Kimi's 5-hour / weekly windows + credit, Grok's weekly credit, Antigravity's Gemini + Claude buckets, GLM/Ark/MiMo/WorkBuddy credits), with a meter and reset time.
30
30
  - **Model picker integration** — each provider's models appear in the session picker with context windows and reasoning tiers; hide or re-order them per provider.
31
+ - **Catalogs come from the provider, not from this build** — every route reads its own model list once and caches it as the provider's table, so a model released, renamed or retired server-side shows up (or disappears) without a plugin update. Signing in and the Settings **refresh models** action are what re-read it; browsing the picker never spends a request.
31
32
  - **⚡ Fast / lightspeed variants** — for models that have one (e.g. Grok 4.7 → `grok-4.7-build-fast`), a bolt button beside the model selector switches to the fast variant; it reads as a reasoning rung so the base model always stays selectable.
32
33
  - **Cross-account pool** — sign in to several accounts on one provider; the plugin draws on the next account when one's allowance runs out.
33
34
  - **Auto fallback** — `auto` moves to the next enabled model when a provider refuses the preferred one (renamed, rate-limited, out of quota), and stops falling back once a stream has started.
package/README.zh.dev.md CHANGED
@@ -56,7 +56,7 @@ Antigravity 的流程是 Google 的桌面 OAuth 客户端(`accounts.google.com
56
56
  | --- | --- |
57
57
  | `ctx.llm` | `kimi`、`grok`、`antigravity`、`glm` 和 `auto` 提供方路由(模型、推理强度、流式输出) |
58
58
  | Settings | 一个 **Subscriptions** 区域:每个提供方一张卡片——登录、退出登录、默认账户、令牌刷新、套餐配额、模型目录、管理对话框 |
59
- | 模型选择器 | 每个提供方自己的模型,外加**作为独立路由的 `auto`**,横跨它们——Kimi 提供 `kimi-for-coding`、`k3`、`k3[1m]`、`k3-256k`;Grok 内置 `grok-4.6`、`grok-4.5`,并加入其实时目录所报告的任何模型;Antigravity 列出其端点所报告内容(`fetchAvailableModels`)中可供 agent 调用的子集,过滤掉 tabs、commit-message 和 transcription 相关层面;GLM 提供 `glm-5.3` 系列(`glm-5.3`、`glm-5.3-flash`、`glm-5.2`、`glm-5.1`、`glm-5`),并加入其编码主机所列出的一切 |
59
+ | 模型选择器 | 每个提供方自己的模型,外加**作为独立路由的 `auto`**,横跨它们。成员资格来自提供方自己的模型列表:每个路由只读取一次并缓存为该路由的目录(`~/.dsh/plugins/kimi-oauth/model-catalog.json`),因此选择器不会发出请求,服务端新增或下线模型也无需插件更新。Kimi 提供 `kimi-for-coding`、`k3`(1M)与 `k3-256k`;Grok 在 `grok-4.6`、`grok-4.5` 之上加入其实时目录所报告的任何模型;Antigravity 列出其端点所报告内容(`fetchAvailableModels`)中可供 agent 调用的子集,过滤掉 tabs、commit-message 和 transcription 相关层面;GLM 在 `glm-5.3` 系列之上加入其编码主机所列出的一切 |
60
60
 
61
61
  凭证存放在 `~/.dsh/plugins/kimi-oauth/auth.json`(模式 `0600`,原子写入,每个提供方
62
62
  一个区段)。该目录保留了本插件在只服务 Kimi Code 时所用的名称:它保存着**活跃的
package/README.zh.md CHANGED
@@ -13,12 +13,12 @@
13
13
 
14
14
  | 服务 | 可用模型 | 登录方式 |
15
15
  | --- | --- | --- |
16
- | **Kimi Code** | `kimi-for-coding`、`k3`、`k3[1m]`、`k3-256k` + 实时目录 | OAuth 设备流(显示代码,浏览器确认) |
17
- | **Grok (X Premium)** | `grok-4.7`、`grok-4.6`… + 实时目录,**⚡ lightspeed** 快速变体 | 浏览器 OAuth(自动回调) |
18
- | **Google Antigravity** | Claude Sonnet/Opus、Gemini Flash、GPT-OSS,带推理档 | Google 账号 OAuth |
19
- | **GLM (BigModel)** | `glm-5.3`、`glm-5.3-flash`、`glm-5.2`、`glm-5.1`、`glm-5` | ZCode 浏览器登录(轮询) |
20
- | **Volcengine Ark** | Ark 编程模型 + 实时目录 | 火山引擎 SSO |
21
- | **Xiaomi MiMo** | MiMo Pro、MiMo Flash | 小米账号(扫码 / 密码) |
16
+ | **Kimi Code** | `kimi-for-coding`、`k3`(1M)、`k3-256k` —— 直接来自端点自己的模型列表 | OAuth 设备流(显示代码,浏览器确认) |
17
+ | **Grok (X Premium)** | 账号自己的模型列表所报告的一切(如 `grok-4.7`),**⚡ lightspeed** 快速变体 | 浏览器 OAuth(自动回调) |
18
+ | **Google Antigravity** | Claude Sonnet/Opus、Gemini Flash、GPT-OSS,带推理档 —— 来自账号自己的列表 | Google 账号 OAuth |
19
+ | **GLM (BigModel)** | 编码主机列出的一切(如 `glm-5.3`、`glm-5.3-flash`) | ZCode 浏览器登录(轮询) |
20
+ | **Volcengine Ark** | 套餐自己的编程模型 | 火山引擎 SSO |
21
+ | **Xiaomi MiMo** | MiMo 自己的模型列表,读取失败时回退到应用内置的两个模型 | 小米账号(扫码 / 密码) |
22
22
  | **WorkBuddy (国内版)** | Hy、GLM、Kimi、DeepSeek、MiniMax 模型 | 浏览器 OAuth(腾讯) |
23
23
 
24
24
  每个服务也会汇入 **`auto`** 路由,它横跨全部:选 `auto`,harness 会按顺序尝试你已启用的模型,直到有响应。
@@ -28,6 +28,7 @@
28
28
  - **每个服务登录一次** —— 无需找、贴、轮换 API Key。插件运行服务自己的 OAuth/设备流,令牌保存在磁盘(`~/.dsh/plugins/kimi-oauth/auth.json`,权限 `0600`)。
29
29
  - **实时套餐额度** —— 设置卡片和输入框 chip 显示每个账号的剩余额度(Kimi 的 5 小时 / 每周窗口 + 额度,Grok 每周额度,Antigravity 的 Gemini + Claude 分组,GLM/Ark/MiMo/WorkBuddy 的额度),带进度条和重置时间。
30
30
  - **模型选择器集成** —— 每个服务的模型带上下文窗口和推理档出现在选择器里;可按服务隐藏或排序。
31
+ - **模型目录来自服务本身,而不是本插件** —— 每个路由只读取一次服务自己的模型列表,并把它缓存为该路由的目录,因此服务端新增、改名或下线的模型无需插件更新即可出现(或消失)。重新读取的时机是登录成功和设置页的**刷新模型**;浏览选择器不会发出任何请求。
31
32
  - **⚡ 快速 / lightspeed 变体** —— 对有快速变体的模型(如 Grok 4.7 → `grok-4.7-build-fast`),模型选择器旁的闪电按钮一键切换;它作为推理档实现,基础模型始终可选。
32
33
  - **多账号池** —— 一个服务可登录多个账号;某账号额度用尽时自动切到下一个。
33
34
  - **Auto 回退** —— `auto` 在首选模型被拒(改名、限流、额度尽)时改用下一个已启用模型,且流开始后不再回退。
package/lib/index.js CHANGED
@@ -25,6 +25,7 @@ import { createArkProvider } from './providers/ark-provider.js';
25
25
  import { createMimoProvider } from './providers/mimo-provider.js';
26
26
  import { createWorkBuddyProvider } from './providers/workbuddy-provider.js';
27
27
  import { ModelPreferences } from './settings.js';
28
+ import { ModelCatalogCache } from './providers/model-cache.js';
28
29
  /** Plugin id; also the name every effect label and log line is prefixed with. */
29
30
  export const name = 'dsh-plugin-subscription-oauth';
30
31
  /** The provider routes need the LLM runtime; everything else is optional. */
@@ -68,6 +69,10 @@ export function apply(ctx, config = {}) {
68
69
  const dataDir = dshHomePath(...KIMI_DATA_DIR);
69
70
  const credentialFile = path.join(dataDir, 'auth.json');
70
71
  const preferences = new ModelPreferences(path.join(dataDir, 'preferences.json'));
72
+ // The endpoint's own last answer per route. It is written whenever a route reads its
73
+ // live listing — at startup, after a login, and on the page's "refresh models" action
74
+ // — and read as the route's model table, so a picker query is never a request.
75
+ const catalogCache = new ModelCatalogCache(path.join(dataDir, 'model-catalog.json'));
71
76
  /** Registration handles, so a catalog change can re-announce one route. */
72
77
  const handles = new Map();
73
78
  const reannounce = (id) => { handles.get(id)?.replace([id]); };
@@ -84,6 +89,8 @@ export function apply(ctx, config = {}) {
84
89
  streamIdleTimeoutMs,
85
90
  ...config.defaultModel === undefined ? {} : { defaultModel: config.defaultModel },
86
91
  ...config.extraModels === undefined ? {} : { extraModels: config.extraModels },
92
+ cachedListing: () => catalogCache.read('kimi'),
93
+ saveListing: (rows) => catalogCache.write('kimi', rows),
87
94
  preferences,
88
95
  // Resolved per request: the attachment service may start after this plugin.
89
96
  resolveAttachments: () => ctx.get('attachments'),
@@ -98,6 +105,8 @@ export function apply(ctx, config = {}) {
98
105
  discovery: config.discovery ?? true,
99
106
  streamIdleTimeoutMs,
100
107
  preferences,
108
+ cachedListing: () => catalogCache.read('grok'),
109
+ saveListing: (payload) => catalogCache.write('grok', payload),
101
110
  resolveAttachments: () => ctx.get('attachments'),
102
111
  onCatalogChanged: () => { reannounce('grok'); },
103
112
  onWarn,
@@ -113,6 +122,8 @@ export function apply(ctx, config = {}) {
113
122
  resolveAttachments: () => ctx.get('attachments'),
114
123
  onCatalogChanged: () => { reannounce('antigravity'); },
115
124
  onWarn,
125
+ cachedListing: () => catalogCache.read('antigravity'),
126
+ saveListing: (payload) => catalogCache.write('antigravity', payload),
116
127
  fetchFn: proxiedFetch,
117
128
  }));
118
129
  break;
@@ -122,6 +133,8 @@ export function apply(ctx, config = {}) {
122
133
  discovery: config.discovery ?? true,
123
134
  streamIdleTimeoutMs,
124
135
  preferences,
136
+ cachedListing: () => catalogCache.read('glm'),
137
+ saveListing: (payload) => catalogCache.write('glm', payload),
125
138
  resolveAttachments: () => ctx.get('attachments'),
126
139
  onCatalogChanged: () => { reannounce('glm'); },
127
140
  onWarn,
@@ -134,6 +147,8 @@ export function apply(ctx, config = {}) {
134
147
  discovery: config.discovery ?? true,
135
148
  streamIdleTimeoutMs,
136
149
  preferences,
150
+ cachedListing: () => catalogCache.read('ark'),
151
+ saveListing: (payload) => catalogCache.write('ark', payload),
137
152
  resolveAttachments: () => ctx.get('attachments'),
138
153
  onCatalogChanged: () => { reannounce('ark'); },
139
154
  onWarn,
@@ -143,8 +158,11 @@ export function apply(ctx, config = {}) {
143
158
  case 'mimo':
144
159
  routes.push(createMimoProvider({
145
160
  credentialFile,
161
+ discovery: config.discovery ?? true,
146
162
  streamIdleTimeoutMs,
147
163
  preferences,
164
+ cachedListing: () => catalogCache.read('mimo'),
165
+ saveListing: (payload) => catalogCache.write('mimo', payload),
148
166
  resolveAttachments: () => ctx.get('attachments'),
149
167
  onCatalogChanged: () => { reannounce('mimo'); },
150
168
  onWarn,
@@ -154,8 +172,11 @@ export function apply(ctx, config = {}) {
154
172
  case 'workbuddy':
155
173
  routes.push(createWorkBuddyProvider({
156
174
  credentialFile,
175
+ discovery: config.discovery ?? true,
157
176
  streamIdleTimeoutMs,
158
177
  preferences,
178
+ cachedListing: () => catalogCache.read('workbuddy'),
179
+ saveListing: (payload) => catalogCache.write('workbuddy', payload),
159
180
  onCatalogChanged: () => { reannounce('workbuddy'); },
160
181
  onWarn,
161
182
  fetchFn: proxiedFetch,
@@ -195,6 +195,17 @@ export interface AntigravityAdapterOptions {
195
195
  modelOrder?: () => Promise<readonly string[]>;
196
196
  fetchFn?: FetchFn;
197
197
  onCredentialRejected?: (account: string) => void;
198
+ /**
199
+ * The endpoint's last live listing, from this plugin's cache.
200
+ *
201
+ * Read once per session, before the first catalog query, so the picker offers real
202
+ * models without asking the endpoint anything. A route that fetched once and cached
203
+ * the answer has no reason to fetch again on every refresh; the Settings "refresh
204
+ * models" action is what re-reads it.
205
+ */
206
+ cachedListing?: () => Promise<unknown>;
207
+ /** Record what a live listing just produced, replacing the cached table. */
208
+ saveListing?: (payload: unknown) => Promise<void>;
198
209
  }
199
210
  /** Antigravity wire adapter serving the `antigravity` route. */
200
211
  export declare class AntigravityAdapter extends LlmAdapter {
@@ -203,6 +214,8 @@ export declare class AntigravityAdapter extends LlmAdapter {
203
214
  private discovered;
204
215
  /** Live catalog entries, so the Settings list matches what the account has. */
205
216
  private live;
217
+ /** Whether the cached listing has been read; it is read at most once per session. */
218
+ private hydrated;
206
219
  /** Per-model quota windows from the last catalog read. */
207
220
  private quotas;
208
221
  /** Ids the user added by hand, cached so the sync catalog can name them. */
@@ -213,12 +226,20 @@ export declare class AntigravityAdapter extends LlmAdapter {
213
226
  listModels(_provider: string): Promise<readonly LlmModelInfo[]>;
214
227
  private hidden;
215
228
  /**
216
- * The catalog the route serves: its own entries, then the ids the user added
217
- * by hand. The two are built separately so the hand-added check can look at
218
- * the shipped list without recursing.
229
+ * Read the cached listing, once, so the picker works without asking the endpoint.
230
+ *
231
+ * The cache is this route's last live catalog — the entries the endpoint reported, which
232
+ * is also what a fresh read produces — so adopting it is indistinguishable from having
233
+ * just fetched. A read failure is a cache miss, never a failure.
219
234
  */
235
+ hydrate(): Promise<void>;
236
+ /** Publish one listing as this route's catalog. */
237
+ private adopt;
238
+ /** The catalog the route serves: its own entries, then the ids the user added
239
+ * by hand. The two are built separately so the hand-added check can look at
240
+ * the shipped list without recursing. */
220
241
  private catalog;
221
- /** Live entries when discovery has run, otherwise an empty catalog. */
242
+ /** The live entries: the cached listing until a fresh one replaces it. */
222
243
  private shippedCatalog;
223
244
  resolveModel(_provider: string, model: string): Promise<LlmResolvedModelInfo>;
224
245
  stream(options: GenerateOptions): AsyncIterable<StreamChunk>;
@@ -332,6 +353,15 @@ export interface AntigravityProviderOptions {
332
353
  fetchFn?: FetchFn;
333
354
  /** Activate an eligible account when `loadCodeAssist` reports no project. */
334
355
  onboard?: boolean;
356
+ /**
357
+ * The endpoint's last live catalog, from this plugin's cache.
358
+ *
359
+ * This route has no curated list — what an account may use is plan-dependent — so the
360
+ * cache is what makes a cold start (or an unreachable endpoint) still show models.
361
+ */
362
+ cachedListing?: () => Promise<unknown>;
363
+ /** Record what a live listing just produced, replacing the cached table. */
364
+ saveListing?: (payload: unknown) => Promise<void>;
335
365
  }
336
366
  /**
337
367
  * Build the `antigravity` route: its adapter and its login/lifecycle controller.
@@ -329,16 +329,6 @@ export async function fetchAntigravityModels(input) {
329
329
  if (typeof models !== 'object' || models === null) {
330
330
  throw new LlmError('Antigravity models endpoint returned no models object', 'MALFORMED_RESPONSE');
331
331
  }
332
- // TEMP diagnostics: write the raw model-id list to a file so an over-eager filter is
333
- // inspectable after a discovery run, since the node half's stdout is unreachable in
334
- // the desktop shell.
335
- try {
336
- const { writeFileSync } = require('node:fs');
337
- const { homedir } = require('node:os');
338
- const { join } = require('node:path');
339
- writeFileSync(join(homedir(), '.dsh', 'antigravity-raw-models.json'), JSON.stringify(Object.keys(models), null, 2), 'utf8');
340
- }
341
- catch { /* diagnostics never break parsing */ }
342
332
  // The response names far more than the agent may call; the filter keeps the
343
333
  // callable grouping and reports the per-model quota for the fallback path.
344
334
  const entries = parseAntigravityModels(payload);
@@ -365,6 +355,33 @@ function quotaWindow(scope, quota) {
365
355
  ...Number.isFinite(resetsAt) ? { resetsAt } : {},
366
356
  };
367
357
  }
358
+ /** Whether one cached value is a usable catalog entry. */
359
+ function isCachedEntry(value) {
360
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
361
+ return false;
362
+ const record = value;
363
+ return typeof record['id'] === 'string' && record['id'] !== ''
364
+ && typeof record['name'] === 'string'
365
+ && typeof record['contextWindow'] === 'number' && Number.isFinite(record['contextWindow']);
366
+ }
367
+ /**
368
+ * Read a cached catalog payload.
369
+ *
370
+ * The route writes the entries themselves; a whole `{ entries: [...] }` document is
371
+ * accepted too, so a payload written in either shape cannot silently empty the picker.
372
+ * Anything unusable is dropped rather than trusted.
373
+ *
374
+ * @param payload - whatever the cache returned.
375
+ * @returns the usable entries, possibly none.
376
+ */
377
+ function cachedEntries(payload) {
378
+ const rows = Array.isArray(payload)
379
+ ? payload
380
+ : typeof payload === 'object' && payload !== null && Array.isArray(payload.entries)
381
+ ? payload.entries
382
+ : [];
383
+ return rows.filter(isCachedEntry);
384
+ }
368
385
  /** Antigravity wire adapter serving the `antigravity` route. */
369
386
  export class AntigravityAdapter extends LlmAdapter {
370
387
  options;
@@ -372,6 +389,8 @@ export class AntigravityAdapter extends LlmAdapter {
372
389
  discovered;
373
390
  /** Live catalog entries, so the Settings list matches what the account has. */
374
391
  live;
392
+ /** Whether the cached listing has been read; it is read at most once per session. */
393
+ hydrated = false;
375
394
  /** Per-model quota windows from the last catalog read. */
376
395
  quotas = new Map();
377
396
  /** Ids the user added by hand, cached so the sync catalog can name them. */
@@ -392,6 +411,7 @@ export class AntigravityAdapter extends LlmAdapter {
392
411
  // must not appear in the picker, and Auto must not try it.
393
412
  if (await this.options.tokens.session() === undefined)
394
413
  return [];
414
+ await this.hydrate();
395
415
  await this.syncUserModels();
396
416
  const hidden = await this.hidden();
397
417
  return resolveCatalog(this.catalog(), [], hidden).entries.map((entry) => ({
@@ -414,18 +434,44 @@ export class AntigravityAdapter extends LlmAdapter {
414
434
  }
415
435
  }
416
436
  /**
417
- * The catalog the route serves: its own entries, then the ids the user added
418
- * by hand. The two are built separately so the hand-added check can look at
419
- * the shipped list without recursing.
437
+ * Read the cached listing, once, so the picker works without asking the endpoint.
438
+ *
439
+ * The cache is this route's last live catalog — the entries the endpoint reported, which
440
+ * is also what a fresh read produces — so adopting it is indistinguishable from having
441
+ * just fetched. A read failure is a cache miss, never a failure.
420
442
  */
443
+ async hydrate() {
444
+ if (this.hydrated)
445
+ return;
446
+ this.hydrated = true;
447
+ if (this.live !== undefined || this.options.cachedListing === undefined)
448
+ return;
449
+ try {
450
+ const entries = cachedEntries(await this.options.cachedListing());
451
+ if (entries.length > 0)
452
+ this.adopt(entries);
453
+ }
454
+ catch (error) {
455
+ this.options.onWarn?.(`cached model catalog unavailable (${errorChain(error)})`);
456
+ }
457
+ }
458
+ /** Publish one listing as this route's catalog. */
459
+ adopt(entries) {
460
+ this.live = entries;
461
+ this.discovered = entries.map((entry) => entry.id);
462
+ }
463
+ /** The catalog the route serves: its own entries, then the ids the user added
464
+ * by hand. The two are built separately so the hand-added check can look at
465
+ * the shipped list without recursing. */
421
466
  catalog() {
422
467
  return [...this.shippedCatalog(), ...this.addedEntries()];
423
468
  }
424
- /** Live entries when discovery has run, otherwise an empty catalog. */
469
+ /** The live entries: the cached listing until a fresh one replaces it. */
425
470
  shippedCatalog() {
426
471
  return this.live ?? [];
427
472
  }
428
473
  async resolveModel(_provider, model) {
474
+ await this.hydrate();
429
475
  await this.syncUserModels();
430
476
  const entries = this.catalog();
431
477
  const entry = findEntry(entries, model);
@@ -591,7 +637,13 @@ export class AntigravityAdapter extends LlmAdapter {
591
637
  if (Date.now() < session.expiresAt) {
592
638
  return { account, accessToken: session.accessToken, expiresAt: session.expiresAt };
593
639
  }
594
- return await this.options.tokens.resolve(account, true);
640
+ try {
641
+ const resolved = await this.options.tokens.resolve(account, true);
642
+ return resolved;
643
+ }
644
+ catch (error) {
645
+ throw error;
646
+ }
595
647
  }
596
648
  /** Translate an auth failure into the message a user can act on. */
597
649
  refreshHint(error) {
@@ -611,17 +663,23 @@ export class AntigravityAdapter extends LlmAdapter {
611
663
  const envelope = await toAntigravityRequest({ ...options, model }, projectId, options.system, this.imageResolver(signal), true);
612
664
  const fetchFn = this.options.fetchFn ?? proxiedFetch;
613
665
  const origin = this.options.baseUrl ?? ANTIGRAVITY_BASE_URL;
614
- return fetchFn(`${origin}/v1internal:streamGenerateContent?alt=sse`, {
615
- method: 'POST',
616
- headers: attributedHeaders({
617
- 'authorization': `Bearer ${accessToken}`,
618
- 'accept': 'text/event-stream',
619
- 'content-type': 'application/json',
620
- 'user-agent': ANTIGRAVITY_USER_AGENT,
621
- }),
622
- body: JSON.stringify(envelope),
623
- signal,
624
- });
666
+ try {
667
+ const response = await fetchFn(`${origin}/v1internal:streamGenerateContent?alt=sse`, {
668
+ method: 'POST',
669
+ headers: attributedHeaders({
670
+ 'authorization': `Bearer ${accessToken}`,
671
+ 'accept': 'text/event-stream',
672
+ 'content-type': 'application/json',
673
+ 'user-agent': ANTIGRAVITY_USER_AGENT,
674
+ }),
675
+ body: JSON.stringify(envelope),
676
+ signal,
677
+ });
678
+ return response;
679
+ }
680
+ catch (error) {
681
+ throw error;
682
+ }
625
683
  }
626
684
  /** Resolve image blocks through the attachment service, when it is present. */
627
685
  imageResolver(signal) {
@@ -664,8 +722,17 @@ export class AntigravityAdapter extends LlmAdapter {
664
722
  ...signal === undefined ? {} : { signal },
665
723
  });
666
724
  this.quotas = quotas;
667
- this.live = entries;
668
- this.discovered = entries.map((entry) => entry.id);
725
+ this.adopt(entries);
726
+ // The catalog the endpoint just reported is what the next session starts from, so
727
+ // the picker does not have to ask again. A cache failure never fails this read.
728
+ if (this.options.saveListing !== undefined) {
729
+ try {
730
+ await this.options.saveListing(entries);
731
+ }
732
+ catch (error) {
733
+ this.options.onWarn?.(`model catalog cache write failed (${errorChain(error)})`);
734
+ }
735
+ }
669
736
  return this.discovered;
670
737
  }
671
738
  catch (error) {
@@ -911,8 +978,13 @@ export function createAntigravityProvider(options) {
911
978
  hiddenModels: () => options.preferences.hiddenIds(ANTIGRAVITY_PROVIDER_ID),
912
979
  userModels: () => options.preferences.extraIds(ANTIGRAVITY_PROVIDER_ID),
913
980
  modelOrder: () => options.preferences.orderOf(ANTIGRAVITY_PROVIDER_ID),
981
+ ...options.cachedListing === undefined ? {} : { cachedListing: options.cachedListing },
982
+ ...options.saveListing === undefined ? {} : { saveListing: options.saveListing },
914
983
  fetchFn,
915
984
  });
985
+ // Read the cached table now rather than at the first picker query: the Settings page
986
+ // reads the catalog synchronously, so a request-free start must already have it.
987
+ void adapter.hydrate().catch(() => undefined);
916
988
  const spec = {
917
989
  id: ANTIGRAVITY_PROVIDER_ID,
918
990
  displayName: ANTIGRAVITY_PROVIDER_NAME,
@@ -27,6 +27,16 @@ export interface ArkProviderOptions {
27
27
  streamIdleTimeoutMs: number;
28
28
  defaultModel?: string;
29
29
  extraModels?: readonly string[];
30
+ /**
31
+ * The endpoint's last live listing, from this plugin's cache.
32
+ *
33
+ * The cached table is what the picker shows before any request: the endpoint's own
34
+ * answer from the last read, so a release does not need a plugin update and a refresh
35
+ * does not need a request.
36
+ */
37
+ cachedListing?: () => Promise<unknown>;
38
+ /** Record what a live listing just produced, replacing the cached table. */
39
+ saveListing?: (payload: unknown) => Promise<void>;
30
40
  preferences: ModelPreferences;
31
41
  resolveAttachments?: () => AttachmentStore | undefined;
32
42
  onCatalogChanged?: () => void;
@@ -113,8 +113,14 @@ export function createArkProvider(options) {
113
113
  hiddenModels: () => options.preferences.hiddenIds(ARK_PROVIDER_ID),
114
114
  userModels: () => options.preferences.extraIds(ARK_PROVIDER_ID),
115
115
  ...options.extraModels === undefined ? {} : { extraModels: options.extraModels },
116
+ ...options.defaultModel === undefined ? {} : { defaultModel: options.defaultModel },
117
+ ...options.cachedListing === undefined ? {} : { cachedListing: options.cachedListing },
118
+ ...options.saveListing === undefined ? {} : { saveListing: options.saveListing },
116
119
  fetchFn,
117
120
  });
121
+ // Read the cached table now rather than at the first picker query: the Settings page
122
+ // reads the catalog synchronously, so a request-free start must already have it.
123
+ void adapter.hydrate().catch(() => undefined);
118
124
  const spec = {
119
125
  id: ARK_PROVIDER_ID,
120
126
  displayName: ARK_PROVIDER_NAME,
@@ -11,7 +11,10 @@
11
11
  * malformed one is told so (`401 The API key format is incorrect`). So the
12
12
  * credential is a UUID key, sent as a plain bearer — no request signing.
13
13
  * - `/api/coding/v3` is documented by the official CLI as "OpenTOP + CodingPlan
14
- * API", and the catalogue below is the plan's roster.
14
+ * API", and its `/models` listing — read once and kept in this plugin's cache, see
15
+ * `hydrate` and `fetchCatalog` — is the roster this route publishes. Nothing in this
16
+ * module decides which models the plan serves, so one released, renamed or retired
17
+ * server-side reaches the picker without a plugin update.
15
18
  *
16
19
  * @module dsh-plugin-subscription-oauth/providers/ark
17
20
  */
@@ -42,14 +45,12 @@ export declare const ARK_DEFAULT_MODEL = "ark-code-latest";
42
45
  /**
43
46
  * Context capacity advertised for the plan's models.
44
47
  *
45
- * The Coding Plan is a curated, text-first roster and states no window per model;
46
- * discovery refines this where the endpoint reports one.
48
+ * The plan states no window in this build; the endpoint's own listing supplies one
49
+ * where it names a model, and discovery refines this where it does not.
47
50
  */
48
51
  export declare const ARK_CONTEXT_WINDOW = 262144;
49
52
  /** Output budget used when the caller sets no cap. */
50
53
  export declare const ARK_DEFAULT_MAX_TOKENS = 32000;
51
- /** The plan's model roster, as the reference registry records it. */
52
- export declare const ARK_MODELS: readonly CatalogEntry[];
53
54
  /** One signed-in Ark account. */
54
55
  export interface ArkSession extends SessionBase {
55
56
  /**
@@ -89,6 +90,25 @@ export interface ArkAdapterOptions {
89
90
  hiddenModels?: () => Promise<ReadonlySet<string>> | ReadonlySet<string>;
90
91
  userModels?: () => Promise<readonly string[]> | readonly string[];
91
92
  extraModels?: readonly string[];
93
+ /**
94
+ * Model this route preselects, and therefore always offers.
95
+ *
96
+ * Configuration rather than a shipped fact: the plan re-points its alias at a new
97
+ * release on its own schedule, and the picker must be able to preselect it. Defaults
98
+ * to {@link ARK_DEFAULT_MODEL}.
99
+ */
100
+ defaultModel?: string;
101
+ /**
102
+ * The endpoint's last live listing, from this plugin's cache.
103
+ *
104
+ * Read once per session, before the first catalog query, so the picker offers real
105
+ * models without asking the endpoint anything. A route that fetched once and cached
106
+ * the answer has no reason to fetch again on every refresh; the Settings "refresh
107
+ * models" action is what re-reads it.
108
+ */
109
+ cachedListing?: () => Promise<unknown>;
110
+ /** Record what a live listing just produced, replacing the cached table. */
111
+ saveListing?: (payload: unknown) => Promise<void>;
92
112
  resolveAttachments?: () => AttachmentStore | undefined;
93
113
  onWarn?: (message: string) => void;
94
114
  onCredentialRejected?: (account: string) => void;
@@ -99,10 +119,45 @@ export interface ArkAdapterOptions {
99
119
  /** The adapter behind the `ark` route. */
100
120
  export declare class ArkAdapter extends LlmAdapter {
101
121
  private readonly options;
122
+ /**
123
+ * The catalog this route serves: entries derived from the endpoint's own listing,
124
+ * either the one just read or the one the cache kept from the last read.
125
+ *
126
+ * Undefined means neither source has produced a listing yet, so only the configured
127
+ * default model is offered until one does.
128
+ */
102
129
  private live;
103
- /** Image-generation model id, when discovery reported one. */
130
+ /** Whether the cached listing has been read; it is read at most once per session. */
131
+ private hydrated;
132
+ /** Image-generation model id, when the listing reported one. */
104
133
  private imageModel;
134
+ /**
135
+ * Fast-variant wire id per picker row.
136
+ *
137
+ * Derived from the listing rather than a curated alias map: a row whose name is a
138
+ * listed model plus the provider's own fast word rides that row instead of becoming
139
+ * one of its own.
140
+ */
141
+ private readonly fastVariants;
105
142
  constructor(options: ArkAdapterOptions);
143
+ /**
144
+ * Adopt the rows of one live listing as this route's catalog.
145
+ *
146
+ * Everything the picker shows is derived here from the endpoint's own answer: the
147
+ * rows that become picker entries and the fast variant that pairs with each row.
148
+ * Nothing about which models exist, or what each one is, is decided by this build.
149
+ *
150
+ * @param rows - decoded rows of the endpoint's listing, in the endpoint's order.
151
+ * @returns the entries to publish, in picker order.
152
+ */
153
+ private adoptListing;
154
+ /**
155
+ * Read the cached listing, once, so the picker works without asking the endpoint.
156
+ *
157
+ * Called before the first catalog query. A read failure is a cache miss, never a
158
+ * failure: the route simply has no listing until one is fetched.
159
+ */
160
+ hydrate(): Promise<void>;
106
161
  providerInfo(provider: string): LlmProviderInfo;
107
162
  providerRetryPolicy(): undefined;
108
163
  listModels(_provider: string): Promise<readonly LlmModelInfo[]>;
@@ -113,18 +168,49 @@ export declare class ArkAdapter extends LlmAdapter {
113
168
  /** Translate an auth failure into the message a user can act on. */
114
169
  private refreshHint;
115
170
  private catalog;
171
+ /**
172
+ * The catalog to present: the models the endpoint's listing named, in its own order,
173
+ * each with the metadata that listing stated, then the configured extras.
174
+ *
175
+ * There is no shipped roster. A listing is read once and cached (see {@link hydrate}
176
+ * and {@link fetchCatalog}), and that cached table is what the picker shows until the
177
+ * next read, so a model released, renamed or retired server-side is reflected without
178
+ * a plugin update. Only the configured default is always offered, because the picker
179
+ * preselects it.
180
+ */
116
181
  private shippedCatalog;
182
+ /** The model the picker preselects, from configuration. */
183
+ private defaultModel;
117
184
  private addedEntries;
118
185
  private hidden;
119
- /** Adopt the endpoint's live catalog. */
186
+ /**
187
+ * Adopt the ids a caller reports as the served set, without a listing.
188
+ *
189
+ * {@link fetchCatalog} already derived the entries from the rows it read, and the
190
+ * controller reports those same ids back here; the entries it recorded are kept. A
191
+ * caller that only has ids (a test, another discovery path) gets conservative entries
192
+ * for them.
193
+ *
194
+ * @param ids - model ids the caller reports.
195
+ */
120
196
  discover(ids: readonly string[]): void;
121
197
  /**
122
- * Read the plan's live model list.
198
+ * Read the plan's live model list and adopt it as this route's catalog.
199
+ *
200
+ * The Coding Plan answers the OpenAI models shape on its own base, so the rows are
201
+ * parsed exactly as the other routes parse theirs: what the listing names is what the
202
+ * picker offers, with the display name, window, modalities and ladder it states. The
203
+ * rows are also handed to {@link ArkAdapterOptions.saveListing}, so the next session
204
+ * starts from this table instead of asking again — the Settings "refresh models"
205
+ * action is what re-reads it.
123
206
  *
124
- * The Coding Plan answers the OpenAI models shape on its own base; an endpoint
125
- * that does not serve it leaves the shipped roster in place.
207
+ * @param apiKey - bearer for the account.
208
+ * @param signal - optional cancellation.
209
+ * @returns the model ids now served, or undefined when the listing is unavailable.
126
210
  */
127
211
  fetchCatalog(apiKey: string, signal?: AbortSignal): Promise<readonly string[] | undefined>;
212
+ /** Persist one live listing, reporting a cache failure without failing the read. */
213
+ private saveListing;
128
214
  /** Send one streaming request over the Responses wire. */
129
215
  private request;
130
216
  /** Resolve image blocks through the attachment service, when it is present. */