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/CHANGELOG.md +82 -0
- package/lib/bin.js +410 -72
- package/lib/client.js +89 -15
- package/lib/index.d.ts +1033 -926
- package/lib/index.js +482 -86
- package/package.json +1 -1
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:\
|
|
1931
|
-
* `%ProgramFiles%\WorkBuddy\WorkBuddy.exe` probe cannot see
|
|
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
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
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
|
|
1951
|
-
|
|
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
|
-
|
|
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 =
|
|
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"])
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
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
|
-
|
|
1986
|
-
|
|
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
|
-
*
|
|
2990
|
-
*
|
|
2991
|
-
*
|
|
2992
|
-
*
|
|
2993
|
-
*
|
|
2994
|
-
*
|
|
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:
|
|
3004
|
-
maxOutputTokens:
|
|
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:
|
|
3012
|
-
maxOutputTokens:
|
|
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:
|
|
3020
|
-
maxOutputTokens:
|
|
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:
|
|
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: "
|
|
3040
|
-
name: "
|
|
3289
|
+
id: "glm-5v-turbo",
|
|
3290
|
+
name: "GLM-5v-Turbo",
|
|
3041
3291
|
contextWindow: 2e5,
|
|
3042
|
-
maxOutputTokens:
|
|
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:
|
|
3049
|
-
maxOutputTokens:
|
|
3299
|
+
contextWindow: 1e6,
|
|
3300
|
+
maxOutputTokens: 32e3,
|
|
3050
3301
|
supportsImages: true,
|
|
3051
3302
|
multiplier: 1.62
|
|
3052
3303
|
},
|
|
3053
3304
|
{
|
|
3054
|
-
id: "
|
|
3055
|
-
name: "
|
|
3056
|
-
contextWindow:
|
|
3057
|
-
maxOutputTokens:
|
|
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: "
|
|
3062
|
-
name: "
|
|
3063
|
-
contextWindow:
|
|
3064
|
-
maxOutputTokens:
|
|
3313
|
+
id: "kimi-k2.7",
|
|
3314
|
+
name: "Kimi-K2.7-Code",
|
|
3315
|
+
contextWindow: 256e3,
|
|
3316
|
+
maxOutputTokens: 32e3,
|
|
3065
3317
|
supportsImages: true,
|
|
3066
|
-
multiplier:
|
|
3318
|
+
multiplier: .57
|
|
3067
3319
|
},
|
|
3068
3320
|
{
|
|
3069
|
-
id: "
|
|
3070
|
-
name: "
|
|
3071
|
-
contextWindow:
|
|
3072
|
-
maxOutputTokens:
|
|
3321
|
+
id: "kimi-k2.6",
|
|
3322
|
+
name: "Kimi-K2.6",
|
|
3323
|
+
contextWindow: 256e3,
|
|
3324
|
+
maxOutputTokens: 32e3,
|
|
3073
3325
|
supportsImages: true,
|
|
3074
|
-
multiplier: .
|
|
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
|
-
|
|
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);
|