dsh-workbuddy-xdpool 1.7.2 → 1.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -12,6 +12,39 @@ import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai";
12
12
  import { createServer } from "node:http";
13
13
  import { Readable } from "node:stream";
14
14
  //#region src/upstream.ts
15
+ /**
16
+ * Backoff between catalog-fetch attempts.
17
+ *
18
+ * Three retries after the first try, so a startup network hiccup does not cost
19
+ * the user their model list for the whole session. The window covers the case
20
+ * that actually bit: several independent components reported `fetch failed`
21
+ * within the same second while the machine was still bringing its network up,
22
+ * and the very next request succeeded 0.7s later.
23
+ *
24
+ * Deliberately short and bounded. This runs during plugin startup, so a long
25
+ * ladder would delay the provider appearing at all; the total added latency
26
+ * here is ~12s in the worst case, and only when the network is genuinely down.
27
+ */
28
+ const CATALOG_RETRY_BACKOFF_MS = [
29
+ 1e3,
30
+ 3e3,
31
+ 8e3
32
+ ];
33
+ /**
34
+ * Sleep helper for the retry ladder.
35
+ *
36
+ * Deliberately NOT unref'd. An unref'd timer does not keep the event loop
37
+ * alive, so awaiting one can hang forever when nothing else is pending — the
38
+ * retry would simply never resume. That is exactly what the first version of
39
+ * this did, and it wedged the very first end-to-end run: `fetchModels` never
40
+ * settled. The wait is at most a few seconds and only happens on failure, so
41
+ * holding the loop open is the correct trade.
42
+ */
43
+ function delay(ms) {
44
+ return new Promise((resolve) => {
45
+ setTimeout(resolve, ms);
46
+ });
47
+ }
15
48
  /** CN chat base per dingminhua's on-machine probe (HTTP 200 for chat/models). */
16
49
  const CN_CHAT_BASE = "https://copilot.tencent.com";
17
50
  /** Billing/console base — `www.codebuddy.cn` is the billing origin. */
@@ -33,6 +66,13 @@ const MODELS_CATALOG_PATH = "/v2/enterprises/personal/models";
33
66
  const GLOBAL_CONFIG_PATH = "/v3/config";
34
67
  const JSON_TIMEOUT_MS = 3e4;
35
68
  const ERROR_BODY_LIMIT = 4096;
69
+ /** True for a request the CALLER aborted, which must never be retried. */
70
+ function isAbortError(error) {
71
+ if (typeof error !== "object" || error === null) return false;
72
+ if (error.name === "AbortError") return true;
73
+ const cause = error.cause;
74
+ return typeof cause === "object" && cause !== null && cause.name === "AbortError";
75
+ }
36
76
  /** Cap on the reassembled compaction reply, guarding against a runaway stream. */
37
77
  const COMPLETION_TEXT_LIMIT = 65536;
38
78
  /** Insufficient-credit markers, ASCII lowercase plus the original Chinese. */
@@ -745,9 +785,20 @@ function parseTask(value) {
745
785
  var WorkBuddyUpstreamClient = class {
746
786
  fetchImpl;
747
787
  clientVersion;
788
+ /** Backoff ladder for `fetchModels`; empty means a single attempt. */
789
+ catalogRetryBackoffMs;
790
+ /**
791
+ * Optional logger for retry notices.
792
+ *
793
+ * Set by the host so a retry is visible in the log with its attempt count —
794
+ * without it, a retry that eventually succeeds is invisible, and an operator
795
+ * debugging "why was the catalog slow" has nothing to look at.
796
+ */
797
+ logger;
748
798
  constructor(options = {}) {
749
799
  this.fetchImpl = options.fetchImpl ?? globalThis.fetch;
750
800
  this.clientVersion = options.clientVersion ?? "2.0.4";
801
+ this.catalogRetryBackoffMs = options.catalogRetryBackoffMs ?? CATALOG_RETRY_BACKOFF_MS;
751
802
  }
752
803
  /**
753
804
  * Normalize an OpenAI chat-completions body for the WorkBuddy upstream:
@@ -904,6 +955,30 @@ var WorkBuddyUpstreamClient = class {
904
955
  * below is common to the two branches.
905
956
  */
906
957
  async fetchModels(credential, signal) {
958
+ const attempts = this.catalogRetryBackoffMs.length + 1;
959
+ let lastError;
960
+ for (let attempt = 0; attempt < attempts; attempt += 1) try {
961
+ return await this.fetchModelsOnce(credential, signal);
962
+ } catch (error) {
963
+ lastError = error;
964
+ if (isAbortError(error)) throw error;
965
+ const wait = this.catalogRetryBackoffMs[attempt];
966
+ if (wait === void 0) break;
967
+ this.logger?.warn?.(`dsh-workbuddy-xdpool: model catalog fetch failed (attempt ${attempt + 1}/${attempts}), retrying in ${wait}ms`, error);
968
+ await delay(wait);
969
+ }
970
+ throw lastError;
971
+ }
972
+ /**
973
+ * One catalog attempt, without retries.
974
+ *
975
+ * Split out so {@link fetchModels} can retry it: the failure this guards
976
+ * against is a startup network hiccup, where several independent components
977
+ * see `fetch failed` inside the same second and the very next request
978
+ * succeeds — exactly the case a single attempt turns into "the user's model
979
+ * list is missing half its entries for the rest of the session".
980
+ */
981
+ async fetchModelsOnce(credential, signal) {
907
982
  const global = regionOf(credential.domain) === "global";
908
983
  const url = global ? `${globalBase(credential)}${GLOBAL_CONFIG_PATH}` : `${chatBase(credential)}${MODELS_CATALOG_PATH}`;
909
984
  const headers = global ? {
@@ -1922,42 +1997,155 @@ function macosBundleExecutable(bundle) {
1922
1997
  return join(bundle, "Contents", "MacOS", name);
1923
1998
  }
1924
1999
  /**
2000
+ * Encodings a Windows console child process may emit in, most likely first.
2001
+ *
2002
+ * `reg.exe` writes with the ACTIVE CODE PAGE, never UTF-8: a Chinese Windows
2003
+ * (code page 936) returns a path containing 「腾讯」 as GBK bytes. Decoding
2004
+ * those as UTF-8 mangles them into `��Ѷ`, every `existsSync` then fails, and the
2005
+ * plugin concludes no desktop app is installed while one is running fine.
2006
+ *
2007
+ * The list is ordered and each entry is TRIED rather than guessed: the first
2008
+ * decoding that yields usable text wins, and the decision is validated against
2009
+ * the filesystem rather than by sniffing bytes.
2010
+ */
2011
+ const WINDOWS_CONSOLE_ENCODINGS = [
2012
+ "utf-8",
2013
+ "gbk",
2014
+ "big5",
2015
+ "shift_jis",
2016
+ "euc-kr",
2017
+ "windows-1252"
2018
+ ];
2019
+ /**
2020
+ * Decode bytes from a Windows console child process.
2021
+ *
2022
+ * `accept` decides whether a decoding is usable — the callers pass "this text
2023
+ * contains a path that actually exists". Bytes that are already UTF-8 pass on
2024
+ * the first attempt, so the healthy case pays nothing.
2025
+ */
2026
+ function decodeWindowsConsole(buf, accept) {
2027
+ for (const label of WINDOWS_CONSOLE_ENCODINGS) {
2028
+ let text;
2029
+ try {
2030
+ text = new TextDecoder(label, { fatal: false }).decode(buf);
2031
+ } catch {
2032
+ continue;
2033
+ }
2034
+ if (accept(text)) return text;
2035
+ }
2036
+ return new TextDecoder("utf-8", { fatal: false }).decode(buf);
2037
+ }
2038
+ /** Strip the `"<path>",<index>` decoration DisplayIcon carries. */
2039
+ function cleanIconPath(raw) {
2040
+ return raw.replace(/^"/u, "").replace(/",-?\d+$/u, "").replace(/,-?\d+$/u, "").trim();
2041
+ }
2042
+ /** Registry locations the installer records its own product under. */
2043
+ const UNINSTALL_KEY_ROOTS = [
2044
+ "HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall",
2045
+ "HKLM\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall",
2046
+ "HKCU\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall"
2047
+ ];
2048
+ /**
1925
2049
  * Windows install locations recorded by the app's own uninstaller.
1926
2050
  *
1927
2051
  * The registry is the authoritative answer: it survives a non-default drive, a
1928
2052
  * renamed folder and a differently-named executable, none of which any fixed
1929
2053
  * path list can predict. Real machines put the app at `D:\workbuddy\WorkBuddy.exe`
1930
- * and `D:\workbuddyai\WorkBuddyAI.exe` — exactly the layouts a
1931
- * `%ProgramFiles%\WorkBuddy\WorkBuddy.exe` probe cannot see, which is why the
1932
- * plugin reported "the desktop app could not provide the key" for an app that was
1933
- * installed and running.
2054
+ * and `D:\ruanjian\anzhuang\腾讯\WorkBuddy\WorkBuddy.exe` — layouts a
2055
+ * `%ProgramFiles%\WorkBuddy\WorkBuddy.exe` probe cannot see.
1934
2056
  *
1935
2057
  * `DisplayIcon` is the field that actually carries the path (observed as
1936
2058
  * `D:\workbuddy\WorkBuddy.exe,0`); `InstallLocation` is usually empty for these
1937
2059
  * installers, so both are read and either may contribute.
1938
2060
  *
2061
+ * Two readers, because neither alone is enough:
2062
+ *
2063
+ * - **PowerShell first.** `Get-ItemProperty` talks to the registry API, so no
2064
+ * console code page is involved and non-ASCII paths come back intact. This
2065
+ * is the encoding-proof path.
2066
+ * - **`reg.exe` as a fallback**, for a machine where PowerShell is unavailable
2067
+ * (locked-down or stripped images). Its raw bytes are decoded through
2068
+ * {@link decodeWindowsConsole}, which is what the original code got wrong.
2069
+ *
1939
2070
  * Returns [] on any failure — a missing registry key is the normal case on
1940
2071
  * non-Windows, not an error.
1941
2072
  */
1942
2073
  function windowsRegistryAppPaths() {
1943
2074
  if (process.platform !== "win32") return [];
1944
- const roots = [
1945
- ["HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall", "/**"],
1946
- ["HKLM\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall", "/**"],
1947
- ["HKCU\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall", "/**"]
1948
- ];
2075
+ const viaPowerShell = windowsRegistryAppPathsViaPowerShell();
2076
+ if (viaPowerShell.length > 0) return viaPowerShell;
2077
+ return windowsRegistryAppPathsViaReg();
2078
+ }
2079
+ /**
2080
+ * Registry read through PowerShell, immune to the console code page.
2081
+ *
2082
+ * `[Console]::OutputEncoding` is set to UTF-8 so the bytes Node receives are
2083
+ * UTF-8 regardless of the machine's code page. Values are printed one per line
2084
+ * and matched by the caller, so a Chinese path survives the round trip.
2085
+ *
2086
+ * Best-effort: any failure (no PowerShell, no keys, a policy restriction)
2087
+ * returns [], and the `reg.exe` reader takes over.
2088
+ */
2089
+ function windowsRegistryAppPathsViaPowerShell() {
2090
+ const script = [
2091
+ "$ErrorActionPreference='SilentlyContinue'",
2092
+ "try { [Console]::OutputEncoding=[System.Text.Encoding]::UTF8 } catch {}",
2093
+ "$roots=@('HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*','HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*','HKCU:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\*')",
2094
+ "foreach($r in $roots){",
2095
+ " Get-ItemProperty -Path $r | ForEach-Object {",
2096
+ " $dn=[string]$_.DisplayName; $di=[string]$_.DisplayIcon; $il=[string]$_.InstallLocation",
2097
+ " if($dn -match 'WorkBuddy|CodeBuddy' -or $di -match 'WorkBuddy|CodeBuddy'){",
2098
+ " if($di -ne ''){ Write-Output $di }",
2099
+ " if($il -ne ''){ Write-Output $il }",
2100
+ " }",
2101
+ " }",
2102
+ "}"
2103
+ ].join("\n");
2104
+ let stdout;
2105
+ try {
2106
+ stdout = execFileSync("powershell", [
2107
+ "-NoProfile",
2108
+ "-NonInteractive",
2109
+ "-ExecutionPolicy",
2110
+ "Bypass",
2111
+ "-Command",
2112
+ script
2113
+ ], {
2114
+ encoding: "utf8",
2115
+ timeout: 2e4,
2116
+ windowsHide: true,
2117
+ maxBuffer: 8388608
2118
+ });
2119
+ } catch {
2120
+ return [];
2121
+ }
1949
2122
  const out = [];
1950
- for (const [root] of roots) {
1951
- let listing;
2123
+ for (const line of stdout.split(/\r?\n/u)) {
2124
+ const cleaned = cleanIconPath(line.trim());
2125
+ if (cleaned !== "") out.push(cleaned);
2126
+ }
2127
+ return out;
2128
+ }
2129
+ /**
2130
+ * Registry read through `reg.exe`, with console-code-page detection.
2131
+ *
2132
+ * Kept as the fallback for machines without PowerShell. The decode step is the
2133
+ * fix: the output is captured as BYTES and decoded with whichever encoding
2134
+ * yields a path that exists, instead of assuming UTF-8 and quietly producing
2135
+ * mojibake that no existence check can ever match.
2136
+ */
2137
+ function windowsRegistryAppPathsViaReg() {
2138
+ const out = [];
2139
+ for (const root of UNINSTALL_KEY_ROOTS) {
2140
+ let listingBuf;
1952
2141
  try {
1953
- listing = execFileSync("reg", [
2142
+ listingBuf = execFileSync("reg", [
1954
2143
  "query",
1955
2144
  root,
1956
2145
  "/s",
1957
2146
  "/v",
1958
2147
  "DisplayName"
1959
2148
  ], {
1960
- encoding: "utf8",
1961
2149
  timeout: 1e4,
1962
2150
  windowsHide: true,
1963
2151
  maxBuffer: 8388608
@@ -1965,26 +2153,35 @@ function windowsRegistryAppPaths() {
1965
2153
  } catch {
1966
2154
  continue;
1967
2155
  }
1968
- const keys = listing.split(/\r?\n(?=HKEY_)/u).filter((block) => /WorkBuddy|CodeBuddy/iu.test(block));
2156
+ const keys = decodeWindowsConsole(listingBuf, (text) => /WorkBuddy|CodeBuddy/iu.test(text)).split(/\r?\n(?=HKEY_)/u).filter((block) => /WorkBuddy|CodeBuddy/iu.test(block));
1969
2157
  for (const key of keys) {
1970
2158
  const keyPath = /^(HKEY_[^\r\n]+)/u.exec(key)?.[1]?.trim();
1971
2159
  if (keyPath === void 0) continue;
1972
- for (const name of ["DisplayIcon", "InstallLocation"]) try {
1973
- const value = execFileSync("reg", [
1974
- "query",
1975
- keyPath,
1976
- "/v",
1977
- name
1978
- ], {
1979
- encoding: "utf8",
1980
- timeout: 5e3,
1981
- windowsHide: true
2160
+ for (const name of ["DisplayIcon", "InstallLocation"]) {
2161
+ let valueBuf;
2162
+ try {
2163
+ valueBuf = execFileSync("reg", [
2164
+ "query",
2165
+ keyPath,
2166
+ "/v",
2167
+ name
2168
+ ], {
2169
+ timeout: 5e3,
2170
+ windowsHide: true
2171
+ });
2172
+ } catch {
2173
+ continue;
2174
+ }
2175
+ const value = decodeWindowsConsole(valueBuf, (text) => {
2176
+ const candidate = /REG_(?:SZ|EXPAND_SZ)\s+(.+)$/mu.exec(text)?.[1]?.trim();
2177
+ if (candidate === void 0) return false;
2178
+ const path = cleanIconPath(candidate);
2179
+ return path !== "" && existsSync(path);
1982
2180
  });
1983
2181
  const raw = /REG_(?:SZ|EXPAND_SZ)\s+(.+)$/mu.exec(value)?.[1]?.trim();
1984
2182
  if (raw === void 0 || raw === "") continue;
1985
- const cleaned = raw.replace(/^"/u, "").replace(/",-?\d+$/u, "").replace(/,-?\d+$/u, "").trim();
1986
- out.push(cleaned);
1987
- } catch {}
2183
+ out.push(cleanIconPath(raw));
2184
+ }
1988
2185
  }
1989
2186
  }
1990
2187
  return out;
@@ -2253,7 +2450,7 @@ const ENCRYPTED_CREDENTIAL_CODE = "ENCRYPTED_CREDENTIAL";
2253
2450
  var WorkBuddyEncryptedCredentialError = class extends Error {
2254
2451
  code = ENCRYPTED_CREDENTIAL_CODE;
2255
2452
  constructor(sourcePath) {
2256
- super(`workbuddy: ${sourcePath} holds encrypted credentials, but no WorkBuddy desktop app could be located to provide the key. If the app IS installed, it is simply outside the paths this plugin probes — set WORKBUDDY_APP_EXECUTABLE to its full .exe path (then restart DSH) and the credential will open. Signing in again will not help: the credential itself is intact. Run \`dsh-workbuddy-xdpool doctor\` to see which paths were probed.`);
2453
+ super(`workbuddy: ${sourcePath} holds encrypted credentials, but no WorkBuddy desktop app could be located to provide the key. If the app IS installed, it is simply outside the paths this plugin probes — set WORKBUDDY_APP_EXECUTABLE to its full .exe path (then restart DSH) and the credential will open. Signing in again will not help: the credential itself is intact. Run \`dsh-workbuddy-xdpool doctor\` to see which paths were probed — if one of them looks like mojibake (e.g. "??" where a Chinese folder name should be), the registry value could not be decoded on this machine; report that line and use the override above in the meantime.`);
2257
2454
  this.name = "WorkBuddyEncryptedCredentialError";
2258
2455
  }
2259
2456
  };
@@ -2986,38 +3183,97 @@ ${new Date(until).toISOString()}
2986
3183
  * Static fallback used before the first live catalog fetch, and whenever the
2987
3184
  * upstream cannot be reached.
2988
3185
  *
2989
- * The multipliers are carried on purpose. Without them the provider's model
2990
- * picker silently loses every rate and every free badge the moment the live
2991
- * fetch fails — which reads to the user as "the plugin broke my model list"
2992
- * rather than "the upstream is unreachable". The values are the ones the two
2993
- * gateways actually advertise for these ids (`credits: "x0.79 credits"` and so
2994
- * on), so a fallback row looks the same as a live one.
3186
+ * This table is the DOMESTIC (CN) roster, and it is kept in step with what
3187
+ * `copilot.tencent.com` actually advertises. It matters more than a "just in
3188
+ * case" list usually would: a failed startup fetch falls back to it, and the
3189
+ * user sees that as "half my models were deleted" — `deepseek-v4.1-flash`
3190
+ * and friends simply vanishing from the picker, with no visible explanation.
3191
+ *
3192
+ * Every row below was captured from the live endpoint, INCLUDING the window
3193
+ * sizes: the previous version carried a 32K window for `hy3` when the gateway
3194
+ * says 192K, and listed `kimi-k3` under an id the gateway no longer uses
3195
+ * (`kimi-k3-1`). A stale fallback is worse than a short one — it looks
3196
+ * authoritative while being wrong.
2995
3197
  *
2996
3198
  * `multiplier: 0` is the gateways' own spelling of "free" (`credits: "x0.00"`),
2997
3199
  * which is what turns on the free badge.
2998
3200
  */
2999
3201
  const FALLBACK_WORKBUDDY_MODELS = [
3202
+ {
3203
+ id: "auto",
3204
+ name: "Auto",
3205
+ contextWindow: 256e3,
3206
+ maxOutputTokens: 32e3,
3207
+ supportsImages: true
3208
+ },
3209
+ {
3210
+ id: "hy4-preview",
3211
+ name: "Hy4 preview",
3212
+ contextWindow: 1e6,
3213
+ maxOutputTokens: 64e3,
3214
+ supportsImages: true,
3215
+ multiplier: .29
3216
+ },
3217
+ {
3218
+ id: "hy3",
3219
+ name: "Hy3",
3220
+ contextWindow: 192e3,
3221
+ maxOutputTokens: 64e3,
3222
+ supportsImages: true,
3223
+ multiplier: 0
3224
+ },
3225
+ {
3226
+ id: "hy3-x",
3227
+ name: "Hy3",
3228
+ contextWindow: 192e3,
3229
+ maxOutputTokens: 64e3,
3230
+ supportsImages: true,
3231
+ multiplier: .05
3232
+ },
3233
+ {
3234
+ id: "deepseek-v4.1-flash",
3235
+ name: "Deepseek-V4.1-Flash",
3236
+ contextWindow: 1e6,
3237
+ maxOutputTokens: 128e3,
3238
+ supportsImages: true,
3239
+ multiplier: .11
3240
+ },
3241
+ {
3242
+ id: "deepseek-v4-pro",
3243
+ name: "Deepseek-V4-Pro",
3244
+ contextWindow: 1e6,
3245
+ maxOutputTokens: 128e3,
3246
+ supportsImages: true,
3247
+ multiplier: .51
3248
+ },
3249
+ {
3250
+ id: "deepseek-v4-flash",
3251
+ name: "DeepSeek-V4-Flash",
3252
+ contextWindow: 1e6,
3253
+ maxOutputTokens: 128e3,
3254
+ supportsImages: true
3255
+ },
3000
3256
  {
3001
3257
  id: "glm-5.3",
3002
3258
  name: "GLM-5.3",
3003
- contextWindow: 2e5,
3004
- maxOutputTokens: 128e3,
3259
+ contextWindow: 1e6,
3260
+ maxOutputTokens: 64e3,
3005
3261
  supportsImages: true,
3006
3262
  multiplier: .79
3007
3263
  },
3008
3264
  {
3009
3265
  id: "glm-5.3-flash",
3010
3266
  name: "GLM-5.3-Flash",
3011
- contextWindow: 2e5,
3012
- maxOutputTokens: 128e3,
3267
+ contextWindow: 1e6,
3268
+ maxOutputTokens: 131072,
3013
3269
  supportsImages: true,
3014
3270
  multiplier: .06
3015
3271
  },
3016
3272
  {
3017
3273
  id: "glm-5.2",
3018
3274
  name: "GLM-5.2",
3019
- contextWindow: 2e5,
3020
- maxOutputTokens: 128e3,
3275
+ contextWindow: 1e6,
3276
+ maxOutputTokens: 64e3,
3021
3277
  supportsImages: true,
3022
3278
  multiplier: .79
3023
3279
  },
@@ -3025,53 +3281,57 @@ const FALLBACK_WORKBUDDY_MODELS = [
3025
3281
  id: "glm-5.1",
3026
3282
  name: "GLM-5.1",
3027
3283
  contextWindow: 2e5,
3028
- maxOutputTokens: 128e3,
3029
- supportsImages: false
3030
- },
3031
- {
3032
- id: "deepseek-v4-pro",
3033
- name: "DeepSeek-V4-Pro",
3034
- contextWindow: 2e5,
3035
- maxOutputTokens: 128e3,
3036
- supportsImages: true
3284
+ maxOutputTokens: 48e3,
3285
+ supportsImages: false,
3286
+ multiplier: .79
3037
3287
  },
3038
3288
  {
3039
- id: "deepseek-v4-flash",
3040
- name: "DeepSeek-V4-Flash",
3289
+ id: "glm-5v-turbo",
3290
+ name: "GLM-5v-Turbo",
3041
3291
  contextWindow: 2e5,
3042
- maxOutputTokens: 128e3,
3043
- supportsImages: true
3292
+ maxOutputTokens: 64e3,
3293
+ supportsImages: true,
3294
+ multiplier: .71
3044
3295
  },
3045
3296
  {
3046
- id: "kimi-k3",
3297
+ id: "kimi-k3-1",
3047
3298
  name: "Kimi-K3",
3048
- contextWindow: 2e5,
3049
- maxOutputTokens: 128e3,
3299
+ contextWindow: 1e6,
3300
+ maxOutputTokens: 32e3,
3050
3301
  supportsImages: true,
3051
3302
  multiplier: 1.62
3052
3303
  },
3053
3304
  {
3054
- id: "minimax-m3",
3055
- name: "MiniMax-M3",
3056
- contextWindow: 2e5,
3057
- maxOutputTokens: 128e3,
3058
- supportsImages: true
3305
+ id: "kimi-k2.8-preview",
3306
+ name: "Kimi-K2.8-Preview",
3307
+ contextWindow: 1e6,
3308
+ maxOutputTokens: 64e3,
3309
+ supportsImages: true,
3310
+ multiplier: .77
3059
3311
  },
3060
3312
  {
3061
- id: "hy3",
3062
- name: "Hy3",
3063
- contextWindow: 32e3,
3064
- maxOutputTokens: 8e3,
3313
+ id: "kimi-k2.7",
3314
+ name: "Kimi-K2.7-Code",
3315
+ contextWindow: 256e3,
3316
+ maxOutputTokens: 32e3,
3065
3317
  supportsImages: true,
3066
- multiplier: 0
3318
+ multiplier: .57
3067
3319
  },
3068
3320
  {
3069
- id: "hy4-preview",
3070
- name: "Hy4-Preview",
3071
- contextWindow: 1e6,
3072
- maxOutputTokens: 128e3,
3321
+ id: "kimi-k2.6",
3322
+ name: "Kimi-K2.6",
3323
+ contextWindow: 256e3,
3324
+ maxOutputTokens: 32e3,
3073
3325
  supportsImages: true,
3074
- multiplier: .29
3326
+ multiplier: .52
3327
+ },
3328
+ {
3329
+ id: "minimax-m3",
3330
+ name: "MiniMax-M3",
3331
+ contextWindow: 512e3,
3332
+ maxOutputTokens: 64e3,
3333
+ supportsImages: true,
3334
+ multiplier: .25
3075
3335
  }
3076
3336
  ];
3077
3337
  /** Live catalog with a static fallback behind it. */
@@ -3080,6 +3340,19 @@ var WorkBuddyCatalog = class {
3080
3340
  listeners = /* @__PURE__ */ new Set();
3081
3341
  /** User's model selection. Empty object = follow the catalog unfiltered. */
3082
3342
  selection = {};
3343
+ /**
3344
+ * Whether `models` came from the gateway or from the static table.
3345
+ *
3346
+ * Tracked so the card can SAY which it is showing. A failed fetch used to be
3347
+ * invisible on screen: the picker simply held fewer models than before, which
3348
+ * the user reasonably read as "the plugin deleted my models" rather than "the
3349
+ * network hiccuped at startup".
3350
+ */
3351
+ source = "fallback";
3352
+ /** When the live list last landed. */
3353
+ lastUpdatedAt;
3354
+ /** Why the last fetch failed, for display. */
3355
+ lastError;
3083
3356
  current() {
3084
3357
  return this.models;
3085
3358
  }
@@ -3114,6 +3387,7 @@ var WorkBuddyCatalog = class {
3114
3387
  /** Restore the static fallback, e.g. when the upstream stops answering. */
3115
3388
  reset() {
3116
3389
  this.models = FALLBACK_WORKBUDDY_MODELS;
3390
+ this.source = "fallback";
3117
3391
  this.notify();
3118
3392
  }
3119
3393
  /** Replace the user's selection; the adapter rebuilds from `visible()`. */
@@ -3125,6 +3399,31 @@ var WorkBuddyCatalog = class {
3125
3399
  currentSelection() {
3126
3400
  return this.selection;
3127
3401
  }
3402
+ /**
3403
+ * Whether this catalog is serving live data or the built-in table.
3404
+ *
3405
+ * `fallback` is not an error state, but it IS a degraded one: the user is
3406
+ * looking at a shorter roster than the gateway offers, so the card says so
3407
+ * and offers a retry instead of letting them wonder where the models went.
3408
+ */
3409
+ currentSource() {
3410
+ return this.source;
3411
+ }
3412
+ /**
3413
+ * When the live list last landed, if it ever did.
3414
+ *
3415
+ * Named `catalogUpdatedAt()` rather than `updatedAt()` because the class
3416
+ * already had an `updatedAt` member; two members of the same name is a
3417
+ * compile error, and the awkwardness is a useful signal that the concept is
3418
+ * "when THIS catalog was refreshed", not a generic timestamp.
3419
+ */
3420
+ catalogUpdatedAt() {
3421
+ return this.lastUpdatedAt;
3422
+ }
3423
+ /** Why the last fetch failed, if it did. */
3424
+ lastFetchError() {
3425
+ return this.lastError;
3426
+ }
3128
3427
  onChange(listener) {
3129
3428
  this.listeners.add(listener);
3130
3429
  return () => this.listeners.delete(listener);
@@ -3135,6 +3434,20 @@ var WorkBuddyCatalog = class {
3135
3434
  /** Replace the catalog from the live upstream list; keeps the fallback if empty. */
3136
3435
  updateFromUpstream(models) {
3137
3436
  this.update(catalogFromUpstream(models));
3437
+ this.source = "live";
3438
+ this.lastUpdatedAt = (/* @__PURE__ */ new Date()).toISOString();
3439
+ this.lastError = void 0;
3440
+ }
3441
+ /**
3442
+ * Record that a fetch attempt failed, leaving the current list in place.
3443
+ *
3444
+ * The list is deliberately NOT reset here: a refresh that fails should keep
3445
+ * whatever working catalog is already loaded, rather than demoting a healthy
3446
+ * session to the static table because one retry ran out.
3447
+ */
3448
+ noteFetchFailure(message) {
3449
+ this.lastError = message;
3450
+ this.notify();
3138
3451
  }
3139
3452
  notify() {
3140
3453
  for (const listener of this.listeners) listener();
@@ -3778,6 +4091,16 @@ function expertChatEvents(expert, conversationId, requestId) {
3778
4091
  const POOL_STATUS_PATH = "/plugins/dsh-workbuddy-xdpool/status";
3779
4092
  /** Plugin-owned local account rescan endpoint (re-read desktop snapshots). */
3780
4093
  const POOL_RESCAN_PATH = "/plugins/dsh-workbuddy-xdpool/accounts/rescan";
4094
+ /**
4095
+ * Re-fetch the upstream model catalog for both regions.
4096
+ *
4097
+ * Separate from {@link POOL_RESCAN_PATH} because they answer different
4098
+ * questions, and conflating them misled users: "detect accounts again" only
4099
+ * re-read the desktop snapshots, so it could NOT recover a model list that had
4100
+ * fallen back to the static table after a failed startup fetch. The only way
4101
+ * out was restarting DSH.
4102
+ */
4103
+ const POOL_CATALOG_REFRESH_PATH = "/plugins/dsh-workbuddy-xdpool/models/refresh";
3781
4104
  /** Plugin-owned cooldown reset endpoint (clear all 429 cooldowns). */
3782
4105
  const POOL_RESET_COOLDOWN_PATH = "/plugins/dsh-workbuddy-xdpool/cooldowns/reset";
3783
4106
  /** Plugin-owned daily check-in action endpoint (claim today's reward). */
@@ -5949,7 +6272,10 @@ async function poolWebStatus(deps, region = "cn") {
5949
6272
  shim,
5950
6273
  automation,
5951
6274
  creditReserves: deps.pool.creditReservesInOrder(),
5952
- ignored: deps.ignoredAccounts?.() ?? []
6275
+ ignored: deps.ignoredAccounts?.() ?? [],
6276
+ catalogSource: deps.catalogs[region].currentSource(),
6277
+ ...deps.catalogs[region].catalogUpdatedAt() === void 0 ? {} : { catalogUpdatedAt: deps.catalogs[region].catalogUpdatedAt() },
6278
+ ...deps.catalogs[region].lastFetchError() === void 0 ? {} : { catalogError: safeMessage(deps.catalogs[region].lastFetchError()) }
5953
6279
  };
5954
6280
  }
5955
6281
  /**
@@ -5972,6 +6298,35 @@ function registerPoolStatusRoute(ctx, deps) {
5972
6298
  }
5973
6299
  }
5974
6300
  });
6301
+ /**
6302
+ * Re-fetch the upstream model catalog.
6303
+ *
6304
+ * Its own route, distinct from the account rescan, because that is the
6305
+ * distinction users were missing: a model list that had fallen back to the
6306
+ * static table could not be recovered from the card AT ALL — "detect
6307
+ * accounts again" only re-read the desktop snapshots, so the only way back
6308
+ * to the full roster was restarting DSH.
6309
+ *
6310
+ * POST only, loopback origin only. Returns the resulting roster size per
6311
+ * region so the card can confirm the refresh actually changed something.
6312
+ */
6313
+ const disposeCatalogRefresh = ctx.webServer.register({
6314
+ kind: "exact",
6315
+ path: POOL_CATALOG_REFRESH_PATH,
6316
+ handler: async (req, res) => {
6317
+ if (req.method !== "POST") return json(res, 405, { error: "method not allowed" });
6318
+ if (!loopbackOrigin(req)) return json(res, 403, { error: "origin-not-trusted" });
6319
+ if (deps.refreshCatalog === void 0) return json(res, 503, { error: "catalog refresh is not available in this build" });
6320
+ try {
6321
+ json(res, 200, {
6322
+ ok: true,
6323
+ ...await deps.refreshCatalog()
6324
+ });
6325
+ } catch (error) {
6326
+ json(res, 500, { error: safeMessage(error) });
6327
+ }
6328
+ }
6329
+ });
5975
6330
  const disposeRescan = ctx.webServer.register({
5976
6331
  kind: "exact",
5977
6332
  path: POOL_RESCAN_PATH,
@@ -6169,6 +6524,7 @@ function registerPoolStatusRoute(ctx, deps) {
6169
6524
  disposeAutomationRun();
6170
6525
  disposeCreditReserve();
6171
6526
  disposeAccountIgnore();
6527
+ disposeCatalogRefresh();
6172
6528
  disposeCheckin();
6173
6529
  disposeAccountDisable();
6174
6530
  disposeModelsSave();
@@ -6537,6 +6893,51 @@ function apply(ctx, config = {}) {
6537
6893
  core.pool.applyIgnored(ignoredAccounts.map((entry) => entry.id));
6538
6894
  };
6539
6895
  /**
6896
+ * Fetch and install the model catalog for both regions.
6897
+ *
6898
+ * Each region is isolated AND independently retried: they are separate
6899
+ * gateways on separate hosts, so one being unreachable must not degrade the
6900
+ * other. The failure this exists for is a startup network hiccup — several
6901
+ * components reporting `fetch failed` inside the same second, with the next
6902
+ * request succeeding 0.7s later — which used to cost the user their model
6903
+ * list for the whole session, with no way back short of a DSH restart.
6904
+ *
6905
+ * Returns what each region ended up with, so the card can confirm a manual
6906
+ * refresh actually changed something.
6907
+ */
6908
+ const seedCatalog = async () => {
6909
+ const accounts = await core.pool.scan();
6910
+ const regions = {};
6911
+ for (const region of ["cn", "global"]) try {
6912
+ const credential = accounts.find((account) => regionOf(account.credential.domain) === region)?.credential;
6913
+ if (credential === void 0) {
6914
+ ctx.logger.info?.(`dsh-workbuddy-xdpool: no ${region} account yet; keeping the static ${region} catalog`);
6915
+ regions[region] = {
6916
+ source: "fallback",
6917
+ models: core.catalogs[region].current().length
6918
+ };
6919
+ continue;
6920
+ }
6921
+ const models = await core.client.fetchModels(credential);
6922
+ core.catalogs[region].updateFromUpstream(models);
6923
+ ctx.logger.info?.(`dsh-workbuddy-xdpool: ${region} catalog seeded with ${models.length} model(s)`);
6924
+ regions[region] = {
6925
+ source: "live",
6926
+ models: models.length
6927
+ };
6928
+ } catch (error) {
6929
+ const message = error instanceof Error ? error.message : String(error);
6930
+ core.catalogs[region].noteFetchFailure(message);
6931
+ ctx.logger.warn(`dsh-workbuddy-xdpool: ${region} model catalog unavailable; using static fallback`, error);
6932
+ regions[region] = {
6933
+ source: core.catalogs[region].currentSource(),
6934
+ models: core.catalogs[region].current().length,
6935
+ error: message
6936
+ };
6937
+ }
6938
+ return { regions };
6939
+ };
6940
+ /**
6540
6941
  * Invalidate the provider snapshot so the picker re-reads the catalog.
6541
6942
  *
6542
6943
  * Seeded with a no-op and reassigned once the adapters exist. The settings
@@ -6741,7 +7142,12 @@ function apply(ctx, config = {}) {
6741
7142
  refreshIgnored();
6742
7143
  if (!ignored) await core.pool.scan();
6743
7144
  },
6744
- ignoredAccounts: () => ignoredAccounts
7145
+ ignoredAccounts: () => ignoredAccounts,
7146
+ refreshCatalog: async () => {
7147
+ const result = await seedCatalog();
7148
+ invalidateCatalog();
7149
+ return result;
7150
+ }
6745
7151
  }));
6746
7152
  api = {
6747
7153
  ...core,
@@ -6752,6 +7158,8 @@ function apply(ctx, config = {}) {
6752
7158
  async rescan() {
6753
7159
  const accounts = await core.pool.scan();
6754
7160
  ctx.logger.info?.(`dsh-workbuddy-xdpool: discovered ${accounts.length} account(s)`);
7161
+ await seedCatalog();
7162
+ invalidateCatalog();
6755
7163
  return accounts.length;
6756
7164
  },
6757
7165
  async status(includeCredits = false) {
@@ -6852,19 +7260,7 @@ function apply(ctx, config = {}) {
6852
7260
  ctx.logger.warn("dsh-workbuddy-xdpool: account discovery failed", error);
6853
7261
  });
6854
7262
  (async () => {
6855
- const accounts = await core.pool.scan();
6856
- for (const region of ["cn", "global"]) try {
6857
- const credential = accounts.find((account) => regionOf(account.credential.domain) === region)?.credential;
6858
- if (credential === void 0) {
6859
- ctx.logger.info?.(`dsh-workbuddy-xdpool: no ${region} account yet; keeping the static ${region} catalog`);
6860
- continue;
6861
- }
6862
- const models = await core.client.fetchModels(credential);
6863
- core.catalogs[region].updateFromUpstream(models);
6864
- ctx.logger.info?.(`dsh-workbuddy-xdpool: ${region} catalog seeded with ${models.length} model(s)`);
6865
- } catch (error) {
6866
- ctx.logger.warn(`dsh-workbuddy-xdpool: ${region} model catalog unavailable; using static fallback`, error);
6867
- }
7263
+ await seedCatalog();
6868
7264
  invalidateCatalog();
6869
7265
  })().catch((error) => {
6870
7266
  ctx.logger.warn("dsh-workbuddy-xdpool: account catalog seed failed", error);