@eddyskywalker/dsh-chatgpt-subscription 0.36.2 → 0.37.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.
- package/CHANGELOG.md +39 -0
- package/README.md +23 -3
- package/lib/client.js +340 -47
- package/lib/client.js.map +1 -1
- package/lib/index.js +3043 -40
- package/lib/types/client/common/ComposerQuotaBadge.d.ts +7 -0
- package/lib/types/client/common/ComposerQuotaBadge.d.ts.map +1 -1
- package/lib/types/client/locales-ru/opendesign.d.ts.map +1 -1
- package/lib/types/client/locales-ru/progress-updates.d.ts.map +1 -1
- package/lib/types/client/locales.d.ts +4 -4
- package/lib/types/client/opendesign/OpenDesignComposerQuota.d.ts +6 -0
- package/lib/types/client/opendesign/OpenDesignComposerQuota.d.ts.map +1 -1
- package/lib/types/client/opendesign/OpenDesignSection.d.ts.map +1 -1
- package/lib/types/client/opendesign/locales.d.ts +54 -0
- package/lib/types/client/opendesign/locales.d.ts.map +1 -1
- package/lib/types/client/opendesign/styles.d.ts +5 -0
- package/lib/types/client/opendesign/styles.d.ts.map +1 -1
- package/lib/types/client/progress-updates/locales.d.ts +17 -2
- package/lib/types/client/progress-updates/locales.d.ts.map +1 -1
- package/lib/types/client/progress-updates/settings-binding.d.ts +13 -3
- package/lib/types/client/progress-updates/settings-binding.d.ts.map +1 -1
- package/lib/types/client/progress-updates/settings-section.d.ts +5 -5
- package/lib/types/client/progress-updates/settings-section.d.ts.map +1 -1
- package/lib/types/client/progress-updates/styles.d.ts.map +1 -1
- package/lib/types/host/opendesign/client.d.ts +103 -0
- package/lib/types/host/opendesign/client.d.ts.map +1 -1
- package/lib/types/host/opendesign/routes.d.ts.map +1 -1
- package/lib/types/host/opendesign/types.d.ts +31 -0
- package/lib/types/host/opendesign/types.d.ts.map +1 -1
- package/lib/types/host/progress-updates/index.d.ts +5 -0
- package/lib/types/host/progress-updates/index.d.ts.map +1 -1
- package/lib/types/host/progress-updates/prompt.d.ts +37 -3
- package/lib/types/host/progress-updates/prompt.d.ts.map +1 -1
- package/lib/types/host/progress-updates/tool.d.ts.map +1 -1
- package/lib/types/host/proxy-manager.d.ts +63 -1
- package/lib/types/host/proxy-manager.d.ts.map +1 -1
- package/lib/types/host/widget-board/line-contracts.d.ts +192 -0
- package/lib/types/host/widget-board/line-contracts.d.ts.map +1 -0
- package/lib/types/host/widget-board/line-quota.d.ts +284 -0
- package/lib/types/host/widget-board/line-quota.d.ts.map +1 -0
- package/lib/types/host/widget-board/line-registry.d.ts +402 -0
- package/lib/types/host/widget-board/line-registry.d.ts.map +1 -0
- package/lib/types/host/widget-board/provider.d.ts +140 -0
- package/lib/types/host/widget-board/provider.d.ts.map +1 -0
- package/lib/types/index.d.ts +10 -0
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/shared/opendesign-contracts.d.ts +47 -0
- package/lib/types/shared/opendesign-contracts.d.ts.map +1 -1
- package/package.json +2 -1
- package/widgets/line-flip/index.html +58 -0
- package/widgets/line-flip/main.js +1102 -0
- package/widgets/line-flip/style.css +729 -0
- package/widgets/line-flip/widget.json +555 -0
package/lib/index.js
CHANGED
|
@@ -803,7 +803,7 @@ async function rehydrateSnapshot(key) {
|
|
|
803
803
|
* so a backend that flattens the envelope does not read as an empty catalog.
|
|
804
804
|
*/
|
|
805
805
|
function parseListing(payload) {
|
|
806
|
-
const record = asRecord$
|
|
806
|
+
const record = asRecord$18(payload);
|
|
807
807
|
const models = Array.isArray(payload) ? payload : record?.models;
|
|
808
808
|
if (!Array.isArray(models)) return [];
|
|
809
809
|
return models.flatMap((item) => parseListingEntry$1(item));
|
|
@@ -817,11 +817,11 @@ function parseListing(payload) {
|
|
|
817
817
|
* back to a conservative answer rather than to a guess.
|
|
818
818
|
*/
|
|
819
819
|
function parseListingEntry$1(value) {
|
|
820
|
-
const record = asRecord$
|
|
820
|
+
const record = asRecord$18(value);
|
|
821
821
|
if (record === void 0) return [];
|
|
822
822
|
const id = asString$25(record.slug) ?? asString$25(record.id);
|
|
823
823
|
if (id === void 0) return [];
|
|
824
|
-
const contextWindow = asNumber$
|
|
824
|
+
const contextWindow = asNumber$7(record.context_window) ?? asNumber$7(record.contextWindow);
|
|
825
825
|
const modalities = parseModalities(record.input_modalities);
|
|
826
826
|
const efforts = parseEfforts(record.supported_reasoning_levels);
|
|
827
827
|
const fallback = CODEX_MODEL_CATALOG.find((entry) => entry.id === id);
|
|
@@ -853,7 +853,7 @@ function parseEfforts(value) {
|
|
|
853
853
|
if (!Array.isArray(value)) return null;
|
|
854
854
|
const out = [];
|
|
855
855
|
for (const item of value) {
|
|
856
|
-
const effort = asString$25(item) ?? asString$25(asRecord$
|
|
856
|
+
const effort = asString$25(item) ?? asString$25(asRecord$18(item)?.effort);
|
|
857
857
|
if (effort !== void 0 && !out.includes(effort)) out.push(effort);
|
|
858
858
|
}
|
|
859
859
|
return out.length > 0 ? out : null;
|
|
@@ -868,13 +868,13 @@ function parseEfforts(value) {
|
|
|
868
868
|
function accountKeyFor$1(credentials) {
|
|
869
869
|
return credentials.accountId || "default";
|
|
870
870
|
}
|
|
871
|
-
function asRecord$
|
|
871
|
+
function asRecord$18(value) {
|
|
872
872
|
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
|
|
873
873
|
}
|
|
874
874
|
function asString$25(value) {
|
|
875
875
|
return typeof value === "string" && value !== "" ? value : void 0;
|
|
876
876
|
}
|
|
877
|
-
function asNumber$
|
|
877
|
+
function asNumber$7(value) {
|
|
878
878
|
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
879
879
|
}
|
|
880
880
|
//#endregion
|
|
@@ -8675,7 +8675,143 @@ function parseEnvProxy(env = process.env, options = {}) {
|
|
|
8675
8675
|
} catch {}
|
|
8676
8676
|
return null;
|
|
8677
8677
|
}
|
|
8678
|
-
|
|
8678
|
+
/**
|
|
8679
|
+
* The raw `NO_PROXY` list, or an empty string when none is configured.
|
|
8680
|
+
*
|
|
8681
|
+
* Read next to the proxy itself on purpose: a proxy that a line is *told* to use
|
|
8682
|
+
* and a bypass list that lives somewhere else are how "the local gateway
|
|
8683
|
+
* suddenly goes through Clash" happens.
|
|
8684
|
+
*/
|
|
8685
|
+
function parseNoProxy(env = process.env, options = {}) {
|
|
8686
|
+
const direct = env.NO_PROXY ?? env.no_proxy;
|
|
8687
|
+
if (direct && direct.trim()) return direct.trim();
|
|
8688
|
+
if (options.envFile === null) return "";
|
|
8689
|
+
try {
|
|
8690
|
+
const dshHome = env.DSH_HOME || path.join(os.homedir(), ".dsh");
|
|
8691
|
+
const envFile = options.envFile ?? path.join(dshHome, ".env");
|
|
8692
|
+
if (fs$1.existsSync(envFile)) {
|
|
8693
|
+
const match = fs$1.readFileSync(envFile, "utf8").match(/^(?:export\s+)?(?:NO_PROXY|no_proxy)\s*=\s*["']?([^"'\r\n]*)["']?/m);
|
|
8694
|
+
if (match) return (match[1] ?? "").trim();
|
|
8695
|
+
}
|
|
8696
|
+
} catch {}
|
|
8697
|
+
return "";
|
|
8698
|
+
}
|
|
8699
|
+
/** {@link shouldBypassProxy} needs a port to honor a `host:port` entry. */
|
|
8700
|
+
function defaultPortFor(protocol) {
|
|
8701
|
+
if (protocol === "http:") return "80";
|
|
8702
|
+
if (protocol === "https:") return "443";
|
|
8703
|
+
return "";
|
|
8704
|
+
}
|
|
8705
|
+
function normalizeHostForMatch(hostname) {
|
|
8706
|
+
return hostname.toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, "");
|
|
8707
|
+
}
|
|
8708
|
+
/**
|
|
8709
|
+
* Whether a host is loopback, and therefore never worth a proxy hop.
|
|
8710
|
+
*
|
|
8711
|
+
* The whole `127.0.0.0/8` block, not just `127.0.0.1`: a second loopback alias
|
|
8712
|
+
* is a normal way to run a local gateway, and a proxy cannot reach it any
|
|
8713
|
+
* better than the process itself can.
|
|
8714
|
+
*/
|
|
8715
|
+
function isLoopbackHostname$1(hostname) {
|
|
8716
|
+
const host = normalizeHostForMatch(hostname);
|
|
8717
|
+
if (host === "localhost" || host.endsWith(".localhost")) return true;
|
|
8718
|
+
if (host === "::1") return true;
|
|
8719
|
+
const v4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
8720
|
+
return v4 !== null && v4[1] === "127";
|
|
8721
|
+
}
|
|
8722
|
+
/**
|
|
8723
|
+
* Standard `NO_PROXY` matching: `*`, exact hosts, domain suffixes, `host:port`.
|
|
8724
|
+
*
|
|
8725
|
+
* Deliberately not CIDR: a proxy configured for this plugin is a local GUI
|
|
8726
|
+
* proxy, and the entries that matter are the ones a user types.
|
|
8727
|
+
*/
|
|
8728
|
+
function noProxyBypasses(hostname, port, list) {
|
|
8729
|
+
const host = normalizeHostForMatch(hostname);
|
|
8730
|
+
for (const raw of list.split(",")) {
|
|
8731
|
+
const entry = raw.trim().toLowerCase();
|
|
8732
|
+
if (!entry) continue;
|
|
8733
|
+
if (entry === "*") return true;
|
|
8734
|
+
const withoutScheme = entry.replace(/^[a-z][a-z0-9+.-]*:\/\//, "").replace(/\/.*$/, "");
|
|
8735
|
+
let entryHost = withoutScheme;
|
|
8736
|
+
let entryPort = "";
|
|
8737
|
+
const bracketed = withoutScheme.match(/^\[([^\]]+)\](?::(\d+))?$/);
|
|
8738
|
+
if (bracketed) {
|
|
8739
|
+
entryHost = bracketed[1];
|
|
8740
|
+
entryPort = bracketed[2] ?? "";
|
|
8741
|
+
} else if (withoutScheme.split(":").length === 2) {
|
|
8742
|
+
const [name, candidate] = withoutScheme.split(":");
|
|
8743
|
+
if (/^\d+$/.test(candidate)) {
|
|
8744
|
+
entryHost = name;
|
|
8745
|
+
entryPort = candidate;
|
|
8746
|
+
}
|
|
8747
|
+
}
|
|
8748
|
+
const suffix = entryHost.replace(/^\./, "").replace(/\.$/, "");
|
|
8749
|
+
if (!suffix) continue;
|
|
8750
|
+
if (entryPort && entryPort !== port) continue;
|
|
8751
|
+
if (host === suffix || host.endsWith("." + suffix)) return true;
|
|
8752
|
+
}
|
|
8753
|
+
return false;
|
|
8754
|
+
}
|
|
8755
|
+
/**
|
|
8756
|
+
* Whether a request target must skip the proxy.
|
|
8757
|
+
*
|
|
8758
|
+
* Loopback first: a local gateway behind a proxy hop is a request the proxy has
|
|
8759
|
+
* to refuse (or worse, forwards to itself), and the plugin's own loopback reads
|
|
8760
|
+
* already use a bare fetch for the same reason.
|
|
8761
|
+
*/
|
|
8762
|
+
function shouldBypassProxy(target, noProxyList = "") {
|
|
8763
|
+
let url;
|
|
8764
|
+
try {
|
|
8765
|
+
url = typeof target === "string" ? new URL(target) : target;
|
|
8766
|
+
} catch {
|
|
8767
|
+
return false;
|
|
8768
|
+
}
|
|
8769
|
+
if (isLoopbackHostname$1(url.hostname)) return true;
|
|
8770
|
+
if (!noProxyList) return false;
|
|
8771
|
+
return noProxyBypasses(url.hostname, url.port || defaultPortFor(url.protocol), noProxyList);
|
|
8772
|
+
}
|
|
8773
|
+
/**
|
|
8774
|
+
* Read one GNOME proxy setting; `null` when `gsettings` is absent or the key is
|
|
8775
|
+
* unreadable (headless box, no dconf session).
|
|
8776
|
+
*/
|
|
8777
|
+
function readGsettings(key) {
|
|
8778
|
+
try {
|
|
8779
|
+
return execSync(`gsettings get ${key}`, {
|
|
8780
|
+
timeout: 1500,
|
|
8781
|
+
encoding: "utf-8",
|
|
8782
|
+
stdio: [
|
|
8783
|
+
"ignore",
|
|
8784
|
+
"pipe",
|
|
8785
|
+
"ignore"
|
|
8786
|
+
]
|
|
8787
|
+
}).trim();
|
|
8788
|
+
} catch {
|
|
8789
|
+
return null;
|
|
8790
|
+
}
|
|
8791
|
+
}
|
|
8792
|
+
/** gsettings prints strings single-quoted, ports bare, and absent keys as ''. */
|
|
8793
|
+
function gsettingsString(value) {
|
|
8794
|
+
if (value === null) return "";
|
|
8795
|
+
return value.replace(/^['"]|['"]$/g, "").trim();
|
|
8796
|
+
}
|
|
8797
|
+
/**
|
|
8798
|
+
* GNOME's manual proxy, the Linux equivalent of the Windows registry read.
|
|
8799
|
+
*
|
|
8800
|
+
* `auto` is a PAC URL, which cannot be applied by posting through an agent, and
|
|
8801
|
+
* `none` means no system proxy; both fall through to the environment.
|
|
8802
|
+
*/
|
|
8803
|
+
function parseLinuxSystemProxy(read) {
|
|
8804
|
+
if (gsettingsString(read("org.gnome.system.proxy mode")).toLowerCase() !== "manual") return null;
|
|
8805
|
+
const scheme = (name) => {
|
|
8806
|
+
const host = gsettingsString(read(`org.gnome.system.proxy.${name} host`));
|
|
8807
|
+
const port = Number(gsettingsString(read(`org.gnome.system.proxy.${name} port`)));
|
|
8808
|
+
if (!host || !Number.isInteger(port) || port <= 0 || port > 65535) return null;
|
|
8809
|
+
const authority = host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
|
|
8810
|
+
return `${name === "socks" ? "socks5" : "http"}://${authority}:${port}`;
|
|
8811
|
+
};
|
|
8812
|
+
return scheme("https") ?? scheme("http") ?? scheme("socks");
|
|
8813
|
+
}
|
|
8814
|
+
function detectSystemProxy(platform = process.platform, env = process.env, readSettings = readGsettings) {
|
|
8679
8815
|
try {
|
|
8680
8816
|
if (platform === "win32") {
|
|
8681
8817
|
const detected = parseWindowsProxyRegistry(execSync("reg query \"HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings\"", {
|
|
@@ -8699,17 +8835,33 @@ function detectSystemProxy(platform = process.platform, env = process.env) {
|
|
|
8699
8835
|
]
|
|
8700
8836
|
}));
|
|
8701
8837
|
if (detected) return detected;
|
|
8838
|
+
} else if (platform === "linux") {
|
|
8839
|
+
const detected = parseLinuxSystemProxy(readSettings);
|
|
8840
|
+
if (detected) return detected;
|
|
8702
8841
|
}
|
|
8703
8842
|
} catch {}
|
|
8704
8843
|
return parseEnvProxy(env);
|
|
8705
8844
|
}
|
|
8845
|
+
/**
|
|
8846
|
+
* The URL a fetch call targets, for the bypass decision.
|
|
8847
|
+
*
|
|
8848
|
+
* A `Request` exposes its URL without consuming the body, so reading it here
|
|
8849
|
+
* costs the request nothing.
|
|
8850
|
+
*/
|
|
8851
|
+
function requestTarget(input) {
|
|
8852
|
+
if (typeof input === "string" || input instanceof URL) return input;
|
|
8853
|
+
return input.url;
|
|
8854
|
+
}
|
|
8706
8855
|
var ProxyManager = class {
|
|
8707
8856
|
getPreferences;
|
|
8708
8857
|
baseFetch;
|
|
8709
8858
|
systemProxyDetector;
|
|
8859
|
+
noProxyReader;
|
|
8710
8860
|
logger;
|
|
8711
8861
|
cachedSystemProxy = null;
|
|
8712
8862
|
lastSystemProxyCheck = 0;
|
|
8863
|
+
cachedNoProxy = "";
|
|
8864
|
+
lastNoProxyCheck = 0;
|
|
8713
8865
|
detected = false;
|
|
8714
8866
|
proxyListeners = /* @__PURE__ */ new Set();
|
|
8715
8867
|
agents = /* @__PURE__ */ new Map();
|
|
@@ -8717,6 +8869,7 @@ var ProxyManager = class {
|
|
|
8717
8869
|
this.getPreferences = options.getPreferences;
|
|
8718
8870
|
this.baseFetch = options.baseFetch ?? fetch;
|
|
8719
8871
|
this.systemProxyDetector = options.systemProxyDetector ?? (() => detectSystemProxy());
|
|
8872
|
+
this.noProxyReader = options.noProxyReader ?? (() => parseNoProxy());
|
|
8720
8873
|
this.logger = options.logger;
|
|
8721
8874
|
}
|
|
8722
8875
|
getSystemProxy(force = false) {
|
|
@@ -8738,6 +8891,24 @@ var ProxyManager = class {
|
|
|
8738
8891
|
return this.cachedSystemProxy;
|
|
8739
8892
|
}
|
|
8740
8893
|
/**
|
|
8894
|
+
* The active `NO_PROXY` list, re-read at most once per TTL.
|
|
8895
|
+
*
|
|
8896
|
+
* A reader that throws (an unreadable `$DSH_HOME/.env`) reads as "no bypass
|
|
8897
|
+
* list", never as "bypass everything": the wrong guess in that direction would
|
|
8898
|
+
* take an active proxy away from every line at once.
|
|
8899
|
+
*/
|
|
8900
|
+
getNoProxyList(force = false) {
|
|
8901
|
+
const now = Date.now();
|
|
8902
|
+
if (!force && now - this.lastNoProxyCheck < SYSTEM_PROXY_CACHE_TTL_MS) return this.cachedNoProxy;
|
|
8903
|
+
this.lastNoProxyCheck = now;
|
|
8904
|
+
try {
|
|
8905
|
+
this.cachedNoProxy = this.noProxyReader().trim();
|
|
8906
|
+
} catch {
|
|
8907
|
+
this.cachedNoProxy = "";
|
|
8908
|
+
}
|
|
8909
|
+
return this.cachedNoProxy;
|
|
8910
|
+
}
|
|
8911
|
+
/**
|
|
8741
8912
|
* Observe the system proxy becoming known.
|
|
8742
8913
|
*
|
|
8743
8914
|
* A proxy that appears after startup — or a first detection that failed — otherwise leaves every
|
|
@@ -8775,6 +8946,7 @@ var ProxyManager = class {
|
|
|
8775
8946
|
return async (input, init) => {
|
|
8776
8947
|
const activeProxy = this.resolveActiveProxyUrl();
|
|
8777
8948
|
if (!activeProxy) return this.baseFetch(input, init);
|
|
8949
|
+
if (shouldBypassProxy(requestTarget(input), this.getNoProxyList())) return this.baseFetch(input, init);
|
|
8778
8950
|
try {
|
|
8779
8951
|
const agent = this.getOrCreateAgent(activeProxy);
|
|
8780
8952
|
return await fetch$1(input, {
|
|
@@ -12962,6 +13134,2295 @@ function startWebProviderReconciler(options) {
|
|
|
12962
13134
|
};
|
|
12963
13135
|
}
|
|
12964
13136
|
//#endregion
|
|
13137
|
+
//#region src/host/widget-board/line-contracts.ts
|
|
13138
|
+
/**
|
|
13139
|
+
* Provider name this plugin registers under: the BARE package id, without the
|
|
13140
|
+
* `@eddyskywalker/` scope.
|
|
13141
|
+
*
|
|
13142
|
+
* A manifest names a data source or a capability as `<provider>:<id>`, and that
|
|
13143
|
+
* whole string has to match the board's id regular expression
|
|
13144
|
+
* `^[a-z][a-z0-9.-]{1,39}(?::[a-z][a-z0-9.-]{1,39})?$` — which accepts neither
|
|
13145
|
+
* `@` nor `/`. A scoped provider name would therefore be registrable on the host
|
|
13146
|
+
* and unreachable from every manifest, so the registration and the manifests
|
|
13147
|
+
* must agree on this literal.
|
|
13148
|
+
*/
|
|
13149
|
+
const WIDGET_BOARD_PROVIDER = "dsh-chatgpt-subscription";
|
|
13150
|
+
/** Data source: shape C for the line named by \`props.line\`. */
|
|
13151
|
+
const LINE_CARD_DATA_SOURCE_ID = "line-card";
|
|
13152
|
+
/** Capability: refresh one line's quota, then answer shape C. */
|
|
13153
|
+
const LINE_REFRESH_CAPABILITY_ID = "line-refresh";
|
|
13154
|
+
/** Capability: pin one pooled account of one line, then answer shape C. */
|
|
13155
|
+
const LINE_ACCOUNT_SWITCH_CAPABILITY_ID = "line-account-switch";
|
|
13156
|
+
/** Capability: change one line's rotation strategy, then answer shape C. */
|
|
13157
|
+
const LINE_ACCOUNT_STRATEGY_CAPABILITY_ID = "line-account-strategy";
|
|
13158
|
+
/** A list of those windows. */
|
|
13159
|
+
const LINE_CARD_WINDOWS_SCHEMA = {
|
|
13160
|
+
type: "array",
|
|
13161
|
+
items: {
|
|
13162
|
+
type: "object",
|
|
13163
|
+
additionalProperties: false,
|
|
13164
|
+
required: [
|
|
13165
|
+
"label",
|
|
13166
|
+
"usedPercent",
|
|
13167
|
+
"windowMinutes",
|
|
13168
|
+
"resetsAt"
|
|
13169
|
+
],
|
|
13170
|
+
properties: {
|
|
13171
|
+
label: { type: "string" },
|
|
13172
|
+
usedPercent: {
|
|
13173
|
+
type: "number",
|
|
13174
|
+
minimum: 0,
|
|
13175
|
+
maximum: 100
|
|
13176
|
+
},
|
|
13177
|
+
windowMinutes: { type: ["number", "null"] },
|
|
13178
|
+
resetsAt: { type: ["number", "null"] }
|
|
13179
|
+
}
|
|
13180
|
+
}
|
|
13181
|
+
};
|
|
13182
|
+
/**
|
|
13183
|
+
* The JSON Schema a capability's return value is validated against.
|
|
13184
|
+
*
|
|
13185
|
+
* Capability outputs only, like the two older shapes: the DATA SOURCE's answer
|
|
13186
|
+
* is validated against the \`data\` schema of the card that reads it, which
|
|
13187
|
+
* lives in \`widgets/<id>/widget.json\` and is therefore a second declaration of
|
|
13188
|
+
* this shape (kept aligned by a test that compares their key sets literally).
|
|
13189
|
+
*/
|
|
13190
|
+
const LINE_CARD_SCHEMA = {
|
|
13191
|
+
type: "object",
|
|
13192
|
+
additionalProperties: false,
|
|
13193
|
+
required: [
|
|
13194
|
+
"line",
|
|
13195
|
+
"label",
|
|
13196
|
+
"signedIn",
|
|
13197
|
+
"installed",
|
|
13198
|
+
"planLabel",
|
|
13199
|
+
"accountLabel",
|
|
13200
|
+
"accounts",
|
|
13201
|
+
"activeAccountId",
|
|
13202
|
+
"strategy",
|
|
13203
|
+
"quota",
|
|
13204
|
+
"error"
|
|
13205
|
+
],
|
|
13206
|
+
properties: {
|
|
13207
|
+
line: {
|
|
13208
|
+
type: "string",
|
|
13209
|
+
minLength: 1
|
|
13210
|
+
},
|
|
13211
|
+
label: { type: "string" },
|
|
13212
|
+
signedIn: { type: "boolean" },
|
|
13213
|
+
installed: { type: "boolean" },
|
|
13214
|
+
planLabel: { type: ["string", "null"] },
|
|
13215
|
+
accountLabel: { type: ["string", "null"] },
|
|
13216
|
+
accounts: {
|
|
13217
|
+
type: "array",
|
|
13218
|
+
items: {
|
|
13219
|
+
type: "object",
|
|
13220
|
+
additionalProperties: false,
|
|
13221
|
+
required: [
|
|
13222
|
+
"id",
|
|
13223
|
+
"label",
|
|
13224
|
+
"email",
|
|
13225
|
+
"primary",
|
|
13226
|
+
"plan",
|
|
13227
|
+
"cooldownUntil",
|
|
13228
|
+
"authStatus",
|
|
13229
|
+
"usedPercent",
|
|
13230
|
+
"resetsAt"
|
|
13231
|
+
],
|
|
13232
|
+
properties: {
|
|
13233
|
+
id: { type: "string" },
|
|
13234
|
+
label: { type: "string" },
|
|
13235
|
+
email: { type: ["string", "null"] },
|
|
13236
|
+
primary: { type: "boolean" },
|
|
13237
|
+
plan: { type: ["string", "null"] },
|
|
13238
|
+
cooldownUntil: { type: ["number", "null"] },
|
|
13239
|
+
authStatus: { enum: [
|
|
13240
|
+
"ok",
|
|
13241
|
+
"expired",
|
|
13242
|
+
"invalid",
|
|
13243
|
+
null
|
|
13244
|
+
] },
|
|
13245
|
+
usedPercent: {
|
|
13246
|
+
type: ["number", "null"],
|
|
13247
|
+
minimum: 0,
|
|
13248
|
+
maximum: 100
|
|
13249
|
+
},
|
|
13250
|
+
resetsAt: { type: ["number", "null"] },
|
|
13251
|
+
windows: LINE_CARD_WINDOWS_SCHEMA
|
|
13252
|
+
}
|
|
13253
|
+
}
|
|
13254
|
+
},
|
|
13255
|
+
activeAccountId: { type: ["string", "null"] },
|
|
13256
|
+
strategy: { enum: [
|
|
13257
|
+
"sequential",
|
|
13258
|
+
"round-robin",
|
|
13259
|
+
"sticky"
|
|
13260
|
+
] },
|
|
13261
|
+
quota: { anyOf: [{
|
|
13262
|
+
type: "object",
|
|
13263
|
+
additionalProperties: false,
|
|
13264
|
+
required: [
|
|
13265
|
+
"windows",
|
|
13266
|
+
"credits",
|
|
13267
|
+
"resetCredits",
|
|
13268
|
+
"fetchedAt",
|
|
13269
|
+
"refreshing",
|
|
13270
|
+
"error"
|
|
13271
|
+
],
|
|
13272
|
+
properties: {
|
|
13273
|
+
windows: LINE_CARD_WINDOWS_SCHEMA,
|
|
13274
|
+
credits: { anyOf: [{
|
|
13275
|
+
type: "object",
|
|
13276
|
+
additionalProperties: false,
|
|
13277
|
+
required: ["unlimited", "balance"],
|
|
13278
|
+
properties: {
|
|
13279
|
+
unlimited: { type: "boolean" },
|
|
13280
|
+
balance: { type: ["string", "null"] }
|
|
13281
|
+
}
|
|
13282
|
+
}, { type: "null" }] },
|
|
13283
|
+
resetCredits: { anyOf: [{
|
|
13284
|
+
type: "object",
|
|
13285
|
+
additionalProperties: false,
|
|
13286
|
+
required: ["available", "expiresAt"],
|
|
13287
|
+
properties: {
|
|
13288
|
+
available: {
|
|
13289
|
+
type: "integer",
|
|
13290
|
+
minimum: 0
|
|
13291
|
+
},
|
|
13292
|
+
expiresAt: { type: ["number", "null"] }
|
|
13293
|
+
}
|
|
13294
|
+
}, { type: "null" }] },
|
|
13295
|
+
fetchedAt: { type: ["number", "null"] },
|
|
13296
|
+
refreshing: { type: "boolean" },
|
|
13297
|
+
error: { type: ["string", "null"] }
|
|
13298
|
+
}
|
|
13299
|
+
}, { type: "null" }] },
|
|
13300
|
+
error: { type: ["string", "null"] }
|
|
13301
|
+
}
|
|
13302
|
+
};
|
|
13303
|
+
/** The \`line\` argument every line capability takes. */
|
|
13304
|
+
const LINE_ARGUMENT_PROPERTIES = { line: {
|
|
13305
|
+
type: "string",
|
|
13306
|
+
minLength: 1
|
|
13307
|
+
} };
|
|
13308
|
+
/** Arguments of the quota-refresh capability. */
|
|
13309
|
+
const LINE_REFRESH_INPUT_SCHEMA = {
|
|
13310
|
+
type: "object",
|
|
13311
|
+
additionalProperties: false,
|
|
13312
|
+
required: ["line"],
|
|
13313
|
+
properties: LINE_ARGUMENT_PROPERTIES
|
|
13314
|
+
};
|
|
13315
|
+
/** Arguments of the account-switch capability. */
|
|
13316
|
+
const LINE_ACCOUNT_SWITCH_INPUT_SCHEMA = {
|
|
13317
|
+
type: "object",
|
|
13318
|
+
additionalProperties: false,
|
|
13319
|
+
required: ["line", "accountId"],
|
|
13320
|
+
properties: {
|
|
13321
|
+
...LINE_ARGUMENT_PROPERTIES,
|
|
13322
|
+
accountId: {
|
|
13323
|
+
type: "string",
|
|
13324
|
+
minLength: 1
|
|
13325
|
+
}
|
|
13326
|
+
}
|
|
13327
|
+
};
|
|
13328
|
+
/** Arguments of the account-strategy capability. */
|
|
13329
|
+
const LINE_ACCOUNT_STRATEGY_INPUT_SCHEMA = {
|
|
13330
|
+
type: "object",
|
|
13331
|
+
additionalProperties: false,
|
|
13332
|
+
required: ["line", "strategy"],
|
|
13333
|
+
properties: {
|
|
13334
|
+
...LINE_ARGUMENT_PROPERTIES,
|
|
13335
|
+
strategy: { enum: [
|
|
13336
|
+
"sequential",
|
|
13337
|
+
"round-robin",
|
|
13338
|
+
"sticky"
|
|
13339
|
+
] }
|
|
13340
|
+
}
|
|
13341
|
+
};
|
|
13342
|
+
//#endregion
|
|
13343
|
+
//#region src/host/widget-board/line-quota.ts
|
|
13344
|
+
/**
|
|
13345
|
+
* Anything past this instant is treated as "no reset stated".
|
|
13346
|
+
*
|
|
13347
|
+
* The lines are inconsistent about sentinels, not about arithmetic: qoder
|
|
13348
|
+
* reports `expiresAt: 253402214400000`, which is 9999-12-31 and means "never",
|
|
13349
|
+
* and a card that rendered it would print a reset date eight thousand years
|
|
13350
|
+
* out. No real window reopens after 2100, so that is the ceiling — applied when
|
|
13351
|
+
* a window is built, so every line inherits it rather than each one having to
|
|
13352
|
+
* remember.
|
|
13353
|
+
*/
|
|
13354
|
+
const SANE_TIMESTAMP_CEILING_MS = 41024448e5;
|
|
13355
|
+
/**
|
|
13356
|
+
* The payload as a JSON object, or null.
|
|
13357
|
+
*
|
|
13358
|
+
* @param value - Candidate.
|
|
13359
|
+
* @returns The object, or null for anything else (arrays included).
|
|
13360
|
+
*/
|
|
13361
|
+
function asRecord$17(value) {
|
|
13362
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
|
|
13363
|
+
}
|
|
13364
|
+
/**
|
|
13365
|
+
* The payload as a list of JSON objects, dropping anything that is not one.
|
|
13366
|
+
*
|
|
13367
|
+
* @param value - Candidate.
|
|
13368
|
+
* @returns The objects; empty for a non-array.
|
|
13369
|
+
*/
|
|
13370
|
+
function asRecords(value) {
|
|
13371
|
+
if (!Array.isArray(value)) return [];
|
|
13372
|
+
const records = [];
|
|
13373
|
+
for (const entry of value) {
|
|
13374
|
+
const record = asRecord$17(entry);
|
|
13375
|
+
if (record !== null) records.push(record);
|
|
13376
|
+
}
|
|
13377
|
+
return records;
|
|
13378
|
+
}
|
|
13379
|
+
/**
|
|
13380
|
+
* A trimmed non-empty string, or null.
|
|
13381
|
+
*
|
|
13382
|
+
* @param value - Candidate.
|
|
13383
|
+
* @returns The trimmed text, or null.
|
|
13384
|
+
*/
|
|
13385
|
+
function asText$4(value) {
|
|
13386
|
+
if (typeof value !== "string") return null;
|
|
13387
|
+
const trimmed = value.trim();
|
|
13388
|
+
return trimmed === "" ? null : trimmed;
|
|
13389
|
+
}
|
|
13390
|
+
/**
|
|
13391
|
+
* A finite number, or null.
|
|
13392
|
+
*
|
|
13393
|
+
* @param value - Candidate.
|
|
13394
|
+
* @returns The number, or null.
|
|
13395
|
+
*/
|
|
13396
|
+
function asNumber$6(value) {
|
|
13397
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
13398
|
+
}
|
|
13399
|
+
/**
|
|
13400
|
+
* A boolean as stated, or null when the payload said something else.
|
|
13401
|
+
*
|
|
13402
|
+
* @param value - Candidate.
|
|
13403
|
+
* @returns The boolean, or null.
|
|
13404
|
+
*/
|
|
13405
|
+
function asBoolean$2(value) {
|
|
13406
|
+
return typeof value === "boolean" ? value : null;
|
|
13407
|
+
}
|
|
13408
|
+
/**
|
|
13409
|
+
* A Unix MILLISECONDS instant, when the payload already states one.
|
|
13410
|
+
*
|
|
13411
|
+
* @param value - Candidate.
|
|
13412
|
+
* @returns Milliseconds rounded to a whole number, or null.
|
|
13413
|
+
*/
|
|
13414
|
+
function asMs(value) {
|
|
13415
|
+
const number = asNumber$6(value);
|
|
13416
|
+
if (number === null || number <= 0) return null;
|
|
13417
|
+
return Math.round(number);
|
|
13418
|
+
}
|
|
13419
|
+
/**
|
|
13420
|
+
* A Unix SECONDS instant as milliseconds.
|
|
13421
|
+
*
|
|
13422
|
+
* @param value - Candidate.
|
|
13423
|
+
* @returns Milliseconds, or null when nothing usable was stated.
|
|
13424
|
+
*/
|
|
13425
|
+
function asSecondsToMs(value) {
|
|
13426
|
+
const number = asNumber$6(value);
|
|
13427
|
+
if (number === null || number <= 0) return null;
|
|
13428
|
+
return Math.round(number * 1e3);
|
|
13429
|
+
}
|
|
13430
|
+
/**
|
|
13431
|
+
* An ISO 8601 instant as Unix milliseconds.
|
|
13432
|
+
*
|
|
13433
|
+
* @param value - Candidate.
|
|
13434
|
+
* @returns Milliseconds, or null for an absent or unparseable string.
|
|
13435
|
+
*/
|
|
13436
|
+
function asIsoToMs(value) {
|
|
13437
|
+
const text = asText$4(value);
|
|
13438
|
+
if (text === null) return null;
|
|
13439
|
+
const parsed = Date.parse(text);
|
|
13440
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
|
13441
|
+
}
|
|
13442
|
+
/**
|
|
13443
|
+
* A reset instant, with the sentinel ceiling applied.
|
|
13444
|
+
*
|
|
13445
|
+
* @param value - Milliseconds, or null.
|
|
13446
|
+
* @returns The instant, or null when absent or implausible.
|
|
13447
|
+
*/
|
|
13448
|
+
function asResetAt(value) {
|
|
13449
|
+
if (value === null || value <= 0) return null;
|
|
13450
|
+
return value > SANE_TIMESTAMP_CEILING_MS ? null : value;
|
|
13451
|
+
}
|
|
13452
|
+
/**
|
|
13453
|
+
* A share in percent, clamped into the bar it is drawn as.
|
|
13454
|
+
*
|
|
13455
|
+
* @param value - Candidate.
|
|
13456
|
+
* @returns The share between 0 and 100, or null when there was none.
|
|
13457
|
+
*/
|
|
13458
|
+
function asPercent(value) {
|
|
13459
|
+
const number = asNumber$6(value);
|
|
13460
|
+
if (number === null) return null;
|
|
13461
|
+
return Math.min(100, Math.max(0, number));
|
|
13462
|
+
}
|
|
13463
|
+
/**
|
|
13464
|
+
* A fraction in [0, 1] as percent.
|
|
13465
|
+
*
|
|
13466
|
+
* @param value - Candidate.
|
|
13467
|
+
* @returns Percent, or null when there was none.
|
|
13468
|
+
*/
|
|
13469
|
+
function asFractionPercent(value) {
|
|
13470
|
+
const number = asNumber$6(value);
|
|
13471
|
+
if (number === null) return null;
|
|
13472
|
+
return Math.min(100, Math.max(0, number * 100));
|
|
13473
|
+
}
|
|
13474
|
+
/**
|
|
13475
|
+
* A percent still available, inverted into the used share.
|
|
13476
|
+
*
|
|
13477
|
+
* @param value - Candidate.
|
|
13478
|
+
* @returns The used share, or null when there was none.
|
|
13479
|
+
*/
|
|
13480
|
+
function remainingPercentToUsed(value) {
|
|
13481
|
+
const remaining = asPercent(value);
|
|
13482
|
+
return remaining === null ? null : 100 - remaining;
|
|
13483
|
+
}
|
|
13484
|
+
/**
|
|
13485
|
+
* A remaining fraction in [0, 1] inverted into percent used.
|
|
13486
|
+
*
|
|
13487
|
+
* @param value - Candidate.
|
|
13488
|
+
* @returns The used share, or null when there was none.
|
|
13489
|
+
*/
|
|
13490
|
+
function remainingFractionToUsed(value) {
|
|
13491
|
+
const remaining = asFractionPercent(value);
|
|
13492
|
+
return remaining === null ? null : 100 - remaining;
|
|
13493
|
+
}
|
|
13494
|
+
/**
|
|
13495
|
+
* The conventional name of a window length.
|
|
13496
|
+
*
|
|
13497
|
+
* Used when the line itself states no label (minimax keys its windows as
|
|
13498
|
+
* `interval` / `weekly`, deliberately leaving the wording to the client) and as
|
|
13499
|
+
* a fallback under a label the line left blank. Only lengths that are
|
|
13500
|
+
* unambiguous get a name; anything else stays unnamed rather than mislabelled.
|
|
13501
|
+
*
|
|
13502
|
+
* @param minutes - Nominal window length.
|
|
13503
|
+
* @returns The label, or null when the length is not a conventional one.
|
|
13504
|
+
*/
|
|
13505
|
+
function standardWindowLabel(minutes) {
|
|
13506
|
+
switch (minutes) {
|
|
13507
|
+
case 300: return "5 小时";
|
|
13508
|
+
case 1440: return "每日";
|
|
13509
|
+
case 10080: return "每周";
|
|
13510
|
+
case 43200: return "每月";
|
|
13511
|
+
default: return null;
|
|
13512
|
+
}
|
|
13513
|
+
}
|
|
13514
|
+
/** The window length each named window of the lines that name one stands for. */
|
|
13515
|
+
const NAMED_WINDOW_MINUTES = {
|
|
13516
|
+
"5h": 300,
|
|
13517
|
+
five_hour: 300,
|
|
13518
|
+
"five-hour": 300,
|
|
13519
|
+
interval: 300,
|
|
13520
|
+
daily: 1440,
|
|
13521
|
+
monthly: 43200,
|
|
13522
|
+
week: 10080,
|
|
13523
|
+
weekly: 10080,
|
|
13524
|
+
seven_day: 10080,
|
|
13525
|
+
"7d": 10080
|
|
13526
|
+
};
|
|
13527
|
+
/**
|
|
13528
|
+
* The window length a line's own window key stands for.
|
|
13529
|
+
*
|
|
13530
|
+
* @param key - The line's key (`5h`, `five-hour`, `weekly`, …).
|
|
13531
|
+
* @returns Minutes, or null when the key is not one this module knows.
|
|
13532
|
+
*/
|
|
13533
|
+
function namedWindowMinutes(key) {
|
|
13534
|
+
const text = asText$4(key);
|
|
13535
|
+
if (text === null) return null;
|
|
13536
|
+
return NAMED_WINDOW_MINUTES[text.toLowerCase()] ?? null;
|
|
13537
|
+
}
|
|
13538
|
+
/**
|
|
13539
|
+
* One window, or null when nothing measured its consumption.
|
|
13540
|
+
*
|
|
13541
|
+
* A window with no used share is dropped rather than drawn as a full bar: the
|
|
13542
|
+
* lines say null for "the source told us nothing", and painting that as 100%
|
|
13543
|
+
* would report a limit nobody measured.
|
|
13544
|
+
*
|
|
13545
|
+
* @param seed - The mapped fields.
|
|
13546
|
+
* @returns The window, or null.
|
|
13547
|
+
*/
|
|
13548
|
+
function windowOf(seed) {
|
|
13549
|
+
const usedPercent = seed.usedPercent === null || seed.usedPercent === void 0 ? null : Math.min(100, Math.max(0, seed.usedPercent));
|
|
13550
|
+
if (usedPercent === null) return null;
|
|
13551
|
+
const windowMinutes = seed.windowMinutes === null || seed.windowMinutes === void 0 ? null : seed.windowMinutes;
|
|
13552
|
+
return {
|
|
13553
|
+
label: asText$4(seed.label) ?? standardWindowLabel(windowMinutes) ?? "",
|
|
13554
|
+
usedPercent,
|
|
13555
|
+
windowMinutes,
|
|
13556
|
+
resetsAt: asResetAt(seed.resetsAt ?? null)
|
|
13557
|
+
};
|
|
13558
|
+
}
|
|
13559
|
+
/**
|
|
13560
|
+
* Every window the seeds offered, dropping the ones that measured nothing.
|
|
13561
|
+
*
|
|
13562
|
+
* @param seeds - Candidate windows.
|
|
13563
|
+
* @returns The windows that survived.
|
|
13564
|
+
*/
|
|
13565
|
+
function windowsFrom(seeds) {
|
|
13566
|
+
const windows = [];
|
|
13567
|
+
for (const seed of seeds) {
|
|
13568
|
+
const window = windowOf(seed);
|
|
13569
|
+
if (window !== null) windows.push(window);
|
|
13570
|
+
}
|
|
13571
|
+
return windows;
|
|
13572
|
+
}
|
|
13573
|
+
/**
|
|
13574
|
+
* Shortest window first, unstated lengths last.
|
|
13575
|
+
*
|
|
13576
|
+
* @param left - First window.
|
|
13577
|
+
* @param right - Second window.
|
|
13578
|
+
* @returns The comparator result.
|
|
13579
|
+
*/
|
|
13580
|
+
function byWindowLength(left, right) {
|
|
13581
|
+
if (left.windowMinutes === right.windowMinutes) return 0;
|
|
13582
|
+
if (left.windowMinutes === null) return 1;
|
|
13583
|
+
if (right.windowMinutes === null) return -1;
|
|
13584
|
+
return left.windowMinutes - right.windowMinutes;
|
|
13585
|
+
}
|
|
13586
|
+
/**
|
|
13587
|
+
* A quota, or null when the line answered nothing a card could draw.
|
|
13588
|
+
*
|
|
13589
|
+
* "Nothing to draw" is exactly: no window, no credit figure, no reset credit,
|
|
13590
|
+
* and no fetch instant. A payload in that state is reported as `quota: null`
|
|
13591
|
+
* so a card shows its placeholder instead of an empty frame that looks like a
|
|
13592
|
+
* measured zero.
|
|
13593
|
+
*
|
|
13594
|
+
* @param seed - The mapped fields.
|
|
13595
|
+
* @returns The quota, or null.
|
|
13596
|
+
*/
|
|
13597
|
+
function quotaOf(seed) {
|
|
13598
|
+
const windows = [...seed.windows ?? []].sort(byWindowLength);
|
|
13599
|
+
const credits = seed.credits ?? null;
|
|
13600
|
+
const resetCredits = seed.resetCredits ?? null;
|
|
13601
|
+
const fetchedAt = asMs(seed.fetchedAt ?? null);
|
|
13602
|
+
if (windows.length === 0 && credits === null && resetCredits === null && fetchedAt === null) return null;
|
|
13603
|
+
return {
|
|
13604
|
+
windows,
|
|
13605
|
+
credits,
|
|
13606
|
+
resetCredits,
|
|
13607
|
+
fetchedAt,
|
|
13608
|
+
refreshing: seed.refreshing === true,
|
|
13609
|
+
error: asText$4(seed.error)
|
|
13610
|
+
};
|
|
13611
|
+
}
|
|
13612
|
+
/**
|
|
13613
|
+
* The facts one payload yields.
|
|
13614
|
+
*
|
|
13615
|
+
* A line's own failure message lands in `quota.error` when there IS a quota to
|
|
13616
|
+
* annotate, and in the card-level `error` when there is not — the same sentence
|
|
13617
|
+
* either way, placed where the card can actually render it.
|
|
13618
|
+
*
|
|
13619
|
+
* @param seed - The mapped fields.
|
|
13620
|
+
* @returns The facts.
|
|
13621
|
+
*/
|
|
13622
|
+
function factsOf(seed) {
|
|
13623
|
+
const error = asText$4(seed.error);
|
|
13624
|
+
const quota = quotaOf(seed);
|
|
13625
|
+
return {
|
|
13626
|
+
quota: quota === null ? null : {
|
|
13627
|
+
...quota,
|
|
13628
|
+
error
|
|
13629
|
+
},
|
|
13630
|
+
planLabel: asText$4(seed.planLabel),
|
|
13631
|
+
accountLabel: asText$4(seed.accountLabel),
|
|
13632
|
+
signedIn: asBoolean$2(seed.signedIn),
|
|
13633
|
+
error: quota === null ? error : null
|
|
13634
|
+
};
|
|
13635
|
+
}
|
|
13636
|
+
/**
|
|
13637
|
+
* The facts of a payload that could not be read at all.
|
|
13638
|
+
*
|
|
13639
|
+
* @param error - Why the payload was unusable.
|
|
13640
|
+
* @returns The facts, with nothing measured.
|
|
13641
|
+
*/
|
|
13642
|
+
function unavailableFacts(error) {
|
|
13643
|
+
return {
|
|
13644
|
+
quota: null,
|
|
13645
|
+
planLabel: null,
|
|
13646
|
+
accountLabel: null,
|
|
13647
|
+
signedIn: null,
|
|
13648
|
+
error
|
|
13649
|
+
};
|
|
13650
|
+
}
|
|
13651
|
+
/**
|
|
13652
|
+
* The first non-null text of a candidate list.
|
|
13653
|
+
*
|
|
13654
|
+
* @param values - Candidates, most specific first.
|
|
13655
|
+
* @returns The first text that was stated, or null.
|
|
13656
|
+
*/
|
|
13657
|
+
function firstText(values) {
|
|
13658
|
+
for (const value of values) {
|
|
13659
|
+
const text = asText$4(value);
|
|
13660
|
+
if (text !== null) return text;
|
|
13661
|
+
}
|
|
13662
|
+
return null;
|
|
13663
|
+
}
|
|
13664
|
+
/**
|
|
13665
|
+
* A balance as a string, keeping its unit.
|
|
13666
|
+
*
|
|
13667
|
+
* @param balance - The line's own figure.
|
|
13668
|
+
* @param unit - Unit to append, when the line states one.
|
|
13669
|
+
* @returns The balance, or null when there was none.
|
|
13670
|
+
*/
|
|
13671
|
+
function balanceOf(balance, unit) {
|
|
13672
|
+
const number = asNumber$6(balance);
|
|
13673
|
+
const suffix = unit === null || unit === void 0 ? "" : " " + unit;
|
|
13674
|
+
if (number === null) {
|
|
13675
|
+
const text = asText$4(balance);
|
|
13676
|
+
return text === null ? null : text + suffix;
|
|
13677
|
+
}
|
|
13678
|
+
return String(number) + suffix;
|
|
13679
|
+
}
|
|
13680
|
+
/**
|
|
13681
|
+
* The display name of one Qoder allowance.
|
|
13682
|
+
*
|
|
13683
|
+
* @param kind - The line's bucket kind.
|
|
13684
|
+
* @returns The label, or null when the kind is not one this module knows.
|
|
13685
|
+
*/
|
|
13686
|
+
function bucketLabel(kind) {
|
|
13687
|
+
switch (asText$4(kind)) {
|
|
13688
|
+
case "personal": return "个人额度";
|
|
13689
|
+
case "add-on": return "加购额度";
|
|
13690
|
+
case "org": return "组织额度";
|
|
13691
|
+
default: return null;
|
|
13692
|
+
}
|
|
13693
|
+
}
|
|
13694
|
+
/**
|
|
13695
|
+
* The ChatGPT line's quota, as `GET /status` answers it.
|
|
13696
|
+
*
|
|
13697
|
+
* Units: `QuotaWindowDto.resetsAt`, `resetCredits.expiresAt` and
|
|
13698
|
+
* `quota.fetchedAt` are Unix SECONDS here (that line's own quota service
|
|
13699
|
+
* multiplies them by 1000 further out), while everything else on the payload is
|
|
13700
|
+
* already milliseconds. The bucket list also carries an older
|
|
13701
|
+
* `primary`/`secondary` pair that some payload generations state instead of
|
|
13702
|
+
* `windows`; both are read, so the card shows the same bars the settings card
|
|
13703
|
+
* does.
|
|
13704
|
+
*
|
|
13705
|
+
* @param status - The route's `value`.
|
|
13706
|
+
* @returns The facts.
|
|
13707
|
+
*/
|
|
13708
|
+
function normalizeCodexQuota(status) {
|
|
13709
|
+
const root = asRecord$17(status);
|
|
13710
|
+
if (root === null) return unavailableFacts(null);
|
|
13711
|
+
const quotaBody = asRecord$17(root.quota);
|
|
13712
|
+
const account = asRecord$17(root.account);
|
|
13713
|
+
const buckets = asRecords(quotaBody?.buckets);
|
|
13714
|
+
const seeds = [];
|
|
13715
|
+
for (const bucket of buckets) {
|
|
13716
|
+
const stated = asRecords(bucket.windows);
|
|
13717
|
+
const source = stated.length > 0 ? stated : [asRecord$17(bucket.primary), asRecord$17(bucket.secondary)];
|
|
13718
|
+
for (const window of source) {
|
|
13719
|
+
if (window === null) continue;
|
|
13720
|
+
seeds.push({
|
|
13721
|
+
label: asText$4(window.label),
|
|
13722
|
+
usedPercent: asPercent(window.usedPercent),
|
|
13723
|
+
windowMinutes: asNumber$6(window.windowDurationMins),
|
|
13724
|
+
resetsAt: asSecondsToMs(window.resetsAt)
|
|
13725
|
+
});
|
|
13726
|
+
}
|
|
13727
|
+
}
|
|
13728
|
+
const creditsBody = asRecord$17(quotaBody?.credits);
|
|
13729
|
+
const credits = creditsBody === null ? null : {
|
|
13730
|
+
unlimited: creditsBody.unlimited === true,
|
|
13731
|
+
balance: asText$4(creditsBody.balance)
|
|
13732
|
+
};
|
|
13733
|
+
const resetBody = asRecord$17(quotaBody?.resetCredits);
|
|
13734
|
+
const resetCredits = resetBody === null ? null : {
|
|
13735
|
+
available: Math.max(0, Math.trunc(asNumber$6(resetBody.availableCount) ?? 0)),
|
|
13736
|
+
expiresAt: asResetAt(asSecondsToMs(resetBody.expiresAt))
|
|
13737
|
+
};
|
|
13738
|
+
const planLabel = firstText([...buckets.map((bucket) => bucket.planType), account?.planType]);
|
|
13739
|
+
const state = asText$4(quotaBody?.state);
|
|
13740
|
+
return factsOf({
|
|
13741
|
+
windows: windowsFrom(seeds),
|
|
13742
|
+
credits,
|
|
13743
|
+
resetCredits,
|
|
13744
|
+
fetchedAt: asSecondsToMs(quotaBody?.fetchedAt),
|
|
13745
|
+
refreshing: root.quotaRefreshing === true,
|
|
13746
|
+
error: firstText([asRecord$17(quotaBody?.error)?.message]),
|
|
13747
|
+
planLabel,
|
|
13748
|
+
accountLabel: account?.email,
|
|
13749
|
+
signedIn: asBoolean$2(root.authenticated) ?? (state === null ? null : state !== "signed-out")
|
|
13750
|
+
});
|
|
13751
|
+
}
|
|
13752
|
+
/**
|
|
13753
|
+
* The Kimi Code line's quota.
|
|
13754
|
+
*
|
|
13755
|
+
* Units: `resetsAt` and `fetchedAt` are already milliseconds. The fallback
|
|
13756
|
+
* wallet (`extraUsage`) is stated in the currency's minor unit, so it is
|
|
13757
|
+
* divided once and kept with its currency: a bare "75.00" would be read as
|
|
13758
|
+
* provider credits by every other card on the board.
|
|
13759
|
+
*
|
|
13760
|
+
* @param status - The route's `value`.
|
|
13761
|
+
* @returns The facts.
|
|
13762
|
+
*/
|
|
13763
|
+
function normalizeKimiCodeQuota(status) {
|
|
13764
|
+
const root = asRecord$17(status);
|
|
13765
|
+
if (root === null) return unavailableFacts(null);
|
|
13766
|
+
const quotaBody = asRecord$17(root.quota);
|
|
13767
|
+
const account = asRecord$17(root.account);
|
|
13768
|
+
const extra = asRecord$17(quotaBody?.extraUsage);
|
|
13769
|
+
const balanceCents = asNumber$6(extra?.balanceCents);
|
|
13770
|
+
const currency = asText$4(extra?.currency);
|
|
13771
|
+
const credits = balanceCents === null ? null : {
|
|
13772
|
+
unlimited: false,
|
|
13773
|
+
balance: (balanceCents / 100).toFixed(2) + (currency === null ? "" : " " + currency)
|
|
13774
|
+
};
|
|
13775
|
+
return factsOf({
|
|
13776
|
+
windows: windowsFrom(asRecords(quotaBody?.windows).map((window) => ({
|
|
13777
|
+
label: asText$4(window.label),
|
|
13778
|
+
usedPercent: asPercent(window.usedPercent),
|
|
13779
|
+
windowMinutes: asNumber$6(window.windowDurationMins),
|
|
13780
|
+
resetsAt: asMs(window.resetsAt)
|
|
13781
|
+
}))),
|
|
13782
|
+
credits,
|
|
13783
|
+
resetCredits: null,
|
|
13784
|
+
fetchedAt: asMs(quotaBody?.fetchedAt),
|
|
13785
|
+
refreshing: root.quotaRefreshing === true,
|
|
13786
|
+
error: firstText([root.quotaError]),
|
|
13787
|
+
planLabel: firstText([quotaBody?.planName, account?.planName]),
|
|
13788
|
+
accountLabel: firstText([account?.email, account?.nickname]),
|
|
13789
|
+
signedIn: root.authenticated
|
|
13790
|
+
});
|
|
13791
|
+
}
|
|
13792
|
+
/**
|
|
13793
|
+
* The MiniMax Code line's quota.
|
|
13794
|
+
*
|
|
13795
|
+
* Units: milliseconds throughout (`resetsAtMs`, `fetchedAtMs`). The windows are
|
|
13796
|
+
* keyed (`interval` / `weekly`) rather than labelled — the line leaves the
|
|
13797
|
+
* wording to the client — and carry a REMAINING percent, sometimes only a
|
|
13798
|
+
* remaining one; both are inverted into the used share here.
|
|
13799
|
+
*
|
|
13800
|
+
* @param status - The route's `value`.
|
|
13801
|
+
* @returns The facts.
|
|
13802
|
+
*/
|
|
13803
|
+
function normalizeMinimaxCodeQuota(status) {
|
|
13804
|
+
const root = asRecord$17(status);
|
|
13805
|
+
if (root === null) return unavailableFacts(null);
|
|
13806
|
+
const quotaBody = asRecord$17(root.quota);
|
|
13807
|
+
const seeds = asRecords(quotaBody?.windows).map((window) => ({
|
|
13808
|
+
label: null,
|
|
13809
|
+
usedPercent: asPercent(window.usedPercent) ?? remainingPercentToUsed(window.remainingPercent),
|
|
13810
|
+
windowMinutes: namedWindowMinutes(window.key),
|
|
13811
|
+
resetsAt: asMs(window.resetsAtMs)
|
|
13812
|
+
}));
|
|
13813
|
+
if (seeds.length === 0) {
|
|
13814
|
+
const headline = asPercent(quotaBody?.usedPercent);
|
|
13815
|
+
if (headline !== null) seeds.push({
|
|
13816
|
+
label: null,
|
|
13817
|
+
usedPercent: headline,
|
|
13818
|
+
windowMinutes: 300,
|
|
13819
|
+
resetsAt: asMs(quotaBody?.resetsAtMs)
|
|
13820
|
+
});
|
|
13821
|
+
}
|
|
13822
|
+
const accounts = asRecords(root.accounts);
|
|
13823
|
+
const activeId = asText$4(root.activeAccountId);
|
|
13824
|
+
const active = accounts.find((account) => asText$4(account.id) === activeId) ?? accounts.find((account) => asText$4(account.email) !== null);
|
|
13825
|
+
return factsOf({
|
|
13826
|
+
windows: windowsFrom(seeds),
|
|
13827
|
+
credits: null,
|
|
13828
|
+
resetCredits: null,
|
|
13829
|
+
fetchedAt: asMs(quotaBody?.fetchedAtMs),
|
|
13830
|
+
refreshing: root.quotaRefreshing === true,
|
|
13831
|
+
error: firstText([root.quotaError]),
|
|
13832
|
+
planLabel: firstText([quotaBody?.label]),
|
|
13833
|
+
accountLabel: firstText([active?.email]),
|
|
13834
|
+
signedIn: root.authenticated
|
|
13835
|
+
});
|
|
13836
|
+
}
|
|
13837
|
+
/**
|
|
13838
|
+
* The Antigravity line's quota.
|
|
13839
|
+
*
|
|
13840
|
+
* Units: the harder one of the set. Each allowance is stated as
|
|
13841
|
+
* `remainingFraction` in [0, 1] (null when the service measured nothing, and
|
|
13842
|
+
* NOT zero), named windows arrive as `window: '5h' | 'weekly'`, and reset
|
|
13843
|
+
* instants as ISO 8601. Windows are grouped (Gemini models, third-party
|
|
13844
|
+
* models); each bucket's label is prefixed with its group once there is more
|
|
13845
|
+
* than one group, because "Weekly" twice on one card says nothing about which
|
|
13846
|
+
* allowance it is.
|
|
13847
|
+
*
|
|
13848
|
+
* @param status - The route's `value`.
|
|
13849
|
+
* @returns The facts.
|
|
13850
|
+
*/
|
|
13851
|
+
function normalizeAntigravityQuota(status) {
|
|
13852
|
+
const root = asRecord$17(status);
|
|
13853
|
+
if (root === null) return unavailableFacts(null);
|
|
13854
|
+
const quotaBody = asRecord$17(root.quota);
|
|
13855
|
+
const groups = asRecords(quotaBody?.groups);
|
|
13856
|
+
const seeds = [];
|
|
13857
|
+
for (const group of groups) {
|
|
13858
|
+
const groupName = groups.length > 1 ? asText$4(group.displayName) : null;
|
|
13859
|
+
for (const bucket of asRecords(group.buckets)) {
|
|
13860
|
+
const minutes = namedWindowMinutes(bucket.window);
|
|
13861
|
+
const windowName = standardWindowLabel(minutes) ?? asText$4(bucket.displayName);
|
|
13862
|
+
seeds.push({
|
|
13863
|
+
label: groupName === null || windowName === null ? windowName : groupName + " · " + windowName,
|
|
13864
|
+
usedPercent: remainingFractionToUsed(bucket.remainingFraction),
|
|
13865
|
+
windowMinutes: minutes,
|
|
13866
|
+
resetsAt: asIsoToMs(bucket.resetTime)
|
|
13867
|
+
});
|
|
13868
|
+
}
|
|
13869
|
+
}
|
|
13870
|
+
const paidTier = asRecord$17(quotaBody?.paidTier);
|
|
13871
|
+
const productTier = asRecord$17(quotaBody?.productTier);
|
|
13872
|
+
return factsOf({
|
|
13873
|
+
windows: windowsFrom(seeds),
|
|
13874
|
+
credits: null,
|
|
13875
|
+
resetCredits: null,
|
|
13876
|
+
fetchedAt: asMs(quotaBody?.fetchedAt) ?? asMs(root.lastFetchedAt),
|
|
13877
|
+
refreshing: root.quotaRefreshing === true,
|
|
13878
|
+
error: firstText([root.quotaError]),
|
|
13879
|
+
planLabel: firstText([
|
|
13880
|
+
quotaBody?.planLabel,
|
|
13881
|
+
paidTier?.name,
|
|
13882
|
+
productTier?.name
|
|
13883
|
+
]),
|
|
13884
|
+
accountLabel: firstText([root.email]),
|
|
13885
|
+
signedIn: root.authenticated
|
|
13886
|
+
});
|
|
13887
|
+
}
|
|
13888
|
+
/**
|
|
13889
|
+
* The Command Code line's quota.
|
|
13890
|
+
*
|
|
13891
|
+
* Units: two different meter shapes, and both are needed. `windows[]` is
|
|
13892
|
+
* already normalized (`usedPercent`, `windowDurationMins`, milliseconds);
|
|
13893
|
+
* `meters[]` states a used FRACTION that is null whenever the service reports a
|
|
13894
|
+
* spend meter instead of a bounded allowance, and only the ones that did state
|
|
13895
|
+
* a fraction become bars. The credit balance is that line's own string, kept
|
|
13896
|
+
* verbatim.
|
|
13897
|
+
*
|
|
13898
|
+
* @param status - The route's `value`.
|
|
13899
|
+
* @returns The facts.
|
|
13900
|
+
*/
|
|
13901
|
+
function normalizeCommandCodeQuota(status) {
|
|
13902
|
+
const root = asRecord$17(status);
|
|
13903
|
+
if (root === null) return unavailableFacts(null);
|
|
13904
|
+
const quotaBody = asRecord$17(root.quota);
|
|
13905
|
+
const windows = windowsFrom(asRecords(quotaBody?.windows).map((window) => ({
|
|
13906
|
+
label: asText$4(window.label),
|
|
13907
|
+
usedPercent: asPercent(window.usedPercent),
|
|
13908
|
+
windowMinutes: asNumber$6(window.windowDurationMins),
|
|
13909
|
+
resetsAt: asMs(window.resetsAt)
|
|
13910
|
+
})));
|
|
13911
|
+
if (windows.length === 0) for (const meter of asRecords(quotaBody?.meters)) windows.push(...windowsFrom([{
|
|
13912
|
+
label: asText$4(meter.label),
|
|
13913
|
+
usedPercent: asFractionPercent(meter.usedFraction),
|
|
13914
|
+
windowMinutes: null,
|
|
13915
|
+
resetsAt: asMs(meter.resetsAt)
|
|
13916
|
+
}]));
|
|
13917
|
+
const account = asRecord$17(root.account) ?? asRecord$17(quotaBody?.account);
|
|
13918
|
+
return factsOf({
|
|
13919
|
+
windows,
|
|
13920
|
+
credits: quotaBody === null ? null : {
|
|
13921
|
+
unlimited: quotaBody.unlimited === true,
|
|
13922
|
+
balance: asText$4(quotaBody.creditBalance)
|
|
13923
|
+
},
|
|
13924
|
+
resetCredits: null,
|
|
13925
|
+
fetchedAt: asMs(quotaBody?.fetchedAt) ?? asMs(root.lastFetchedAt),
|
|
13926
|
+
refreshing: root.quotaRefreshing === true,
|
|
13927
|
+
error: firstText([root.quotaError]),
|
|
13928
|
+
planLabel: firstText([quotaBody?.planName, account?.planLabel]),
|
|
13929
|
+
accountLabel: firstText([account?.email]),
|
|
13930
|
+
signedIn: root.authenticated
|
|
13931
|
+
});
|
|
13932
|
+
}
|
|
13933
|
+
/**
|
|
13934
|
+
* The Claude line's quota.
|
|
13935
|
+
*
|
|
13936
|
+
* Units: millisecond `fetchedAt` / `observedAt`, ISO 8601 `resetsAt`, and a
|
|
13937
|
+
* used share that may be stated as percent or as a fraction in [0, 1] — the
|
|
13938
|
+
* three numeric fields are null together when the source said nothing, so the
|
|
13939
|
+
* fraction is only consulted when the percent is absent.
|
|
13940
|
+
*
|
|
13941
|
+
* The line answers `quota: null` on a first read and only fills it after its
|
|
13942
|
+
* own `POST /quota`; that is what the refresh capability exists for, so an
|
|
13943
|
+
* empty first read is reported as-is rather than as an error.
|
|
13944
|
+
*
|
|
13945
|
+
* @param status - The route's `value`.
|
|
13946
|
+
* @returns The facts.
|
|
13947
|
+
*/
|
|
13948
|
+
function normalizeClaudeQuota(status) {
|
|
13949
|
+
const root = asRecord$17(status);
|
|
13950
|
+
if (root === null) return unavailableFacts(null);
|
|
13951
|
+
const quotaBody = asRecord$17(root.quota);
|
|
13952
|
+
const account = asRecord$17(root.account);
|
|
13953
|
+
return factsOf({
|
|
13954
|
+
windows: windowsFrom(asRecords(quotaBody?.windows).map((window) => {
|
|
13955
|
+
const minutes = asNumber$6(window.windowMinutes) ?? namedWindowMinutes(window.id);
|
|
13956
|
+
return {
|
|
13957
|
+
label: asText$4(window.label) ?? standardWindowLabel(minutes),
|
|
13958
|
+
usedPercent: asPercent(window.usedPercent) ?? asFractionPercent(window.usedFraction),
|
|
13959
|
+
windowMinutes: minutes,
|
|
13960
|
+
resetsAt: asIsoToMs(window.resetsAt)
|
|
13961
|
+
};
|
|
13962
|
+
})),
|
|
13963
|
+
credits: null,
|
|
13964
|
+
resetCredits: null,
|
|
13965
|
+
fetchedAt: asMs(quotaBody?.fetchedAt) ?? asMs(quotaBody?.observedAt),
|
|
13966
|
+
refreshing: root.quotaRefreshing === true,
|
|
13967
|
+
error: firstText([root.quotaError]),
|
|
13968
|
+
planLabel: firstText([account?.subscriptionType]),
|
|
13969
|
+
accountLabel: firstText([account?.email]),
|
|
13970
|
+
signedIn: root.authenticated
|
|
13971
|
+
});
|
|
13972
|
+
}
|
|
13973
|
+
/**
|
|
13974
|
+
* The GLM Coding Plan line's quota.
|
|
13975
|
+
*
|
|
13976
|
+
* Units: milliseconds (`resetsAt`, `fetchedAt`). Windows are keyed
|
|
13977
|
+
* (`five-hour` / `weekly` / `monthly`) and their used percent is null when the
|
|
13978
|
+
* upstream reported no bounded allowance — an unlimited plan states no
|
|
13979
|
+
* fraction, and drawing that as 0% used would invent a measurement.
|
|
13980
|
+
*
|
|
13981
|
+
* @param status - The route's `value`.
|
|
13982
|
+
* @returns The facts.
|
|
13983
|
+
*/
|
|
13984
|
+
function normalizeGlmCodingPlanQuota(status) {
|
|
13985
|
+
const root = asRecord$17(status);
|
|
13986
|
+
if (root === null) return unavailableFacts(null);
|
|
13987
|
+
const quotaBody = asRecord$17(root.quota);
|
|
13988
|
+
return factsOf({
|
|
13989
|
+
windows: windowsFrom(asRecords(quotaBody?.windows).map((window) => {
|
|
13990
|
+
const minutes = namedWindowMinutes(window.key);
|
|
13991
|
+
return {
|
|
13992
|
+
label: standardWindowLabel(minutes),
|
|
13993
|
+
usedPercent: asPercent(window.usedPercent),
|
|
13994
|
+
windowMinutes: minutes,
|
|
13995
|
+
resetsAt: asMs(window.resetsAt)
|
|
13996
|
+
};
|
|
13997
|
+
})),
|
|
13998
|
+
credits: null,
|
|
13999
|
+
resetCredits: null,
|
|
14000
|
+
fetchedAt: asMs(quotaBody?.fetchedAt),
|
|
14001
|
+
refreshing: root.quotaRefreshing === true,
|
|
14002
|
+
error: firstText([root.quotaError]),
|
|
14003
|
+
planLabel: firstText([quotaBody?.planName, quotaBody?.planLevel]),
|
|
14004
|
+
signedIn: root.authenticated
|
|
14005
|
+
});
|
|
14006
|
+
}
|
|
14007
|
+
/**
|
|
14008
|
+
* The Step Code line's quota.
|
|
14009
|
+
*
|
|
14010
|
+
* Units: milliseconds. Two payload generations share one snapshot: an older
|
|
14011
|
+
* account has 5-hour and weekly windows, a newer one has `creditBuckets` — an
|
|
14012
|
+
* absolute amount per bucket, with no percentage at all. The buckets become the
|
|
14013
|
+
* card's credit figure (their remaining amounts summed) rather than being
|
|
14014
|
+
* squeezed into a progress bar nobody measured; the `credit` window key, which
|
|
14015
|
+
* names the same thing, is deliberately left without a bar for the same reason.
|
|
14016
|
+
*
|
|
14017
|
+
* @param status - The route's `value`.
|
|
14018
|
+
* @returns The facts.
|
|
14019
|
+
*/
|
|
14020
|
+
function normalizeStepCodeQuota(status) {
|
|
14021
|
+
const root = asRecord$17(status);
|
|
14022
|
+
if (root === null) return unavailableFacts(null);
|
|
14023
|
+
const quotaBody = asRecord$17(root.quota);
|
|
14024
|
+
let remaining = 0;
|
|
14025
|
+
let hasRemaining = false;
|
|
14026
|
+
for (const bucket of asRecords(quotaBody?.creditBuckets)) {
|
|
14027
|
+
const amount = asNumber$6(bucket.remaining);
|
|
14028
|
+
if (amount === null) continue;
|
|
14029
|
+
remaining += amount;
|
|
14030
|
+
hasRemaining = true;
|
|
14031
|
+
}
|
|
14032
|
+
const credits = hasRemaining ? {
|
|
14033
|
+
unlimited: false,
|
|
14034
|
+
balance: String(Math.round(remaining * 100) / 100)
|
|
14035
|
+
} : null;
|
|
14036
|
+
const account = asRecord$17(root.account) ?? asRecord$17(asRecord$17(root.quotaCredential)?.account);
|
|
14037
|
+
return factsOf({
|
|
14038
|
+
windows: windowsFrom(asRecords(quotaBody?.windows).map((window) => {
|
|
14039
|
+
const minutes = window.key === "credit" ? null : namedWindowMinutes(window.key);
|
|
14040
|
+
return {
|
|
14041
|
+
label: standardWindowLabel(minutes) ?? (window.key === "credit" ? "Credits" : null),
|
|
14042
|
+
usedPercent: asPercent(window.usedPercent),
|
|
14043
|
+
windowMinutes: minutes,
|
|
14044
|
+
resetsAt: asMs(window.resetsAt)
|
|
14045
|
+
};
|
|
14046
|
+
})),
|
|
14047
|
+
credits,
|
|
14048
|
+
resetCredits: null,
|
|
14049
|
+
fetchedAt: asMs(quotaBody?.fetchedAt),
|
|
14050
|
+
refreshing: root.quotaRefreshing === true,
|
|
14051
|
+
error: firstText([root.quotaError]),
|
|
14052
|
+
planLabel: firstText([quotaBody?.planName]),
|
|
14053
|
+
accountLabel: firstText([account?.email]),
|
|
14054
|
+
signedIn: root.authenticated
|
|
14055
|
+
});
|
|
14056
|
+
}
|
|
14057
|
+
/**
|
|
14058
|
+
* The Qoder line's quota.
|
|
14059
|
+
*
|
|
14060
|
+
* Units: milliseconds, with one sentinel: the account period ends at
|
|
14061
|
+
* `253402214400000` (year 9999) for a subscription that never expires, and a
|
|
14062
|
+
* card would print that as a real date, so the shared ceiling drops it.
|
|
14063
|
+
*
|
|
14064
|
+
* Qoder states ABSOLUTE amounts (total / used / remaining + unit), not windows.
|
|
14065
|
+
* A ratio is derived only when both total and used are stated AND total is
|
|
14066
|
+
* positive; anything else is reported as a credit balance instead of a bar.
|
|
14067
|
+
*
|
|
14068
|
+
* @param status - The route's `value`.
|
|
14069
|
+
* @returns The facts.
|
|
14070
|
+
*/
|
|
14071
|
+
function normalizeQoderQuota(status) {
|
|
14072
|
+
const root = asRecord$17(status);
|
|
14073
|
+
if (root === null) return unavailableFacts(null);
|
|
14074
|
+
const quotaBody = asRecord$17(root.quota);
|
|
14075
|
+
const expiresAt = asResetAt(asMs(quotaBody?.expiresAt));
|
|
14076
|
+
const seeds = [];
|
|
14077
|
+
let credits = null;
|
|
14078
|
+
for (const bucket of asRecords(quotaBody?.buckets)) {
|
|
14079
|
+
const total = asNumber$6(bucket.total);
|
|
14080
|
+
const remaining = asNumber$6(bucket.remaining);
|
|
14081
|
+
const used = asNumber$6(bucket.used) ?? (total !== null && remaining !== null ? total - remaining : null);
|
|
14082
|
+
if (total !== null && total > 0 && used !== null) seeds.push({
|
|
14083
|
+
label: bucketLabel(bucket.kind),
|
|
14084
|
+
usedPercent: used / total * 100,
|
|
14085
|
+
windowMinutes: null,
|
|
14086
|
+
resetsAt: expiresAt
|
|
14087
|
+
});
|
|
14088
|
+
if (credits === null && remaining !== null) credits = {
|
|
14089
|
+
unlimited: false,
|
|
14090
|
+
balance: balanceOf(remaining, asText$4(bucket.unit))
|
|
14091
|
+
};
|
|
14092
|
+
}
|
|
14093
|
+
const account = asRecord$17(root.account);
|
|
14094
|
+
return factsOf({
|
|
14095
|
+
windows: windowsFrom(seeds),
|
|
14096
|
+
credits,
|
|
14097
|
+
resetCredits: null,
|
|
14098
|
+
fetchedAt: asMs(quotaBody?.fetchedAt),
|
|
14099
|
+
refreshing: root.quotaRefreshing === true,
|
|
14100
|
+
error: firstText([root.quotaError]),
|
|
14101
|
+
planLabel: null,
|
|
14102
|
+
accountLabel: firstText([account?.email]),
|
|
14103
|
+
signedIn: root.authenticated
|
|
14104
|
+
});
|
|
14105
|
+
}
|
|
14106
|
+
/**
|
|
14107
|
+
* The Cline Pass line's quota.
|
|
14108
|
+
*
|
|
14109
|
+
* Units: `percentUsed` is a percent, `resetsAt` is an ISO 8601 string, and the
|
|
14110
|
+
* window's length is only stated as a gateway token (`five_hour` / `weekly` /
|
|
14111
|
+
* `monthly`). The payload carries one reading PER ACCOUNT, so the row shown is
|
|
14112
|
+
* the pool's active account when it has a reading, else the first account that
|
|
14113
|
+
* did read something. An account that failed states its own reason, which is
|
|
14114
|
+
* what the card shows instead of an empty frame.
|
|
14115
|
+
*
|
|
14116
|
+
* @param status - The route's `value`.
|
|
14117
|
+
* @returns The facts.
|
|
14118
|
+
*/
|
|
14119
|
+
function normalizeClinePassQuota(status) {
|
|
14120
|
+
const root = asRecord$17(status);
|
|
14121
|
+
if (root === null) return unavailableFacts(null);
|
|
14122
|
+
const usage = asRecord$17(root.usage);
|
|
14123
|
+
const readings = asRecords(usage?.accounts);
|
|
14124
|
+
const activeId = asText$4(asRecord$17(root.pool)?.activeAccountId);
|
|
14125
|
+
const reading = readings.find((entry) => asText$4(entry.accountId) === activeId) ?? readings.find((entry) => asRecords(entry.windows).length > 0) ?? readings[0];
|
|
14126
|
+
return factsOf({
|
|
14127
|
+
windows: windowsFrom(asRecords(reading?.windows).map((window) => {
|
|
14128
|
+
const minutes = namedWindowMinutes(window.type);
|
|
14129
|
+
return {
|
|
14130
|
+
label: asText$4(window.label) ?? standardWindowLabel(minutes),
|
|
14131
|
+
usedPercent: asPercent(window.percentUsed),
|
|
14132
|
+
windowMinutes: minutes,
|
|
14133
|
+
resetsAt: asIsoToMs(window.resetsAt)
|
|
14134
|
+
};
|
|
14135
|
+
})),
|
|
14136
|
+
credits: null,
|
|
14137
|
+
resetCredits: null,
|
|
14138
|
+
fetchedAt: asMs(reading?.fetchedAt) ?? asMs(usage?.fetchedAt),
|
|
14139
|
+
refreshing: root.quotaRefreshing === true,
|
|
14140
|
+
error: firstText([root.quotaError, reading?.error]),
|
|
14141
|
+
planLabel: null,
|
|
14142
|
+
accountLabel: null,
|
|
14143
|
+
signedIn: root.authenticated
|
|
14144
|
+
});
|
|
14145
|
+
}
|
|
14146
|
+
/**
|
|
14147
|
+
* An integer credit amount the service states as a STRING.
|
|
14148
|
+
*
|
|
14149
|
+
* `/billing/coding-plan-usage` strings its amounts (`"1000"`), and a number-shaped
|
|
14150
|
+
* field of any other type is not one: reading a float as credits would let a
|
|
14151
|
+
* rounded value become a denominator.
|
|
14152
|
+
*
|
|
14153
|
+
* @param value - Candidate.
|
|
14154
|
+
* @returns The amount, or null when it is not an integer string.
|
|
14155
|
+
*/
|
|
14156
|
+
function asCreditAmount(value) {
|
|
14157
|
+
const text = asText$4(value);
|
|
14158
|
+
if (text === null || !/^\d+$/.test(text)) return null;
|
|
14159
|
+
const amount = Number(text);
|
|
14160
|
+
return Number.isFinite(amount) ? amount : null;
|
|
14161
|
+
}
|
|
14162
|
+
/**
|
|
14163
|
+
* The used share of one window, from the two amounts that bound it.
|
|
14164
|
+
*
|
|
14165
|
+
* `(limit − remaining) / limit` in percent, rounded to the one decimal the bar is
|
|
14166
|
+
* drawn at and clamped into 0-100. A remainder larger than the limit (a top-up
|
|
14167
|
+
* rides outside the window upstream) clamps to 0 rather than reporting a bar
|
|
14168
|
+
* running backwards, and nothing left is exactly a full bar. The REMAINING
|
|
14169
|
+
* percentage the console shows is `100 − this`; no second derivation exists.
|
|
14170
|
+
*
|
|
14171
|
+
* @param limit - Credits the window grants; positive, which the caller checks.
|
|
14172
|
+
* @param remaining - Credits the window still has.
|
|
14173
|
+
* @returns The used share in 0-100 with one decimal.
|
|
14174
|
+
*/
|
|
14175
|
+
function usedSharePercent(limit, remaining) {
|
|
14176
|
+
const share = (limit - remaining) / limit * 100;
|
|
14177
|
+
return Math.min(100, Math.max(0, Math.round(share * 10) / 10));
|
|
14178
|
+
}
|
|
14179
|
+
/**
|
|
14180
|
+
* Every Design Plan window one account stated, in the card's own vocabulary.
|
|
14181
|
+
*
|
|
14182
|
+
* Two amounts and one length make a bar; a window that states less is dropped
|
|
14183
|
+
* rather than patched, because a guessed denominator is a progress bar claiming
|
|
14184
|
+
* a limit nobody measured. `durationSeconds` is the service's own unit (18000 is
|
|
14185
|
+
* the 5-hour window, 604800 the 7-day one) and becomes minutes here because that
|
|
14186
|
+
* is what every other line's card already reads.
|
|
14187
|
+
*
|
|
14188
|
+
* The LABEL stays empty on purpose: the length is the fact, and the card words it
|
|
14189
|
+
* (`5 小时` / `7 天`). Writing that wording here would put a second, competing
|
|
14190
|
+
* vocabulary in the host for the same window.
|
|
14191
|
+
*
|
|
14192
|
+
* @param codingPlan - The account's `codingPlan` object, when it had one.
|
|
14193
|
+
* @returns The windows, shortest first; empty when the read stated none.
|
|
14194
|
+
*/
|
|
14195
|
+
function codingPlanWindows(codingPlan) {
|
|
14196
|
+
const windows = [];
|
|
14197
|
+
for (const window of asRecords(codingPlan?.windows)) {
|
|
14198
|
+
const limit = asCreditAmount(window.limitCredits);
|
|
14199
|
+
const remaining = asCreditAmount(window.remainingCredits);
|
|
14200
|
+
if (limit === null || limit <= 0 || remaining === null) continue;
|
|
14201
|
+
const seconds = asNumber$6(window.durationSeconds);
|
|
14202
|
+
windows.push({
|
|
14203
|
+
label: "",
|
|
14204
|
+
usedPercent: usedSharePercent(limit, remaining),
|
|
14205
|
+
windowMinutes: seconds === null || seconds <= 0 ? null : Math.round(seconds / 60),
|
|
14206
|
+
resetsAt: asIsoToMs(window.resetsAt)
|
|
14207
|
+
});
|
|
14208
|
+
}
|
|
14209
|
+
return windows.sort(byWindowLength);
|
|
14210
|
+
}
|
|
14211
|
+
/**
|
|
14212
|
+
* The OpenDesign line's quota.
|
|
14213
|
+
*
|
|
14214
|
+
* Units: this line's REAL allowance is the "Design Plan" window pair the console
|
|
14215
|
+
* renders as a remaining percentage — a 5-hour window and a 7-day one, each
|
|
14216
|
+
* stating an integer credit LIMIT and what is LEFT of it, plus an ISO reset
|
|
14217
|
+
* instant. A bar is therefore the USED share, `(limit − remaining) / limit`;
|
|
14218
|
+
* the remaining percentage is `100 − used` and is derived by whoever prints it.
|
|
14219
|
+
*
|
|
14220
|
+
* `quota.credits` keeps the WALLET balance, and only that: the spendable USD
|
|
14221
|
+
* figure is a different quantity from a window's allowance, and folding one into
|
|
14222
|
+
* the other is how a card ends up stating a percentage of the wrong thing.
|
|
14223
|
+
*
|
|
14224
|
+
* Absence is the honest half of this reader. The endpoint is only known to accept
|
|
14225
|
+
* a browser session, so a control-key read may be refused; `codingPlan.error`
|
|
14226
|
+
* then carries the reason and `windows` is empty. An empty list is reported as
|
|
14227
|
+
* exactly that — never padded with a 0 % bar, which would claim a limit nobody
|
|
14228
|
+
* observed — and the reason rides along so the card can say why.
|
|
14229
|
+
*
|
|
14230
|
+
* @param status - The route's `value`.
|
|
14231
|
+
* @returns The facts.
|
|
14232
|
+
*/
|
|
14233
|
+
function normalizeOpenDesignQuota(status) {
|
|
14234
|
+
const root = asRecord$17(status);
|
|
14235
|
+
if (root === null) return unavailableFacts(null);
|
|
14236
|
+
const usage = asRecord$17(root.usage);
|
|
14237
|
+
const readings = asRecords(usage?.accounts);
|
|
14238
|
+
const activeId = asText$4(asRecord$17(root.pool)?.activeAccountId);
|
|
14239
|
+
const reading = readings.find((entry) => asText$4(entry.accountId) === activeId) ?? readings.find((entry) => asText$4(entry.balanceUsd) !== null) ?? readings[0];
|
|
14240
|
+
const balance = asText$4(reading?.balanceUsd);
|
|
14241
|
+
const credits = balance === null ? null : {
|
|
14242
|
+
unlimited: false,
|
|
14243
|
+
balance: balance + " USD"
|
|
14244
|
+
};
|
|
14245
|
+
const codingPlan = asRecord$17(reading?.codingPlan);
|
|
14246
|
+
return factsOf({
|
|
14247
|
+
windows: codingPlanWindows(codingPlan),
|
|
14248
|
+
credits,
|
|
14249
|
+
resetCredits: null,
|
|
14250
|
+
fetchedAt: asMs(reading?.fetchedAt) ?? asMs(usage?.fetchedAt),
|
|
14251
|
+
refreshing: root.quotaRefreshing === true,
|
|
14252
|
+
error: firstText([
|
|
14253
|
+
root.quotaError,
|
|
14254
|
+
reading?.error,
|
|
14255
|
+
codingPlan?.error
|
|
14256
|
+
]),
|
|
14257
|
+
planLabel: firstText([asRecord$17(reading?.billing)?.plan, codingPlan?.tier]),
|
|
14258
|
+
accountLabel: null,
|
|
14259
|
+
signedIn: root.authenticated
|
|
14260
|
+
});
|
|
14261
|
+
}
|
|
14262
|
+
/**
|
|
14263
|
+
* The Qoder account's own reading, from one `quota/usage` snapshot.
|
|
14264
|
+
*
|
|
14265
|
+
* Qoder states ABSOLUTE amounts per bucket (total / used / remaining), never a
|
|
14266
|
+
* percentage, so a share is derived here only under the same rule
|
|
14267
|
+
* {@link normalizeQoderQuota} already applies to the line-level card: BOTH a
|
|
14268
|
+
* total (positive) and a used figure must be stated. A bucket that states
|
|
14269
|
+
* amounts without a bounded allowance — a top-up balance, an org cap nobody
|
|
14270
|
+
* measured — is not turned into a bar, and a snapshot with no such bucket
|
|
14271
|
+
* answers null rather than a made-up zero.
|
|
14272
|
+
*
|
|
14273
|
+
* No window list accompanies it: this line has no percentage windows at all
|
|
14274
|
+
* (its buckets are absolute amounts, and its only instant is the snapshot's own
|
|
14275
|
+
* expiry), so `windows` is `[]` — the honest answer rather than a manufactured
|
|
14276
|
+
* one-window list nobody measured.
|
|
14277
|
+
*
|
|
14278
|
+
* @param snapshot - The account's quota snapshot (`{ buckets, expiresAt }`).
|
|
14279
|
+
* @returns The reading, or null when nothing bounded was measured.
|
|
14280
|
+
*/
|
|
14281
|
+
function qoderAccountQuotaReading(snapshot) {
|
|
14282
|
+
const root = asRecord$17(snapshot);
|
|
14283
|
+
if (root === null) return null;
|
|
14284
|
+
const resetsAt = asResetAt(asMs(root.expiresAt));
|
|
14285
|
+
for (const bucket of asRecords(root.buckets)) {
|
|
14286
|
+
const total = asNumber$6(bucket.total);
|
|
14287
|
+
const remaining = asNumber$6(bucket.remaining);
|
|
14288
|
+
const used = asNumber$6(bucket.used) ?? (total !== null && remaining !== null ? total - remaining : null);
|
|
14289
|
+
if (total === null || total <= 0 || used === null) continue;
|
|
14290
|
+
return {
|
|
14291
|
+
usedPercent: asPercent(used / total * 100),
|
|
14292
|
+
resetsAt,
|
|
14293
|
+
windows: []
|
|
14294
|
+
};
|
|
14295
|
+
}
|
|
14296
|
+
return null;
|
|
14297
|
+
}
|
|
14298
|
+
/**
|
|
14299
|
+
* The MiniMax Code account's own reading, from one Token Plan snapshot.
|
|
14300
|
+
*
|
|
14301
|
+
* The window the row's BAR draws is the rolling 5-hour one — the line's own
|
|
14302
|
+
* headline, and the tightest limit the plan has: its machine key is `interval`,
|
|
14303
|
+
* and a payload that named no key is placed by its nominal length instead. The
|
|
14304
|
+
* weekly window is deliberately NOT substituted (a row saying 5% while the
|
|
14305
|
+
* 5-hour window is spent is worse than a row saying nothing), and an `unlimited`
|
|
14306
|
+
* window states no share at all, so nothing becomes a bar.
|
|
14307
|
+
*
|
|
14308
|
+
* `windows` carries the WHOLE list all the same: this line states it — `interval`
|
|
14309
|
+
* and `weekly`, each with a share and a reset instant — so an account can be
|
|
14310
|
+
* opened up and show both of its limits instead of only the bar.
|
|
14311
|
+
*
|
|
14312
|
+
* @param quota - The parsed `MinimaxCodeQuota`.
|
|
14313
|
+
* @returns The reading, or null when the 5-hour window measured nothing.
|
|
14314
|
+
*/
|
|
14315
|
+
function minimaxAccountQuotaReading(quota) {
|
|
14316
|
+
const root = asRecord$17(quota);
|
|
14317
|
+
if (root === null) return null;
|
|
14318
|
+
const stated = asRecords(root.windows);
|
|
14319
|
+
const fiveHour = stated.find((window) => asText$4(window.key) === "interval") ?? stated.find((window) => namedWindowMinutes(window.key) === 300);
|
|
14320
|
+
const usedPercent = (fiveHour === void 0 ? null : asPercent(fiveHour.usedPercent) ?? remainingPercentToUsed(fiveHour.remainingPercent)) ?? asPercent(root.usedPercent);
|
|
14321
|
+
if (usedPercent === null) return null;
|
|
14322
|
+
const resetsAtMs = fiveHour === void 0 ? asMs(root.resetsAtMs) : asMs(fiveHour.resetsAtMs);
|
|
14323
|
+
return {
|
|
14324
|
+
usedPercent,
|
|
14325
|
+
resetsAt: asResetAt(resetsAtMs),
|
|
14326
|
+
windows: minimaxWindowList(stated, usedPercent, resetsAtMs)
|
|
14327
|
+
};
|
|
14328
|
+
}
|
|
14329
|
+
/**
|
|
14330
|
+
* Every window one Token Plan snapshot stated, in the card's own vocabulary.
|
|
14331
|
+
*
|
|
14332
|
+
* Mapped exactly the way `normalizeMinimaxCodeQuota` maps the line-level list, so
|
|
14333
|
+
* the per-account list and the line list can never disagree about what a window
|
|
14334
|
+
* is called: the key is a machine token that stands for a length, and the length
|
|
14335
|
+
* names the window (`interval` → 300 → 「5 小时」, `weekly` → 10080 → 「每周」).
|
|
14336
|
+
* One derived rule, not a second one beside it.
|
|
14337
|
+
*
|
|
14338
|
+
* @param stated - The payload's own `windows`.
|
|
14339
|
+
* @param headlineUsedPercent - The share the row's bar draws.
|
|
14340
|
+
* @param headlineResetsAtMs - The reset instant that goes with it.
|
|
14341
|
+
* @returns The windows, shortest first; one derived from the headline when the
|
|
14342
|
+
* payload stated no window at all, `[]` only when it said nothing usable.
|
|
14343
|
+
*/
|
|
14344
|
+
function minimaxWindowList(stated, headlineUsedPercent, headlineResetsAtMs) {
|
|
14345
|
+
const windows = windowsFrom(stated.map((window) => ({
|
|
14346
|
+
label: null,
|
|
14347
|
+
usedPercent: asPercent(window.usedPercent) ?? remainingPercentToUsed(window.remainingPercent),
|
|
14348
|
+
windowMinutes: namedWindowMinutes(window.key),
|
|
14349
|
+
resetsAt: asMs(window.resetsAtMs)
|
|
14350
|
+
}))).sort(byWindowLength);
|
|
14351
|
+
if (windows.length > 0) return windows;
|
|
14352
|
+
return windowsFrom([{
|
|
14353
|
+
label: null,
|
|
14354
|
+
usedPercent: headlineUsedPercent,
|
|
14355
|
+
windowMinutes: 300,
|
|
14356
|
+
resetsAt: headlineResetsAtMs
|
|
14357
|
+
}]);
|
|
14358
|
+
}
|
|
14359
|
+
/**
|
|
14360
|
+
* Every normalizer, keyed by the line id a card names.
|
|
14361
|
+
*
|
|
14362
|
+
* The registry looks a line up here; a line that is registered but has no entry
|
|
14363
|
+
* falls back to nothing measured, so adding a pool without a normalizer
|
|
14364
|
+
* degrades to "no quota window" instead of to a thrown error.
|
|
14365
|
+
*/
|
|
14366
|
+
const LINE_QUOTA_NORMALIZERS = {
|
|
14367
|
+
codex: normalizeCodexQuota,
|
|
14368
|
+
claude: normalizeClaudeQuota,
|
|
14369
|
+
"glm-coding-plan": normalizeGlmCodingPlanQuota,
|
|
14370
|
+
"step-code": normalizeStepCodeQuota,
|
|
14371
|
+
qoder: normalizeQoderQuota,
|
|
14372
|
+
"cline-pass": normalizeClinePassQuota,
|
|
14373
|
+
opendesign: normalizeOpenDesignQuota,
|
|
14374
|
+
"kimi-code": normalizeKimiCodeQuota,
|
|
14375
|
+
"minimax-code": normalizeMinimaxCodeQuota,
|
|
14376
|
+
antigravity: normalizeAntigravityQuota,
|
|
14377
|
+
"command-code": normalizeCommandCodeQuota
|
|
14378
|
+
};
|
|
14379
|
+
/** The three rotation strategies, in the pool's own vocabulary. */
|
|
14380
|
+
const ROTATION_STRATEGIES$1 = [
|
|
14381
|
+
"sequential",
|
|
14382
|
+
"round-robin",
|
|
14383
|
+
"sticky"
|
|
14384
|
+
];
|
|
14385
|
+
/**
|
|
14386
|
+
* Every line this plugin can serve a card for.
|
|
14387
|
+
*
|
|
14388
|
+
* The routes are the lines' own, verbatim: this module is not allowed to invent
|
|
14389
|
+
* a prefix, because a prefix is what a line owns.
|
|
14390
|
+
*/
|
|
14391
|
+
const LINE_DEFINITIONS = [
|
|
14392
|
+
{
|
|
14393
|
+
id: "codex",
|
|
14394
|
+
label: "ChatGPT",
|
|
14395
|
+
statusPath: "/api/dsh-chatgpt-subscription/status",
|
|
14396
|
+
refreshPath: "/api/dsh-chatgpt-subscription/quota/refresh",
|
|
14397
|
+
normalize: LINE_QUOTA_NORMALIZERS.codex
|
|
14398
|
+
},
|
|
14399
|
+
{
|
|
14400
|
+
id: "claude",
|
|
14401
|
+
label: "Claude",
|
|
14402
|
+
statusPath: "/claude/api/status",
|
|
14403
|
+
refreshPath: "/claude/api/quota",
|
|
14404
|
+
normalize: LINE_QUOTA_NORMALIZERS.claude
|
|
14405
|
+
},
|
|
14406
|
+
{
|
|
14407
|
+
id: "glm-coding-plan",
|
|
14408
|
+
label: "GLM Coding Plan",
|
|
14409
|
+
statusPath: "/glm-coding-plan/api/status",
|
|
14410
|
+
refreshPath: "/glm-coding-plan/api/quota",
|
|
14411
|
+
normalize: LINE_QUOTA_NORMALIZERS["glm-coding-plan"]
|
|
14412
|
+
},
|
|
14413
|
+
{
|
|
14414
|
+
id: "step-code",
|
|
14415
|
+
label: "Step Code",
|
|
14416
|
+
statusPath: "/step-code/api/status",
|
|
14417
|
+
refreshPath: "/step-code/api/quota",
|
|
14418
|
+
normalize: LINE_QUOTA_NORMALIZERS["step-code"]
|
|
14419
|
+
},
|
|
14420
|
+
{
|
|
14421
|
+
id: "qoder",
|
|
14422
|
+
label: "Qoder",
|
|
14423
|
+
statusPath: "/qoder/api/status",
|
|
14424
|
+
refreshPath: "/qoder/api/quota",
|
|
14425
|
+
normalize: LINE_QUOTA_NORMALIZERS.qoder
|
|
14426
|
+
},
|
|
14427
|
+
{
|
|
14428
|
+
id: "cline-pass",
|
|
14429
|
+
label: "Cline Pass",
|
|
14430
|
+
statusPath: "/cline-pass/api/status",
|
|
14431
|
+
refreshPath: "/cline-pass/api/usage",
|
|
14432
|
+
normalize: LINE_QUOTA_NORMALIZERS["cline-pass"]
|
|
14433
|
+
},
|
|
14434
|
+
{
|
|
14435
|
+
id: "opendesign",
|
|
14436
|
+
label: "OpenDesign",
|
|
14437
|
+
statusPath: "/opendesign/api/status",
|
|
14438
|
+
refreshPath: "/opendesign/api/usage",
|
|
14439
|
+
normalize: LINE_QUOTA_NORMALIZERS.opendesign
|
|
14440
|
+
},
|
|
14441
|
+
{
|
|
14442
|
+
id: "kimi-code",
|
|
14443
|
+
label: "Kimi Code",
|
|
14444
|
+
statusPath: "/kimi-code/api/status",
|
|
14445
|
+
refreshPath: "/kimi-code/api/quota",
|
|
14446
|
+
normalize: LINE_QUOTA_NORMALIZERS["kimi-code"]
|
|
14447
|
+
},
|
|
14448
|
+
{
|
|
14449
|
+
id: "minimax-code",
|
|
14450
|
+
label: "MiniMax Code",
|
|
14451
|
+
statusPath: "/minimax-code/api/status",
|
|
14452
|
+
refreshPath: "/minimax-code/api/quota",
|
|
14453
|
+
normalize: LINE_QUOTA_NORMALIZERS["minimax-code"]
|
|
14454
|
+
},
|
|
14455
|
+
{
|
|
14456
|
+
id: "antigravity",
|
|
14457
|
+
label: "Antigravity",
|
|
14458
|
+
statusPath: "/antigravity/api/status",
|
|
14459
|
+
refreshPath: "/antigravity/api/quota",
|
|
14460
|
+
normalize: LINE_QUOTA_NORMALIZERS.antigravity
|
|
14461
|
+
},
|
|
14462
|
+
{
|
|
14463
|
+
id: "command-code",
|
|
14464
|
+
label: "Command Code",
|
|
14465
|
+
statusPath: "/command-code/api/status",
|
|
14466
|
+
refreshPath: "/command-code/api/quota",
|
|
14467
|
+
normalize: LINE_QUOTA_NORMALIZERS["command-code"]
|
|
14468
|
+
}
|
|
14469
|
+
];
|
|
14470
|
+
/**
|
|
14471
|
+
* THE per-account quota hook table: line id → how that line's readings are made.
|
|
14472
|
+
*
|
|
14473
|
+
* Keying it by line id is the whole point: a card names a line, a row names an
|
|
14474
|
+
* account, and a line with no entry here keeps EXACTLY the behaviour it had
|
|
14475
|
+
* before this table existed. That is not a fallback nobody exercised — it is the
|
|
14476
|
+
* default for nine of the eleven lines.
|
|
14477
|
+
*
|
|
14478
|
+
* Nothing in this table imports a line. Each entry needs that line's client and
|
|
14479
|
+
* receives it ({@link LineAccountQuotaClient}); a future line adds one entry
|
|
14480
|
+
* here plus one injected client, and no other module has to learn about it.
|
|
14481
|
+
*
|
|
14482
|
+
* What justified the two entries:
|
|
14483
|
+
*
|
|
14484
|
+
* - `qoder` — the line's `quota/usage` snapshot states absolute buckets, not a
|
|
14485
|
+
* percentage; {@link qoderAccountQuotaReading} derives a share only when a
|
|
14486
|
+
* bucket bounded it (total > 0) AND stated what was used. No bounded bucket,
|
|
14487
|
+
* no bar.
|
|
14488
|
+
* - `minimax-code` — the Token Plan states a 5-hour (`interval`) and a weekly
|
|
14489
|
+
* window; the row draws the 5-hour one, because that is the tightest limit the
|
|
14490
|
+
* plan has ({@link minimaxAccountQuotaReading}). The weekly window is never
|
|
14491
|
+
* substituted for it.
|
|
14492
|
+
*/
|
|
14493
|
+
const LINE_ACCOUNT_QUOTA_READERS = {
|
|
14494
|
+
qoder: (client) => async (credentials, context) => qoderAccountQuotaReading(await client(credentials, context)),
|
|
14495
|
+
"minimax-code": (client) => async (credentials, context) => minimaxAccountQuotaReading(await client(credentials, context))
|
|
14496
|
+
};
|
|
14497
|
+
/**
|
|
14498
|
+
* The readers in force, built from the clients the composition injected.
|
|
14499
|
+
*
|
|
14500
|
+
* A line whose client was not injected gets NO reader, which is what keeps the
|
|
14501
|
+
* hook optional in practice and not merely on paper: a composition that hands
|
|
14502
|
+
* over none of these reads every card exactly as it did before.
|
|
14503
|
+
*
|
|
14504
|
+
* @param clients - Line id → that line's quota client. Omitted or partial is the
|
|
14505
|
+
* normal case, not an error.
|
|
14506
|
+
* @returns Line id → the reader for it; lines without both halves are absent.
|
|
14507
|
+
*/
|
|
14508
|
+
function createAccountQuotaReaders(clients) {
|
|
14509
|
+
const readers = {};
|
|
14510
|
+
if (clients === void 0) return readers;
|
|
14511
|
+
for (const [line, factory] of Object.entries(LINE_ACCOUNT_QUOTA_READERS)) {
|
|
14512
|
+
const client = clients[line];
|
|
14513
|
+
if (client === void 0 || factory === void 0) continue;
|
|
14514
|
+
readers[line] = factory(client);
|
|
14515
|
+
}
|
|
14516
|
+
return readers;
|
|
14517
|
+
}
|
|
14518
|
+
/**
|
|
14519
|
+
* The payload as a JSON object, or null.
|
|
14520
|
+
*
|
|
14521
|
+
* @param value - Candidate.
|
|
14522
|
+
* @returns The object, or null.
|
|
14523
|
+
*/
|
|
14524
|
+
function asRecord$16(value) {
|
|
14525
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null;
|
|
14526
|
+
}
|
|
14527
|
+
/**
|
|
14528
|
+
* A trimmed non-empty string, or null.
|
|
14529
|
+
*
|
|
14530
|
+
* @param value - Candidate.
|
|
14531
|
+
* @returns The trimmed text, or null.
|
|
14532
|
+
*/
|
|
14533
|
+
function nonEmpty$2(value) {
|
|
14534
|
+
if (typeof value !== "string") return null;
|
|
14535
|
+
const trimmed = value.trim();
|
|
14536
|
+
return trimmed === "" ? null : trimmed;
|
|
14537
|
+
}
|
|
14538
|
+
/**
|
|
14539
|
+
* A rotation strategy, with the pool kernel's own fallback.
|
|
14540
|
+
*
|
|
14541
|
+
* @param value - Candidate.
|
|
14542
|
+
* @returns The strategy, defaulting to `sequential`.
|
|
14543
|
+
*/
|
|
14544
|
+
function normalizeStrategy(value) {
|
|
14545
|
+
return value === "round-robin" || value === "sticky" ? value : "sequential";
|
|
14546
|
+
}
|
|
14547
|
+
/**
|
|
14548
|
+
* A consumed share, clamped into the bar it is drawn as.
|
|
14549
|
+
*
|
|
14550
|
+
* @param value - Share in percent.
|
|
14551
|
+
* @returns The share between 0 and 100.
|
|
14552
|
+
*/
|
|
14553
|
+
function clampPercent$1(value) {
|
|
14554
|
+
if (!Number.isFinite(value)) return 0;
|
|
14555
|
+
return Math.min(100, Math.max(0, value));
|
|
14556
|
+
}
|
|
14557
|
+
/**
|
|
14558
|
+
* One error as the sentence a card can show.
|
|
14559
|
+
*
|
|
14560
|
+
* @param error - The caught value.
|
|
14561
|
+
* @param label - The line it happened on.
|
|
14562
|
+
* @param timeoutMs - The budget that may have expired.
|
|
14563
|
+
* @returns A short, user-facing reason.
|
|
14564
|
+
*/
|
|
14565
|
+
function reasonOf(error, label, timeoutMs) {
|
|
14566
|
+
if (error instanceof Error && (error.name === "AbortError" || error.name === "TimeoutError")) return label + " 的接口在 " + timeoutMs + " 毫秒内没有响应。 (Timed out.)";
|
|
14567
|
+
return error instanceof Error ? error.message : String(error);
|
|
14568
|
+
}
|
|
14569
|
+
/**
|
|
14570
|
+
* A deadline, combined with the caller's cancellation when one was passed.
|
|
14571
|
+
*
|
|
14572
|
+
* @param timeoutMs - The budget in milliseconds.
|
|
14573
|
+
* @param caller - The capability's own signal, when it has one.
|
|
14574
|
+
* @returns The signal to hand to fetch, and the disposer that clears the timer.
|
|
14575
|
+
*/
|
|
14576
|
+
function budgetOf(timeoutMs, caller) {
|
|
14577
|
+
const controller = new AbortController();
|
|
14578
|
+
const timer = setTimeout(() => {
|
|
14579
|
+
controller.abort(/* @__PURE__ */ new Error("timeout"));
|
|
14580
|
+
}, timeoutMs);
|
|
14581
|
+
timer.unref?.();
|
|
14582
|
+
const forward = () => {
|
|
14583
|
+
controller.abort(caller?.reason);
|
|
14584
|
+
};
|
|
14585
|
+
if (caller !== void 0) if (caller.aborted) forward();
|
|
14586
|
+
else caller.addEventListener("abort", forward, { once: true });
|
|
14587
|
+
return {
|
|
14588
|
+
signal: controller.signal,
|
|
14589
|
+
dispose: () => {
|
|
14590
|
+
clearTimeout(timer);
|
|
14591
|
+
caller?.removeEventListener("abort", forward);
|
|
14592
|
+
}
|
|
14593
|
+
};
|
|
14594
|
+
}
|
|
14595
|
+
/**
|
|
14596
|
+
* A promise that rejects once the budget expires.
|
|
14597
|
+
*
|
|
14598
|
+
* Applied to the LOCAL pool read as well as to HTTP: a credential store that
|
|
14599
|
+
* hangs (a stuck keychain, a locked file) must not hold the card either.
|
|
14600
|
+
*
|
|
14601
|
+
* @param promise - The work.
|
|
14602
|
+
* @param timeoutMs - The budget.
|
|
14603
|
+
* @param label - The line it belongs to.
|
|
14604
|
+
* @returns The work's value.
|
|
14605
|
+
*/
|
|
14606
|
+
function bounded(promise, timeoutMs, label) {
|
|
14607
|
+
if (timeoutMs <= 0) return promise;
|
|
14608
|
+
return new Promise((resolve, reject) => {
|
|
14609
|
+
const timer = setTimeout(() => {
|
|
14610
|
+
reject(/* @__PURE__ */ new Error(label + " 的本地读取超过 " + timeoutMs + " 毫秒。 (Local read timed out.)"));
|
|
14611
|
+
}, timeoutMs);
|
|
14612
|
+
timer.unref?.();
|
|
14613
|
+
promise.then((value) => {
|
|
14614
|
+
clearTimeout(timer);
|
|
14615
|
+
resolve(value);
|
|
14616
|
+
}, (error) => {
|
|
14617
|
+
clearTimeout(timer);
|
|
14618
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
14619
|
+
});
|
|
14620
|
+
});
|
|
14621
|
+
}
|
|
14622
|
+
/**
|
|
14623
|
+
* The reason a failed response states, when it states one.
|
|
14624
|
+
*
|
|
14625
|
+
* The routes answer their own failures in the same envelope
|
|
14626
|
+
* (`{ ok: false, error: '…' }`), and that sentence is what tells a user what to
|
|
14627
|
+
* do ("every account is disabled", "not signed in"). Reporting only the status
|
|
14628
|
+
* code would throw it away.
|
|
14629
|
+
*
|
|
14630
|
+
* @param response - The failed response.
|
|
14631
|
+
* @returns The stated reason, or null when the body states none.
|
|
14632
|
+
*/
|
|
14633
|
+
async function failureDetail(response) {
|
|
14634
|
+
try {
|
|
14635
|
+
const body = asRecord$16(await response.json());
|
|
14636
|
+
const detail = body?.error;
|
|
14637
|
+
if (typeof detail === "string" && detail.trim() !== "") return detail.trim();
|
|
14638
|
+
const message = body?.message;
|
|
14639
|
+
return typeof message === "string" && message.trim() !== "" ? message.trim() : null;
|
|
14640
|
+
} catch {
|
|
14641
|
+
return null;
|
|
14642
|
+
}
|
|
14643
|
+
}
|
|
14644
|
+
/**
|
|
14645
|
+
* Read one line's status route over loopback.
|
|
14646
|
+
*
|
|
14647
|
+
* @param definition - The line.
|
|
14648
|
+
* @param options - Where to reach the host and with what.
|
|
14649
|
+
* @param timeoutMs - The budget.
|
|
14650
|
+
* @param signal - The caller's cancellation.
|
|
14651
|
+
* @returns The payload, or the reason it could not be read.
|
|
14652
|
+
*/
|
|
14653
|
+
async function readStatus(definition, options, timeoutMs, signal) {
|
|
14654
|
+
const budget = budgetOf(timeoutMs, signal);
|
|
14655
|
+
const client = options.fetchFn ?? fetch;
|
|
14656
|
+
try {
|
|
14657
|
+
const response = await client(originOf(options) + definition.statusPath, {
|
|
14658
|
+
method: "GET",
|
|
14659
|
+
headers: { accept: "application/json" },
|
|
14660
|
+
signal: budget.signal
|
|
14661
|
+
});
|
|
14662
|
+
if (!response.ok) {
|
|
14663
|
+
const detail = await failureDetail(response);
|
|
14664
|
+
return { error: definition.label + " 的 status 路由返回 HTTP " + response.status + (detail === null ? "。" : ":" + detail + "。") + " (Status route failed.)" };
|
|
14665
|
+
}
|
|
14666
|
+
const body = asRecord$16(await response.json());
|
|
14667
|
+
if (body === null) return { error: definition.label + " 的 status 响应不是 JSON 对象。 (Status payload is not an object.)" };
|
|
14668
|
+
if (body.ok !== true) {
|
|
14669
|
+
const detail = nonEmpty$2(typeof body.error === "string" ? body.error : null);
|
|
14670
|
+
return { error: definition.label + " 的 status 路由报告失败:" + (detail ?? "ok 不是 true") + "。 (Status route reported a failure.)" };
|
|
14671
|
+
}
|
|
14672
|
+
return { value: body.value };
|
|
14673
|
+
} catch (error) {
|
|
14674
|
+
return { error: reasonOf(error, definition.label, timeoutMs) };
|
|
14675
|
+
} finally {
|
|
14676
|
+
budget.dispose();
|
|
14677
|
+
}
|
|
14678
|
+
}
|
|
14679
|
+
/**
|
|
14680
|
+
* The origin, resolved at call time.
|
|
14681
|
+
*
|
|
14682
|
+
* Lazy on purpose: the web server may not have bound a port when this provider
|
|
14683
|
+
* registers, and an origin resolved then would be a guess that outlives the
|
|
14684
|
+
* fact. A failure here is reported per card, not thrown at load.
|
|
14685
|
+
*
|
|
14686
|
+
* @param options - The registry options.
|
|
14687
|
+
* @returns The origin, without a trailing slash.
|
|
14688
|
+
*/
|
|
14689
|
+
function originOf(options) {
|
|
14690
|
+
const origin = options.origin();
|
|
14691
|
+
return origin.endsWith("/") ? origin.slice(0, -1) : origin;
|
|
14692
|
+
}
|
|
14693
|
+
/**
|
|
14694
|
+
* POST one line's own refresh route.
|
|
14695
|
+
*
|
|
14696
|
+
* The body is an empty JSON object rather than nothing: several of these routes
|
|
14697
|
+
* parse a JSON body before dispatching, and a request stating `content-length:
|
|
14698
|
+
* 0` is one they would refuse. The `origin` header is what the routes'
|
|
14699
|
+
* same-origin check compares against the `Host` they were addressed to — this
|
|
14700
|
+
* call is the host asking itself, so the two agree.
|
|
14701
|
+
*
|
|
14702
|
+
* @param definition - The line.
|
|
14703
|
+
* @param options - Where to reach the host and with what.
|
|
14704
|
+
* @param timeoutMs - The budget.
|
|
14705
|
+
* @param signal - The caller's cancellation.
|
|
14706
|
+
* @returns The outcome when the refresh failed, or null when it landed.
|
|
14707
|
+
*/
|
|
14708
|
+
async function postRefresh(definition, options, timeoutMs, signal) {
|
|
14709
|
+
const path = definition.refreshPath;
|
|
14710
|
+
if (path === null) return null;
|
|
14711
|
+
const budget = budgetOf(timeoutMs, signal);
|
|
14712
|
+
const client = options.fetchFn ?? fetch;
|
|
14713
|
+
let origin;
|
|
14714
|
+
try {
|
|
14715
|
+
origin = originOf(options);
|
|
14716
|
+
} catch (error) {
|
|
14717
|
+
budget.dispose();
|
|
14718
|
+
return {
|
|
14719
|
+
message: reasonOf(error, definition.label, timeoutMs),
|
|
14720
|
+
stillRunning: false
|
|
14721
|
+
};
|
|
14722
|
+
}
|
|
14723
|
+
try {
|
|
14724
|
+
const response = await client(origin + path, {
|
|
14725
|
+
method: "POST",
|
|
14726
|
+
headers: {
|
|
14727
|
+
"content-type": "application/json",
|
|
14728
|
+
origin
|
|
14729
|
+
},
|
|
14730
|
+
body: "{}",
|
|
14731
|
+
signal: budget.signal
|
|
14732
|
+
});
|
|
14733
|
+
if (!response.ok) {
|
|
14734
|
+
const detail = await failureDetail(response);
|
|
14735
|
+
return {
|
|
14736
|
+
message: definition.label + " 的刷新路由返回 HTTP " + response.status + (detail === null ? "。" : ":" + detail + "。") + " (Refresh route failed.)",
|
|
14737
|
+
stillRunning: false
|
|
14738
|
+
};
|
|
14739
|
+
}
|
|
14740
|
+
return null;
|
|
14741
|
+
} catch (error) {
|
|
14742
|
+
if (budget.signal.aborted && !(signal?.aborted ?? false)) return {
|
|
14743
|
+
message: definition.label + " 的刷新在 " + timeoutMs + " 毫秒内没有返回;线路仍在刷新,卡片读到的是它上一次的快照。 (The refresh did not answer within the budget; the line is still refreshing.)",
|
|
14744
|
+
stillRunning: true
|
|
14745
|
+
};
|
|
14746
|
+
return {
|
|
14747
|
+
message: reasonOf(error, definition.label, timeoutMs),
|
|
14748
|
+
stillRunning: false
|
|
14749
|
+
};
|
|
14750
|
+
} finally {
|
|
14751
|
+
budget.dispose();
|
|
14752
|
+
}
|
|
14753
|
+
}
|
|
14754
|
+
/**
|
|
14755
|
+
* One deadline applied to every request a fetch makes.
|
|
14756
|
+
*
|
|
14757
|
+
* The per-account readers are handed a client, not a signal, so the budget has
|
|
14758
|
+
* to travel inside the client: a reader that keeps the `fetch` it was given is
|
|
14759
|
+
* then cancelled at the deadline instead of merely being ignored while its
|
|
14760
|
+
* socket stays open. A caller's own signal is respected when it already carries
|
|
14761
|
+
* one — this ADDS a deadline, it never loosens one.
|
|
14762
|
+
*
|
|
14763
|
+
* @param fetchFn - The client to wrap.
|
|
14764
|
+
* @param signal - The deadline to attach.
|
|
14765
|
+
* @returns A client whose requests carry it.
|
|
14766
|
+
*/
|
|
14767
|
+
function withDeadline(fetchFn, signal) {
|
|
14768
|
+
const boundedFetch = async (input, init) => await fetchFn(input, init === void 0 ? { signal } : {
|
|
14769
|
+
...init,
|
|
14770
|
+
signal: init.signal ?? signal
|
|
14771
|
+
});
|
|
14772
|
+
return boundedFetch;
|
|
14773
|
+
}
|
|
14774
|
+
/**
|
|
14775
|
+
* A counting gate: at most `limit` callers run `work` at the same time.
|
|
14776
|
+
*
|
|
14777
|
+
* The slot is handed straight to the next waiter rather than released and
|
|
14778
|
+
* re-taken, so `active` can never exceed the limit by one in the window between
|
|
14779
|
+
* two arrivals — the reason a naive decrement-then-increment leaks.
|
|
14780
|
+
*
|
|
14781
|
+
* @param limit - How many may run at once.
|
|
14782
|
+
* @returns The gated runner.
|
|
14783
|
+
*/
|
|
14784
|
+
function gateOf(limit) {
|
|
14785
|
+
let active = 0;
|
|
14786
|
+
const waiting = [];
|
|
14787
|
+
return async function run(work) {
|
|
14788
|
+
if (active < limit) active += 1;
|
|
14789
|
+
else await new Promise((resolve) => waiting.push(resolve));
|
|
14790
|
+
try {
|
|
14791
|
+
return await work();
|
|
14792
|
+
} finally {
|
|
14793
|
+
const next = waiting.shift();
|
|
14794
|
+
if (next === void 0) active -= 1;
|
|
14795
|
+
else next();
|
|
14796
|
+
}
|
|
14797
|
+
};
|
|
14798
|
+
}
|
|
14799
|
+
/**
|
|
14800
|
+
* How long one cached entry stays fresh.
|
|
14801
|
+
*
|
|
14802
|
+
* The two tiers are the point: a good reading waits out the poll interval, a
|
|
14803
|
+
* failed one is retried soon (see the two TTL constants).
|
|
14804
|
+
*
|
|
14805
|
+
* @param entry - The cached entry.
|
|
14806
|
+
* @param runtime - Where the two TTLs live.
|
|
14807
|
+
* @returns Its lifetime in milliseconds.
|
|
14808
|
+
*/
|
|
14809
|
+
function lifetimeOf(entry, runtime) {
|
|
14810
|
+
return entry.reading === null ? runtime.failureCacheMs : runtime.cacheMs;
|
|
14811
|
+
}
|
|
14812
|
+
/**
|
|
14813
|
+
* Read ONE account's own quota, absorbing every failure.
|
|
14814
|
+
*
|
|
14815
|
+
* The two steps — the credential, then the upstream answer — share one budget,
|
|
14816
|
+
* because the row is one question: a credential that took two of the five
|
|
14817
|
+
* seconds must not buy the read a second full allowance.
|
|
14818
|
+
*
|
|
14819
|
+
* @param definition - The line, for the label a diagnostic would state.
|
|
14820
|
+
* @param accountId - The account being read.
|
|
14821
|
+
* @param credentialFor - The pool's per-account credential resolver.
|
|
14822
|
+
* @param reader - The line's reader.
|
|
14823
|
+
* @param fetchFn - The client to read through.
|
|
14824
|
+
* @param runtime - The budgets, and the caller's cancellation.
|
|
14825
|
+
* @param signal - The card's own cancellation, when it has one.
|
|
14826
|
+
* @returns The reading, or null for "nothing measured, one way or another".
|
|
14827
|
+
*/
|
|
14828
|
+
async function readOneAccountQuota(definition, accountId, credentialFor, reader, fetchFn, runtime, signal) {
|
|
14829
|
+
const budget = budgetOf(runtime.timeoutMs, signal);
|
|
14830
|
+
const client = withDeadline(fetchFn, budget.signal);
|
|
14831
|
+
try {
|
|
14832
|
+
return await bounded((async () => {
|
|
14833
|
+
const reading = await reader(await credentialFor(accountId, client), {
|
|
14834
|
+
accountId,
|
|
14835
|
+
fetchFn: client
|
|
14836
|
+
});
|
|
14837
|
+
if (reading === null || reading.usedPercent === null || !Number.isFinite(reading.usedPercent)) return null;
|
|
14838
|
+
const resetsAt = reading.resetsAt !== null && Number.isFinite(reading.resetsAt) ? reading.resetsAt : null;
|
|
14839
|
+
return {
|
|
14840
|
+
usedPercent: clampPercent$1(reading.usedPercent),
|
|
14841
|
+
resetsAt,
|
|
14842
|
+
windows: Array.isArray(reading.windows) ? reading.windows : []
|
|
14843
|
+
};
|
|
14844
|
+
})(), runtime.timeoutMs, definition.label);
|
|
14845
|
+
} catch {
|
|
14846
|
+
return null;
|
|
14847
|
+
} finally {
|
|
14848
|
+
budget.dispose();
|
|
14849
|
+
}
|
|
14850
|
+
}
|
|
14851
|
+
/**
|
|
14852
|
+
* The accounts one read spends its requests on, in the order it spends them.
|
|
14853
|
+
*
|
|
14854
|
+
* Primary first, then most recently used, because a cap has to drop SOMETHING and
|
|
14855
|
+
* the rows it drops should be the ones a reader is least likely to be looking at.
|
|
14856
|
+
* The sort is stable on purpose: rows that tie on both keys keep the pool's own
|
|
14857
|
+
* order, so two reads of an unchanged pool pick the same eight and the window
|
|
14858
|
+
* does not flicker between polls.
|
|
14859
|
+
*
|
|
14860
|
+
* @param accounts - Every pooled account, as the pool stated them.
|
|
14861
|
+
* @param limit - How many may be read at most; zero or less means no cap.
|
|
14862
|
+
* @returns The accounts to read, in the order their requests should start.
|
|
14863
|
+
*/
|
|
14864
|
+
function accountsForReading(accounts, limit) {
|
|
14865
|
+
const ordered = [...accounts].sort((left, right) => {
|
|
14866
|
+
const primary = (right.isPrimary ? 1 : 0) - (left.isPrimary ? 1 : 0);
|
|
14867
|
+
if (primary !== 0) return primary;
|
|
14868
|
+
return (right.lastUsedAt ?? 0) - (left.lastUsedAt ?? 0);
|
|
14869
|
+
});
|
|
14870
|
+
return limit > 0 ? ordered.slice(0, limit) : ordered;
|
|
14871
|
+
}
|
|
14872
|
+
/**
|
|
14873
|
+
* The value a per-account read settles to when the stage budget expired first.
|
|
14874
|
+
*
|
|
14875
|
+
* A sentinel rather than `null` on purpose: `null` means "this account WAS asked
|
|
14876
|
+
* and measured nothing", which the row draws as an empty window list and the cache
|
|
14877
|
+
* remembers as a failure. A row the stage budget never reached was never asked, so
|
|
14878
|
+
* remembering it would be wrong — and not remembering it is exactly what keeps the
|
|
14879
|
+
* promise that the next read tries it again.
|
|
14880
|
+
*/
|
|
14881
|
+
const STAGE_BUDGET_SPENT = Symbol("per-account stage budget spent");
|
|
14882
|
+
/**
|
|
14883
|
+
* A promise that settles the moment `signal` aborts, and never rejects.
|
|
14884
|
+
*
|
|
14885
|
+
* Racing a read against this is how the stage budget cuts a row off: the row stops
|
|
14886
|
+
* being awaited, while the read itself is cancelled through the same signal it was
|
|
14887
|
+
* handed ({@link readOneAccountQuota}), so no gate slot is held afterwards.
|
|
14888
|
+
*
|
|
14889
|
+
* @param signal - The stage's own deadline.
|
|
14890
|
+
* @returns The sentinel, once the deadline passes.
|
|
14891
|
+
*/
|
|
14892
|
+
function whenSpent(signal) {
|
|
14893
|
+
return new Promise((resolve) => {
|
|
14894
|
+
if (signal.aborted) {
|
|
14895
|
+
resolve(STAGE_BUDGET_SPENT);
|
|
14896
|
+
return;
|
|
14897
|
+
}
|
|
14898
|
+
signal.addEventListener("abort", () => resolve(STAGE_BUDGET_SPENT), { once: true });
|
|
14899
|
+
});
|
|
14900
|
+
}
|
|
14901
|
+
/**
|
|
14902
|
+
* Every account's own reading this card can afford.
|
|
14903
|
+
*
|
|
14904
|
+
* Two bounds decide "can afford", and they answer different questions: HOW MANY
|
|
14905
|
+
* rows are worth a request ({@link LINE_ACCOUNT_QUOTA_MAX_ACCOUNTS}, primary and
|
|
14906
|
+
* most-recently-used first) and HOW LONG the stage may hold the card
|
|
14907
|
+
* ({@link LINE_ACCOUNT_QUOTA_WHOLE_BUDGET_MS}). Neither replaces the per-account
|
|
14908
|
+
* budget, which still stops one hanging row from spending the stage's whole
|
|
14909
|
+
* allowance alone.
|
|
14910
|
+
*
|
|
14911
|
+
* @param definition - The line.
|
|
14912
|
+
* @param pool - The account half, already read.
|
|
14913
|
+
* @param source - The line's pool, for its per-account credential resolver.
|
|
14914
|
+
* @param runtime - Readers, cache, gate and budgets.
|
|
14915
|
+
* @param fetchFn - The client the pool and the readers use.
|
|
14916
|
+
* @param signal - The card's own cancellation.
|
|
14917
|
+
* @returns The readings per account id (null included, so a remembered failure
|
|
14918
|
+
* is distinguishable from an absent entry), and whether the line is hooked;
|
|
14919
|
+
* both empty/false for a line that did not opt in, in which case every row
|
|
14920
|
+
* keeps the pool's own snapshot and states no window list.
|
|
14921
|
+
*/
|
|
14922
|
+
async function readAccountQuotas(definition, pool, source, runtime, fetchFn, signal) {
|
|
14923
|
+
const readings = /* @__PURE__ */ new Map();
|
|
14924
|
+
const reader = runtime.readers[definition.id];
|
|
14925
|
+
const credentialFor = source?.getFreshCredential;
|
|
14926
|
+
const hooked = reader !== void 0 && credentialFor !== void 0;
|
|
14927
|
+
if (reader === void 0 || credentialFor === void 0 || pool.accounts.length === 0) return {
|
|
14928
|
+
hooked,
|
|
14929
|
+
readings
|
|
14930
|
+
};
|
|
14931
|
+
const now = Date.now();
|
|
14932
|
+
for (const [key, entry] of runtime.cache) if (now - entry.at >= lifetimeOf(entry, runtime)) runtime.cache.delete(key);
|
|
14933
|
+
const selected = accountsForReading(pool.accounts, runtime.maxAccounts);
|
|
14934
|
+
const stage = runtime.wholeBudgetMs > 0 ? budgetOf(runtime.wholeBudgetMs, signal) : null;
|
|
14935
|
+
const spent = stage === null ? null : whenSpent(stage.signal);
|
|
14936
|
+
const credentialed = credentialFor.bind(source);
|
|
14937
|
+
try {
|
|
14938
|
+
await Promise.all(selected.map(async (account) => {
|
|
14939
|
+
const key = definition.id + "\0" + account.id;
|
|
14940
|
+
const cached = runtime.cache.get(key);
|
|
14941
|
+
if (cached !== void 0 && now - cached.at < lifetimeOf(cached, runtime)) {
|
|
14942
|
+
readings.set(account.id, cached.reading);
|
|
14943
|
+
return;
|
|
14944
|
+
}
|
|
14945
|
+
let flight = runtime.pending.get(key);
|
|
14946
|
+
if (flight === void 0) {
|
|
14947
|
+
flight = runtime.gate(async () => await readOneAccountQuota(definition, account.id, credentialed, reader, fetchFn, runtime, stage?.signal ?? signal));
|
|
14948
|
+
runtime.pending.set(key, flight);
|
|
14949
|
+
const settle = () => {
|
|
14950
|
+
if (runtime.pending.get(key) === flight) runtime.pending.delete(key);
|
|
14951
|
+
};
|
|
14952
|
+
flight.then(settle, settle);
|
|
14953
|
+
}
|
|
14954
|
+
const reading = spent === null ? await flight : await Promise.race([flight, spent]);
|
|
14955
|
+
if (reading === STAGE_BUDGET_SPENT) return;
|
|
14956
|
+
runtime.cache.set(key, {
|
|
14957
|
+
at: Date.now(),
|
|
14958
|
+
reading
|
|
14959
|
+
});
|
|
14960
|
+
readings.set(account.id, reading);
|
|
14961
|
+
}));
|
|
14962
|
+
} finally {
|
|
14963
|
+
stage?.dispose();
|
|
14964
|
+
}
|
|
14965
|
+
return {
|
|
14966
|
+
hooked,
|
|
14967
|
+
readings
|
|
14968
|
+
};
|
|
14969
|
+
}
|
|
14970
|
+
/**
|
|
14971
|
+
* The account half of a card, from the pool itself.
|
|
14972
|
+
*
|
|
14973
|
+
* Both calls are attempted even when one fails: listAccounts and read read the
|
|
14974
|
+
* same document through different paths, and a pool that answers rows but not
|
|
14975
|
+
* its document (or the reverse) still describes most of itself.
|
|
14976
|
+
*
|
|
14977
|
+
* @param pool - The line's pool, if it has one.
|
|
14978
|
+
* @param definition - The line.
|
|
14979
|
+
* @param timeoutMs - The budget.
|
|
14980
|
+
* @returns The facts.
|
|
14981
|
+
*/
|
|
14982
|
+
async function readPool(pool, definition, timeoutMs) {
|
|
14983
|
+
if (pool === void 0) return {
|
|
14984
|
+
installed: false,
|
|
14985
|
+
accounts: [],
|
|
14986
|
+
activeAccountId: null,
|
|
14987
|
+
strategy: "sequential",
|
|
14988
|
+
error: null
|
|
14989
|
+
};
|
|
14990
|
+
const listTask = bounded(pool.listAccounts(), timeoutMs, definition.label);
|
|
14991
|
+
const documentTask = pool.read === void 0 ? Promise.resolve(void 0) : bounded(pool.read(), timeoutMs, definition.label);
|
|
14992
|
+
const [list, document] = await Promise.allSettled([listTask, documentTask]);
|
|
14993
|
+
const accounts = list.status === "fulfilled" ? list.value : [];
|
|
14994
|
+
const failure = list.status === "rejected" ? reasonOf(list.reason, definition.label, timeoutMs) : document.status === "rejected" ? reasonOf(document.reason, definition.label, timeoutMs) : null;
|
|
14995
|
+
const record = document.status === "fulfilled" ? asRecord$16(document.value) : null;
|
|
14996
|
+
const primary = accounts.find((account) => account.isPrimary === true);
|
|
14997
|
+
return {
|
|
14998
|
+
installed: true,
|
|
14999
|
+
accounts,
|
|
15000
|
+
activeAccountId: nonEmpty$2(typeof record?.activeAccountId === "string" ? record.activeAccountId : null) ?? primary?.id ?? null,
|
|
15001
|
+
strategy: normalizeStrategy(record?.rotationStrategy),
|
|
15002
|
+
error: failure
|
|
15003
|
+
};
|
|
15004
|
+
}
|
|
15005
|
+
/**
|
|
15006
|
+
* One pooled account as the card renders it.
|
|
15007
|
+
*
|
|
15008
|
+
* @param account - The pool's own summary.
|
|
15009
|
+
* @param reading - This account's own newest reading, when the line produced
|
|
15010
|
+
* one; null/absent means the line has no hook, the read failed, or it measured
|
|
15011
|
+
* nothing — and all three leave the pool's own snapshot standing.
|
|
15012
|
+
* @param hooked - Whether a per-account read is in force for this line at all,
|
|
15013
|
+
* which is what decides whether the row states a window list. Defaults to
|
|
15014
|
+
* false so a caller with no hook knowledge omits it rather than inventing an
|
|
15015
|
+
* empty list.
|
|
15016
|
+
* @returns The row.
|
|
15017
|
+
*/
|
|
15018
|
+
function accountRow(account, reading, hooked = false) {
|
|
15019
|
+
const window = account.quota?.windows[0];
|
|
15020
|
+
const own = reading === null || reading === void 0 ? null : reading;
|
|
15021
|
+
const ownUsed = own === null || own.usedPercent === null ? null : clampPercent$1(own.usedPercent);
|
|
15022
|
+
return {
|
|
15023
|
+
id: account.id,
|
|
15024
|
+
label: nonEmpty$2(account.alias) ?? nonEmpty$2(account.email) ?? account.id,
|
|
15025
|
+
email: account.email ?? null,
|
|
15026
|
+
primary: account.isPrimary === true,
|
|
15027
|
+
plan: account.planLabel ?? null,
|
|
15028
|
+
cooldownUntil: account.cooldownUntil ?? null,
|
|
15029
|
+
authStatus: account.authStatus ?? null,
|
|
15030
|
+
usedPercent: ownUsed ?? (window === void 0 ? null : clampPercent$1(window.usedPercent)),
|
|
15031
|
+
resetsAt: ownUsed === null ? window?.resetsAt ?? null : own?.resetsAt ?? window?.resetsAt ?? null,
|
|
15032
|
+
...hooked ? { windows: own?.windows ?? [] } : {}
|
|
15033
|
+
};
|
|
15034
|
+
}
|
|
15035
|
+
/**
|
|
15036
|
+
* One line's whole card: the halves read and merged.
|
|
15037
|
+
*
|
|
15038
|
+
* The quota half starts first because the per-account readings need the account
|
|
15039
|
+
* rows to know which accounts exist; both then run while it answers, so the card
|
|
15040
|
+
* still costs one budget rather than two.
|
|
15041
|
+
*
|
|
15042
|
+
* @param definition - The line.
|
|
15043
|
+
* @param options - The registry options.
|
|
15044
|
+
* @param quota - The per-account hook state (readers, cache, gate, budgets).
|
|
15045
|
+
* @param timeoutMs - The budget.
|
|
15046
|
+
* @param signal - The caller's cancellation.
|
|
15047
|
+
* @returns Shape C.
|
|
15048
|
+
*/
|
|
15049
|
+
async function readCard(definition, options, quota, timeoutMs, signal) {
|
|
15050
|
+
const source = options.pools[definition.id];
|
|
15051
|
+
const statusTask = readStatus(definition, options, timeoutMs, signal);
|
|
15052
|
+
const pool = await readPool(source, definition, timeoutMs);
|
|
15053
|
+
const [status, accountQuotas] = await Promise.all([statusTask, readAccountQuotas(definition, pool, source, quota, options.fetchFn ?? fetch, signal)]);
|
|
15054
|
+
const facts = "error" in status ? null : definition.normalize(status.value);
|
|
15055
|
+
const transportError = "error" in status ? status.error : null;
|
|
15056
|
+
const primary = pool.accounts.find((account) => account.id === pool.activeAccountId) ?? pool.accounts.find((account) => account.isPrimary === true) ?? pool.accounts[0];
|
|
15057
|
+
return {
|
|
15058
|
+
line: definition.id,
|
|
15059
|
+
label: definition.label,
|
|
15060
|
+
signedIn: facts?.signedIn === true || pool.accounts.some((account) => account.authStatus === void 0 || account.authStatus === "ok"),
|
|
15061
|
+
installed: pool.installed,
|
|
15062
|
+
planLabel: facts?.planLabel ?? null,
|
|
15063
|
+
accountLabel: facts?.accountLabel ?? nonEmpty$2(primary?.email) ?? null,
|
|
15064
|
+
accounts: pool.accounts.map((account) => accountRow(account, accountQuotas.readings.get(account.id), accountQuotas.hooked)),
|
|
15065
|
+
activeAccountId: pool.activeAccountId ?? primary?.id ?? null,
|
|
15066
|
+
strategy: pool.strategy,
|
|
15067
|
+
quota: facts?.quota ?? null,
|
|
15068
|
+
error: transportError ?? facts?.error ?? pool.error
|
|
15069
|
+
};
|
|
15070
|
+
}
|
|
15071
|
+
/**
|
|
15072
|
+
* Build a registry over the injected line → pool mapping.
|
|
15073
|
+
*
|
|
15074
|
+
* @param options - The mapping, the origin and the seams.
|
|
15075
|
+
* @returns The registry.
|
|
15076
|
+
*/
|
|
15077
|
+
function createLineRegistry(options) {
|
|
15078
|
+
const definitions = options.definitions ?? LINE_DEFINITIONS;
|
|
15079
|
+
const timeoutMs = options.timeoutMs ?? 5e3;
|
|
15080
|
+
const accountQuota = {
|
|
15081
|
+
readers: createAccountQuotaReaders(options.accountQuotaClients),
|
|
15082
|
+
cache: /* @__PURE__ */ new Map(),
|
|
15083
|
+
pending: /* @__PURE__ */ new Map(),
|
|
15084
|
+
gate: gateOf(2),
|
|
15085
|
+
cacheMs: options.accountQuotaCacheMs ?? 6e4,
|
|
15086
|
+
failureCacheMs: options.accountQuotaFailureCacheMs ?? 15e3,
|
|
15087
|
+
timeoutMs: options.accountQuotaTimeoutMs ?? 5e3,
|
|
15088
|
+
wholeBudgetMs: options.accountQuotaWholeBudgetMs ?? 6e3,
|
|
15089
|
+
maxAccounts: options.accountQuotaMaxAccounts ?? 8
|
|
15090
|
+
};
|
|
15091
|
+
const byId = new Map(definitions.map((definition) => [definition.id, definition]));
|
|
15092
|
+
const definitionOf = (line) => {
|
|
15093
|
+
const found = byId.get(line);
|
|
15094
|
+
if (found !== void 0) return found;
|
|
15095
|
+
throw new Error("未知线路「" + line + "」:这个注册表里没有它。 (Unknown line \"" + line + "\"; known lines: " + definitions.map((entry) => entry.id).join(", ") + ".)");
|
|
15096
|
+
};
|
|
15097
|
+
const poolOf = (definition) => options.pools[definition.id];
|
|
15098
|
+
return {
|
|
15099
|
+
definitions,
|
|
15100
|
+
ids: () => definitions.map((definition) => definition.id),
|
|
15101
|
+
read: async (line, signal) => await readCard(definitionOf(line), options, accountQuota, timeoutMs, signal),
|
|
15102
|
+
refresh: async (line, signal) => {
|
|
15103
|
+
const definition = definitionOf(line);
|
|
15104
|
+
const outcome = await postRefresh(definition, options, timeoutMs, signal);
|
|
15105
|
+
const card = await readCard(definition, options, accountQuota, timeoutMs, signal);
|
|
15106
|
+
if (outcome === null) return card;
|
|
15107
|
+
if (outcome.stillRunning && card.quota !== null) return {
|
|
15108
|
+
...card,
|
|
15109
|
+
quota: {
|
|
15110
|
+
...card.quota,
|
|
15111
|
+
refreshing: true
|
|
15112
|
+
}
|
|
15113
|
+
};
|
|
15114
|
+
return card.quota !== null || card.error !== null ? card : {
|
|
15115
|
+
...card,
|
|
15116
|
+
error: outcome.message
|
|
15117
|
+
};
|
|
15118
|
+
},
|
|
15119
|
+
switchAccount: async (line, accountId, signal) => {
|
|
15120
|
+
const definition = definitionOf(line);
|
|
15121
|
+
const pool = poolOf(definition);
|
|
15122
|
+
if (pool === void 0) throw new Error("「" + definition.label + "」的账号池未安装,没有可切换的账号。 (This line has no account pool.)");
|
|
15123
|
+
if (!(await pool.listAccounts()).some((account) => account.id === accountId)) throw new Error("未知账号「" + accountId + "」:「" + definition.label + "」的号池里没有这个 id。 (Unknown account.)");
|
|
15124
|
+
await pool.setPrimary(accountId);
|
|
15125
|
+
return await readCard(definition, options, accountQuota, timeoutMs, signal);
|
|
15126
|
+
},
|
|
15127
|
+
changeStrategy: async (line, strategy, signal) => {
|
|
15128
|
+
const definition = definitionOf(line);
|
|
15129
|
+
const pool = poolOf(definition);
|
|
15130
|
+
if (pool === void 0) throw new Error("「" + definition.label + "」的账号池未安装,没有可调整的轮换策略。 (This line has no account pool.)");
|
|
15131
|
+
const match = ROTATION_STRATEGIES$1.find((candidate) => candidate === strategy);
|
|
15132
|
+
if (match === void 0) throw new Error("轮换策略只能是 " + ROTATION_STRATEGIES$1.join(" / ") + " 之一,收到的不是其中之一。 (The rotation strategy must be one of " + ROTATION_STRATEGIES$1.join(", ") + ".)");
|
|
15133
|
+
await pool.setStrategy(match);
|
|
15134
|
+
return await readCard(definition, options, accountQuota, timeoutMs, signal);
|
|
15135
|
+
}
|
|
15136
|
+
};
|
|
15137
|
+
}
|
|
15138
|
+
/**
|
|
15139
|
+
* The line a card names, from its props.
|
|
15140
|
+
*
|
|
15141
|
+
* @param props - The card's props.
|
|
15142
|
+
* @returns The line id.
|
|
15143
|
+
*/
|
|
15144
|
+
function lineOfProps(props) {
|
|
15145
|
+
const record = asRecord$16(props);
|
|
15146
|
+
const line = nonEmpty$2(typeof record?.line === "string" ? record.line : null);
|
|
15147
|
+
if (line === null) throw new Error("这张卡片没有 line 属性:line-card 数据源需要 props.line 指明读哪条线路。 (This card states no \"line\" prop; the line-card data source needs one.)");
|
|
15148
|
+
return line;
|
|
15149
|
+
}
|
|
15150
|
+
//#endregion
|
|
15151
|
+
//#region src/host/widget-board/provider.ts
|
|
15152
|
+
/**
|
|
15153
|
+
* The `widgetBoard` provider: this plugin's board contribution.
|
|
15154
|
+
*
|
|
15155
|
+
* What it publishes (all under one handle, so unloading this plugin takes all
|
|
15156
|
+
* of it with it):
|
|
15157
|
+
*
|
|
15158
|
+
* - one widget-type directory — the `widgets` folder that ships in this package;
|
|
15159
|
+
* - one data source, `line-card`, which answers one card per subscription line;
|
|
15160
|
+
* - three capabilities: force one line's quota read, pin one of its pooled
|
|
15161
|
+
* accounts, and change that line's rotation strategy.
|
|
15162
|
+
*
|
|
15163
|
+
* The two ChatGPT-only cards this provider used to publish (one for quota, one
|
|
15164
|
+
* for the account pool) are gone: `widgets/line-flip` replaced both with a single
|
|
15165
|
+
* card that names its line in `props.line`, so the old per-subject data sources,
|
|
15166
|
+
* their shapes and their unit conversion were deleted rather than left unused.
|
|
15167
|
+
*
|
|
15168
|
+
* The service is optional. A host without a board never reaches the inject
|
|
15169
|
+
* callback at all, and everything below is sized to make that true even when
|
|
15170
|
+
* the board IS present: nothing here may fail the plugin's load, so the
|
|
15171
|
+
* registration runs off the load path and every failure is a warn.
|
|
15172
|
+
*/
|
|
15173
|
+
/**
|
|
15174
|
+
* The package name the widget directory is looked up by.
|
|
15175
|
+
*
|
|
15176
|
+
* Deliberately the SCOPED id, unlike {@link WIDGET_BOARD_PROVIDER}: this string
|
|
15177
|
+
* never reaches a manifest, it only identifies which `package.json` found by
|
|
15178
|
+
* walking up from this file is this package's own.
|
|
15179
|
+
*/
|
|
15180
|
+
const PACKAGE_NAME = "@eddyskywalker/dsh-chatgpt-subscription";
|
|
15181
|
+
/** How many directories to climb while looking for this package's root. */
|
|
15182
|
+
const MAX_ROOT_WALK = 8;
|
|
15183
|
+
/**
|
|
15184
|
+
* Subdirectory of this package that holds the widget types.
|
|
15185
|
+
*
|
|
15186
|
+
* The board reads every child directory of it as one widget, so it ships as
|
|
15187
|
+
* part of the package (`files` in package.json) rather than being generated.
|
|
15188
|
+
*/
|
|
15189
|
+
const WIDGET_DIRECTORY_NAME = "widgets";
|
|
15190
|
+
/**
|
|
15191
|
+
* Register this plugin's board contribution, if the host has a board.
|
|
15192
|
+
*
|
|
15193
|
+
* @param ctx - The host context; only `inject` is used.
|
|
15194
|
+
* @param options - The line registry to publish through, and the widget directory override.
|
|
15195
|
+
*/
|
|
15196
|
+
function registerWidgetBoard(ctx, options) {
|
|
15197
|
+
ctx.inject(["widgetBoard"], (scope) => {
|
|
15198
|
+
const service = scope.get("widgetBoard");
|
|
15199
|
+
if (service === void 0 || service === null) return;
|
|
15200
|
+
const apiVersion = service.apiVersion;
|
|
15201
|
+
if (typeof apiVersion !== "number" || !Number.isFinite(apiVersion) || apiVersion < 2) {
|
|
15202
|
+
scope.logger.warn(`[dsh-chatgpt-subscription] widget board provider skipped: the board reports apiVersion ${String(apiVersion)}, which this provider cannot serve (it needs a number >= 2).`);
|
|
15203
|
+
return;
|
|
15204
|
+
}
|
|
15205
|
+
if (typeof service.registerWidgetType !== "function") {
|
|
15206
|
+
scope.logger.warn("[dsh-chatgpt-subscription] widget board provider skipped: the board service has no registerWidgetType.");
|
|
15207
|
+
return;
|
|
15208
|
+
}
|
|
15209
|
+
scope.effect(() => {
|
|
15210
|
+
let closed = false;
|
|
15211
|
+
let handle;
|
|
15212
|
+
const lines = options.lines === void 0 ? void 0 : createLineRegistry(options.lines);
|
|
15213
|
+
const publish = async () => {
|
|
15214
|
+
const directory = options.widgetsDirectory ?? widgetsDirectory();
|
|
15215
|
+
if (directory === void 0) {
|
|
15216
|
+
scope.logger.warn("[dsh-chatgpt-subscription] widget board provider skipped: this package's own package.json could not be found from the loaded module, so the widget directory is unknown.");
|
|
15217
|
+
return;
|
|
15218
|
+
}
|
|
15219
|
+
const registered = await service.registerWidgetType({
|
|
15220
|
+
apiVersion,
|
|
15221
|
+
provider: WIDGET_BOARD_PROVIDER,
|
|
15222
|
+
directory
|
|
15223
|
+
});
|
|
15224
|
+
if (closed) {
|
|
15225
|
+
await registered.dispose().catch(() => void 0);
|
|
15226
|
+
return;
|
|
15227
|
+
}
|
|
15228
|
+
handle = registered;
|
|
15229
|
+
registered.register({
|
|
15230
|
+
dataSources: lineDataSources(apiVersion, lines),
|
|
15231
|
+
...typeof service.registerCapability === "function" ? { capabilities: lineCapabilities(apiVersion, lines) } : {}
|
|
15232
|
+
});
|
|
15233
|
+
};
|
|
15234
|
+
publish().catch((error) => {
|
|
15235
|
+
scope.logger.warn(`[dsh-chatgpt-subscription] widget board registration failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
15236
|
+
});
|
|
15237
|
+
return () => {
|
|
15238
|
+
closed = true;
|
|
15239
|
+
const current = handle;
|
|
15240
|
+
handle = void 0;
|
|
15241
|
+
if (current === void 0) return;
|
|
15242
|
+
current.dispose().catch(() => void 0);
|
|
15243
|
+
};
|
|
15244
|
+
}, "dsh-chatgpt-subscription: widget board provider");
|
|
15245
|
+
});
|
|
15246
|
+
}
|
|
15247
|
+
/**
|
|
15248
|
+
* This package's widget directory, resolved from the loaded module.
|
|
15249
|
+
*
|
|
15250
|
+
* `import.meta.url` points at `lib/index.js` in a built package and at a source
|
|
15251
|
+
* file under `src/` in a test run, so the root is found by walking up to the
|
|
15252
|
+
* `package.json` that names THIS package rather than by assuming a depth.
|
|
15253
|
+
*
|
|
15254
|
+
* @returns The absolute directory, or undefined when no such manifest was found.
|
|
15255
|
+
*/
|
|
15256
|
+
function widgetsDirectory() {
|
|
15257
|
+
const root = findPackageRoot(path.dirname(fileURLToPath(import.meta.url)));
|
|
15258
|
+
return root === void 0 ? void 0 : path.join(root, WIDGET_DIRECTORY_NAME);
|
|
15259
|
+
}
|
|
15260
|
+
/**
|
|
15261
|
+
* Walk up from a directory to the package root of this plugin.
|
|
15262
|
+
*
|
|
15263
|
+
* @param start - Directory to start from.
|
|
15264
|
+
* @returns The directory holding this package's `package.json`, or undefined.
|
|
15265
|
+
*/
|
|
15266
|
+
function findPackageRoot(start) {
|
|
15267
|
+
let directory = start;
|
|
15268
|
+
for (let depth = 0; depth < MAX_ROOT_WALK; depth += 1) {
|
|
15269
|
+
try {
|
|
15270
|
+
if (JSON.parse(readFileSync(path.join(directory, "package.json"), "utf8")).name === PACKAGE_NAME) return directory;
|
|
15271
|
+
} catch {}
|
|
15272
|
+
const parent = path.dirname(directory);
|
|
15273
|
+
if (parent === directory) break;
|
|
15274
|
+
directory = parent;
|
|
15275
|
+
}
|
|
15276
|
+
}
|
|
15277
|
+
/**
|
|
15278
|
+
* The three rotation strategies every line pool accepts, in the pool own
|
|
15279
|
+
* vocabulary.
|
|
15280
|
+
*
|
|
15281
|
+
* The registry re-checks the same list inside its own changeStrategy; this copy
|
|
15282
|
+
* is what lets the capability refuse a bad argument before any pool is touched,
|
|
15283
|
+
* naming the mistake the way the other capability arguments do.
|
|
15284
|
+
*/
|
|
15285
|
+
const ROTATION_STRATEGIES = [
|
|
15286
|
+
"sequential",
|
|
15287
|
+
"round-robin",
|
|
15288
|
+
"sticky"
|
|
15289
|
+
];
|
|
15290
|
+
/**
|
|
15291
|
+
* The `accountId` argument, or a refusal.
|
|
15292
|
+
*
|
|
15293
|
+
* @param args - Capability arguments.
|
|
15294
|
+
* @returns The account id.
|
|
15295
|
+
*/
|
|
15296
|
+
function readAccountId(args) {
|
|
15297
|
+
const accountId = (typeof args === "object" && args !== null ? args : {}).accountId;
|
|
15298
|
+
if (typeof accountId !== "string" || accountId.trim() === "") throw new Error("切换账号需要 accountId 参数。 (Switching accounts needs a non-empty \"accountId\" argument.)");
|
|
15299
|
+
return accountId;
|
|
15300
|
+
}
|
|
15301
|
+
/**
|
|
15302
|
+
* The `strategy` argument, or a refusal.
|
|
15303
|
+
*
|
|
15304
|
+
* @param args - Capability arguments.
|
|
15305
|
+
* @returns The strategy.
|
|
15306
|
+
*/
|
|
15307
|
+
function readStrategy(args) {
|
|
15308
|
+
const strategy = (typeof args === "object" && args !== null ? args : {}).strategy;
|
|
15309
|
+
const match = ROTATION_STRATEGIES.find((candidate) => candidate === strategy);
|
|
15310
|
+
if (match === void 0) throw new Error(`轮换策略只能是 ${ROTATION_STRATEGIES.join(" / ")} 之一,收到的不是其中之一。 (The rotation strategy must be one of ${ROTATION_STRATEGIES.join(", ")}.)`);
|
|
15311
|
+
return match;
|
|
15312
|
+
}
|
|
15313
|
+
/**
|
|
15314
|
+
* The `line-card` data source, when a line registry was handed over.
|
|
15315
|
+
*
|
|
15316
|
+
* This is the whole data side of the board contribution: one source, read per
|
|
15317
|
+
* card, with each card naming its own line. A composition with no registry
|
|
15318
|
+
* registers nothing here rather than falling back to a ChatGPT-only source.
|
|
15319
|
+
*
|
|
15320
|
+
* @param apiVersion - The board's own version, passed through verbatim.
|
|
15321
|
+
* @param lines - The registry, when the composition has one.
|
|
15322
|
+
* @returns The registrations; empty without a registry.
|
|
15323
|
+
*/
|
|
15324
|
+
function lineDataSources(apiVersion, lines) {
|
|
15325
|
+
if (lines === void 0) return [];
|
|
15326
|
+
return [{
|
|
15327
|
+
apiVersion,
|
|
15328
|
+
provider: WIDGET_BOARD_PROVIDER,
|
|
15329
|
+
id: LINE_CARD_DATA_SOURCE_ID,
|
|
15330
|
+
read: (instance) => lines.read(lineOfProps(instance.props))
|
|
15331
|
+
}];
|
|
15332
|
+
}
|
|
15333
|
+
/**
|
|
15334
|
+
* The three line capabilities, when a line registry was handed over.
|
|
15335
|
+
*
|
|
15336
|
+
* Like the data source they declare no `permission`: they force one line's own
|
|
15337
|
+
* quota read or write one line's own pool document, both of which the user is
|
|
15338
|
+
* already looking at on the card they clicked.
|
|
15339
|
+
*
|
|
15340
|
+
* @param apiVersion - The board's own version, passed through verbatim.
|
|
15341
|
+
* @param lines - The registry, when the composition has one.
|
|
15342
|
+
* @returns The registrations; empty without a registry.
|
|
15343
|
+
*/
|
|
15344
|
+
function lineCapabilities(apiVersion, lines) {
|
|
15345
|
+
if (lines === void 0) return [];
|
|
15346
|
+
return [
|
|
15347
|
+
{
|
|
15348
|
+
apiVersion,
|
|
15349
|
+
provider: WIDGET_BOARD_PROVIDER,
|
|
15350
|
+
id: LINE_REFRESH_CAPABILITY_ID,
|
|
15351
|
+
title: {
|
|
15352
|
+
zh: "刷新该线路额度",
|
|
15353
|
+
en: "Refresh this line quota"
|
|
15354
|
+
},
|
|
15355
|
+
description: {
|
|
15356
|
+
zh: "先让这条线路自己向上游读一次额度,再把读到的结果作为整张卡交回。",
|
|
15357
|
+
en: "Ask the line to read its quota upstream, then answer with the refreshed card."
|
|
15358
|
+
},
|
|
15359
|
+
input: LINE_REFRESH_INPUT_SCHEMA,
|
|
15360
|
+
output: LINE_CARD_SCHEMA,
|
|
15361
|
+
call: (_instance, args, signal) => withLine(lines, args, (line) => lines.refresh(line, signal))
|
|
15362
|
+
},
|
|
15363
|
+
{
|
|
15364
|
+
apiVersion,
|
|
15365
|
+
provider: WIDGET_BOARD_PROVIDER,
|
|
15366
|
+
id: LINE_ACCOUNT_SWITCH_CAPABILITY_ID,
|
|
15367
|
+
title: {
|
|
15368
|
+
zh: "切换线路账号",
|
|
15369
|
+
en: "Switch line account"
|
|
15370
|
+
},
|
|
15371
|
+
description: {
|
|
15372
|
+
zh: "把这条线路号池里的某个账号设为主账号,之后的下一次请求就由它来发。",
|
|
15373
|
+
en: "Pin one account of this line as the primary; the next request is spent on it."
|
|
15374
|
+
},
|
|
15375
|
+
input: LINE_ACCOUNT_SWITCH_INPUT_SCHEMA,
|
|
15376
|
+
output: LINE_CARD_SCHEMA,
|
|
15377
|
+
call: (_instance, args, signal) => withLine(lines, args, (line) => lines.switchAccount(line, readAccountId(args), signal))
|
|
15378
|
+
},
|
|
15379
|
+
{
|
|
15380
|
+
apiVersion,
|
|
15381
|
+
provider: WIDGET_BOARD_PROVIDER,
|
|
15382
|
+
id: LINE_ACCOUNT_STRATEGY_CAPABILITY_ID,
|
|
15383
|
+
title: {
|
|
15384
|
+
zh: "线路轮换策略",
|
|
15385
|
+
en: "Line rotation strategy"
|
|
15386
|
+
},
|
|
15387
|
+
description: {
|
|
15388
|
+
zh: "改这条线路号池的选择策略:顺序、轮询或粘住当前账号。",
|
|
15389
|
+
en: "Change how this line s pool picks its next account: sequential, round-robin or sticky."
|
|
15390
|
+
},
|
|
15391
|
+
input: LINE_ACCOUNT_STRATEGY_INPUT_SCHEMA,
|
|
15392
|
+
output: LINE_CARD_SCHEMA,
|
|
15393
|
+
call: (_instance, args, signal) => withLine(lines, args, (line) => lines.changeStrategy(line, readStrategy(args), signal))
|
|
15394
|
+
}
|
|
15395
|
+
];
|
|
15396
|
+
}
|
|
15397
|
+
/**
|
|
15398
|
+
* The `line` argument of one line capability.
|
|
15399
|
+
*
|
|
15400
|
+
* @param args - Capability arguments, already schema-checked by the board.
|
|
15401
|
+
* @returns The line id.
|
|
15402
|
+
*/
|
|
15403
|
+
function readLine(args) {
|
|
15404
|
+
const line = (typeof args === "object" && args !== null ? args : {}).line;
|
|
15405
|
+
if (typeof line !== "string" || line.trim() === "") throw new Error("这条能力需要 line 参数,指明作用在哪条线路上。 (This capability needs a non-empty \"line\" argument.)");
|
|
15406
|
+
return line.trim();
|
|
15407
|
+
}
|
|
15408
|
+
/**
|
|
15409
|
+
* Run one line action, resolving its `line` argument first.
|
|
15410
|
+
*
|
|
15411
|
+
* The check runs before the action so an unknown line fails on the argument
|
|
15412
|
+
* rather than halfway through a write, and so every capability reports the same
|
|
15413
|
+
* sentence for the same mistake.
|
|
15414
|
+
*
|
|
15415
|
+
* @param lines - The registry.
|
|
15416
|
+
* @param args - Capability arguments.
|
|
15417
|
+
* @param run - The action, given the resolved line id.
|
|
15418
|
+
* @returns The refreshed card.
|
|
15419
|
+
*/
|
|
15420
|
+
async function withLine(lines, args, run) {
|
|
15421
|
+
const line = readLine(args);
|
|
15422
|
+
if (!lines.ids().includes(line)) throw new Error("未知线路「" + line + "」:已知的是 " + lines.ids().join(" / ") + "。 (Unknown line; known lines: " + lines.ids().join(", ") + ".)");
|
|
15423
|
+
return await run(line);
|
|
15424
|
+
}
|
|
15425
|
+
//#endregion
|
|
12965
15426
|
//#region src/host/common/quota-refresh.ts
|
|
12966
15427
|
/**
|
|
12967
15428
|
* One quota refresh that runs behind an already-answered status call.
|
|
@@ -66530,6 +68991,37 @@ const WALLET_BALANCE_PATH = "/wallet/balance";
|
|
|
66530
68991
|
*/
|
|
66531
68992
|
const BILLING_SUMMARY_PATH = "/billing/summary";
|
|
66532
68993
|
/**
|
|
68994
|
+
* The workspace the signed-in account is CURRENTLY on.
|
|
68995
|
+
*
|
|
68996
|
+
* This is what the console itself reads to pick "the active workspace": its
|
|
68997
|
+
* response object carries `activeWorkspaceId` beside `workspaceType` and `role`.
|
|
68998
|
+
* It is the first place the coding-plan read looks for an id, because it answers
|
|
68999
|
+
* "where is this account right now" rather than "where could it go".
|
|
69000
|
+
*/
|
|
69001
|
+
const CURRENT_WORKSPACE_PATH = "/workspaces/current";
|
|
69002
|
+
/**
|
|
69003
|
+
* The workspace list: the FALLBACK source of that id.
|
|
69004
|
+
*
|
|
69005
|
+
* `GET {amr}/workspaces` with the same control key answers every workspace the
|
|
69006
|
+
* account can see. It is only consulted when {@link CURRENT_WORKSPACE_PATH} does
|
|
69007
|
+
* not state an id — a listing describes what the account could switch to, while
|
|
69008
|
+
* the coding-plan read needs the one workspace it is on. The device flow returns
|
|
69009
|
+
* neither, so these two reads are the only way to learn an id at all.
|
|
69010
|
+
*/
|
|
69011
|
+
const WORKSPACES_PATH = "/workspaces";
|
|
69012
|
+
/**
|
|
69013
|
+
* The Design Plan's real allowance, per workspace: the 5-hour and 7-day windows
|
|
69014
|
+
* the console draws.
|
|
69015
|
+
*
|
|
69016
|
+
* Appended to `{WORKSPACES_PATH}/{workspaceId}`. It is the only surface of this
|
|
69017
|
+
* line that states a bounded window — `/billing/summary` states credits and the
|
|
69018
|
+
* tier but never a rolling allowance — and it authenticates with the control key
|
|
69019
|
+
* as a Bearer token plus `x-vela-workspace-id`, exactly like the wallet read.
|
|
69020
|
+
* The console itself calls it with a browser session, so a control-key Bearer is
|
|
69021
|
+
* unverified: a refusal is reported as data, never as a reason to guess.
|
|
69022
|
+
*/
|
|
69023
|
+
const CODING_PLAN_USAGE_SUFFIX = "/billing/coding-plan-usage";
|
|
69024
|
+
/**
|
|
66533
69025
|
* What the create call says about this client. The service stores host /
|
|
66534
69026
|
* profile / cliVersion per authorization (its console lists devices by them);
|
|
66535
69027
|
* the profile vocabulary is 'prod' | 'test' | 'feature-test' | 'local' per the
|
|
@@ -68080,6 +70572,391 @@ async function fetchBillingSummary({ fetchFn, controlKey, apiBase = AMR_API_BASE
|
|
|
68080
70572
|
};
|
|
68081
70573
|
}
|
|
68082
70574
|
}
|
|
70575
|
+
/**
|
|
70576
|
+
* The refusal one endpoint stated, as one line.
|
|
70577
|
+
*
|
|
70578
|
+
* The machine `code` comes first because that is the only part a caller can act
|
|
70579
|
+
* on — `coding_plan_configuration_unavailable` means "this workspace has no
|
|
70580
|
+
* coding plan", which is a fact about the account rather than a fault. Both
|
|
70581
|
+
* `{ code }` and `{ error: { code } }` are read (the control plane uses both
|
|
70582
|
+
* spellings across its surfaces), then the usual `error`/`message` prose, then
|
|
70583
|
+
* the raw body. Nothing is invented when the body says nothing: that case stays
|
|
70584
|
+
* empty and the caller prints the status alone.
|
|
70585
|
+
*/
|
|
70586
|
+
function refusalDetail(json, text) {
|
|
70587
|
+
if (isRecord$2(json)) {
|
|
70588
|
+
const code = pickString(json, ["code"]);
|
|
70589
|
+
if (code !== void 0) return code;
|
|
70590
|
+
if (isRecord$2(json.error)) {
|
|
70591
|
+
const nested = pickString(json.error, ["code", "message"]);
|
|
70592
|
+
if (nested !== void 0) return nested;
|
|
70593
|
+
}
|
|
70594
|
+
const message = firstMessage(json);
|
|
70595
|
+
if (message !== "") return message;
|
|
70596
|
+
}
|
|
70597
|
+
return text.trim().slice(0, 120);
|
|
70598
|
+
}
|
|
70599
|
+
/** `HTTP <status>: <code or first message>`, or the bare status when nothing was said. */
|
|
70600
|
+
function httpRefusal(status, json, text) {
|
|
70601
|
+
const detail = refusalDetail(json, text);
|
|
70602
|
+
return detail === "" ? `HTTP ${status}` : `HTTP ${status}: ${detail}`;
|
|
70603
|
+
}
|
|
70604
|
+
/**
|
|
70605
|
+
* The keys a workspace id travels under, in the order the console's payloads
|
|
70606
|
+
* spell them.
|
|
70607
|
+
*
|
|
70608
|
+
* `activeWorkspaceId` is what the current-workspace read answers; `workspaceId`
|
|
70609
|
+
* and `id` are how a listing entry or a nested `workspace` object names the same
|
|
70610
|
+
* fact.
|
|
70611
|
+
*/
|
|
70612
|
+
const WORKSPACE_ID_KEYS = [
|
|
70613
|
+
"activeWorkspaceId",
|
|
70614
|
+
"workspaceId",
|
|
70615
|
+
"id"
|
|
70616
|
+
];
|
|
70617
|
+
/**
|
|
70618
|
+
* One id out of one object: the direct keys first, then a nested `workspace`.
|
|
70619
|
+
*
|
|
70620
|
+
* @param record - Candidate object.
|
|
70621
|
+
* @returns The id, or null when nothing in it states one.
|
|
70622
|
+
*/
|
|
70623
|
+
function workspaceIdIn(record) {
|
|
70624
|
+
const direct = pickString(record, WORKSPACE_ID_KEYS);
|
|
70625
|
+
if (direct !== void 0) return direct;
|
|
70626
|
+
const nested = record.workspace;
|
|
70627
|
+
if (isRecord$2(nested)) return pickString(nested, WORKSPACE_ID_KEYS) ?? null;
|
|
70628
|
+
return null;
|
|
70629
|
+
}
|
|
70630
|
+
/**
|
|
70631
|
+
* The entries a listing endpoint answered, whatever wrapper it used.
|
|
70632
|
+
*
|
|
70633
|
+
* The wrapper is not worth trusting: the same control-plane family answers a bare
|
|
70634
|
+
* array, `{ workspaces }`, `{ items }`, `{ data }` and `{ data: { items | workspaces } }`
|
|
70635
|
+
* on its own different surfaces, and a lookup that guesses one of them reports
|
|
70636
|
+
* "no workspace" on a response that carried several.
|
|
70637
|
+
*
|
|
70638
|
+
* @param body - The decoded response.
|
|
70639
|
+
* @returns The candidate entries; empty when none of the shapes matched.
|
|
70640
|
+
*/
|
|
70641
|
+
function listedWorkspaces(body) {
|
|
70642
|
+
if (Array.isArray(body)) return body;
|
|
70643
|
+
if (!isRecord$2(body)) return [];
|
|
70644
|
+
if (Array.isArray(body.workspaces)) return body.workspaces;
|
|
70645
|
+
if (Array.isArray(body.items)) return body.items;
|
|
70646
|
+
if (Array.isArray(body.data)) return body.data;
|
|
70647
|
+
if (isRecord$2(body.data)) {
|
|
70648
|
+
if (Array.isArray(body.data.items)) return body.data.items;
|
|
70649
|
+
if (Array.isArray(body.data.workspaces)) return body.data.workspaces;
|
|
70650
|
+
}
|
|
70651
|
+
return [];
|
|
70652
|
+
}
|
|
70653
|
+
/**
|
|
70654
|
+
* The first id a listing states.
|
|
70655
|
+
*
|
|
70656
|
+
* @param body - The decoded response.
|
|
70657
|
+
* @returns The id, or null when no entry states one.
|
|
70658
|
+
*/
|
|
70659
|
+
function listedWorkspaceId(body) {
|
|
70660
|
+
for (const entry of listedWorkspaces(body)) {
|
|
70661
|
+
if (!isRecord$2(entry)) continue;
|
|
70662
|
+
const direct = pickString(entry, ["id", "workspaceId"]);
|
|
70663
|
+
if (direct !== void 0) return direct;
|
|
70664
|
+
const nested = isRecord$2(entry.workspace) ? pickString(entry.workspace, ["id", "workspaceId"]) : void 0;
|
|
70665
|
+
if (nested !== void 0) return nested;
|
|
70666
|
+
}
|
|
70667
|
+
return null;
|
|
70668
|
+
}
|
|
70669
|
+
/**
|
|
70670
|
+
* The SHAPE of a response body — never its content.
|
|
70671
|
+
*
|
|
70672
|
+
* Diagnostic only. A failed lookup has to say what the endpoint actually answered
|
|
70673
|
+
* without leaking a single value: the top-level keys, sorted and capped, are the
|
|
70674
|
+
* one thing that makes the next live attempt debuggable while an id, a workspace
|
|
70675
|
+
* name or a token stays out of the log and out of the card.
|
|
70676
|
+
*
|
|
70677
|
+
* @param json - The decoded response.
|
|
70678
|
+
* @returns A key summary such as `keys: activeWorkspaceId, role, workspaceType`.
|
|
70679
|
+
*/
|
|
70680
|
+
function bodyShape(json) {
|
|
70681
|
+
if (Array.isArray(json)) return `a bare array of ${json.length}`;
|
|
70682
|
+
if (!isRecord$2(json)) return "no object body";
|
|
70683
|
+
const keys = Object.keys(json).sort().slice(0, 8);
|
|
70684
|
+
return keys.length === 0 ? "an empty object" : `keys: ${keys.join(", ")}`;
|
|
70685
|
+
}
|
|
70686
|
+
/**
|
|
70687
|
+
* Read the workspace id the coding-plan endpoint is addressed by.
|
|
70688
|
+
*
|
|
70689
|
+
* TWO READS, in this order, because the console itself does not use one:
|
|
70690
|
+
*
|
|
70691
|
+
* 1. `GET {amr}/workspaces/current` — the workspace this account is on right now,
|
|
70692
|
+
* the object the console's own switcher starts from. The id is read from
|
|
70693
|
+
* `activeWorkspaceId`, then `workspaceId`, then `id`, then the same keys under
|
|
70694
|
+
* a nested `workspace`, then the same order again under a `data` wrapper.
|
|
70695
|
+
* 2. Only when that states NO id, `GET {amr}/workspaces` — the listing, which
|
|
70696
|
+
* answers what the account could switch to rather than where it is. Its
|
|
70697
|
+
* first entry with an id wins, and every wrapper shape it has been seen in is
|
|
70698
|
+
* accepted (bare array, `{ workspaces }`, `{ items }`, `{ data }`,
|
|
70699
|
+
* `{ data: { items | workspaces } }`).
|
|
70700
|
+
*
|
|
70701
|
+
* The listing used to be the ONLY read here, and on a live account it answered
|
|
70702
|
+
* 200 with a body whose entries carried no id: the lookup must therefore say
|
|
70703
|
+
* WHICH endpoint failed and what it looked like, or the next attempt is blind.
|
|
70704
|
+
* A failure reports the endpoint, its HTTP status (or the transport error) and a
|
|
70705
|
+
* keys-only shape summary; no value and no id ever appears in it.
|
|
70706
|
+
*
|
|
70707
|
+
* Pure data in, data out: a refusal, a timeout or a malformed body is returned as
|
|
70708
|
+
* `error`, never thrown, and a transport failure on the first read does not stop
|
|
70709
|
+
* the second from answering. The caller composes the result into the account
|
|
70710
|
+
* reading, so one unreadable surface cannot take the whole quota read down.
|
|
70711
|
+
*
|
|
70712
|
+
* @param options.fetchFn - Injected fetch (tests never reach the network).
|
|
70713
|
+
* @param options.controlKey - The OAuth account's `vela_ctrl_…` key.
|
|
70714
|
+
* @param options.apiBase - Control-plane base; overridable for a deployment.
|
|
70715
|
+
* @param options.signal - Caller cancellation.
|
|
70716
|
+
* @param options.timeoutMs - Per-request budget.
|
|
70717
|
+
* @returns The id, or the reason neither endpoint stated one.
|
|
70718
|
+
*/
|
|
70719
|
+
async function fetchWorkspaceId({ fetchFn, controlKey, apiBase = AMR_API_BASE, signal, timeoutMs = 15e3 }) {
|
|
70720
|
+
const base = trimBase$1(apiBase);
|
|
70721
|
+
const notes = [];
|
|
70722
|
+
/** One read, with its outcome kept as a NOTE: a throw here must not hide the next one. */
|
|
70723
|
+
const probe = async (path) => {
|
|
70724
|
+
try {
|
|
70725
|
+
return await sendJSON$1(fetchFn, `${base}${path}`, {
|
|
70726
|
+
apiKey: controlKey,
|
|
70727
|
+
signal,
|
|
70728
|
+
timeoutMs
|
|
70729
|
+
});
|
|
70730
|
+
} catch (error) {
|
|
70731
|
+
notes.push(`GET ${path} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
70732
|
+
return null;
|
|
70733
|
+
}
|
|
70734
|
+
};
|
|
70735
|
+
/** One endpoint's diagnostic line: what was called, what it answered, what shape. */
|
|
70736
|
+
const noteOf = (path, result) => result.ok ? `GET ${path} HTTP ${result.status}, ${bodyShape(result.json)}` : `GET ${path} ${httpRefusal(result.status, result.json, result.text)}, ${bodyShape(result.json)}`;
|
|
70737
|
+
try {
|
|
70738
|
+
const current = await probe(CURRENT_WORKSPACE_PATH);
|
|
70739
|
+
if (current !== null) {
|
|
70740
|
+
if (current.ok) {
|
|
70741
|
+
const body = current.json;
|
|
70742
|
+
const id = isRecord$2(body) ? workspaceIdIn(body) ?? (isRecord$2(body.data) ? workspaceIdIn(body.data) : null) : null;
|
|
70743
|
+
if (id !== null) return {
|
|
70744
|
+
ok: true,
|
|
70745
|
+
id,
|
|
70746
|
+
error: ""
|
|
70747
|
+
};
|
|
70748
|
+
}
|
|
70749
|
+
notes.push(noteOf(CURRENT_WORKSPACE_PATH, current));
|
|
70750
|
+
}
|
|
70751
|
+
const list = await probe(WORKSPACES_PATH);
|
|
70752
|
+
if (list !== null) {
|
|
70753
|
+
if (list.ok) {
|
|
70754
|
+
const id = listedWorkspaceId(list.json);
|
|
70755
|
+
if (id !== null) return {
|
|
70756
|
+
ok: true,
|
|
70757
|
+
id,
|
|
70758
|
+
error: ""
|
|
70759
|
+
};
|
|
70760
|
+
}
|
|
70761
|
+
notes.push(noteOf(WORKSPACES_PATH, list));
|
|
70762
|
+
}
|
|
70763
|
+
return {
|
|
70764
|
+
ok: false,
|
|
70765
|
+
id: null,
|
|
70766
|
+
error: `the workspaces endpoint listed no workspace id (${notes.join("; ")})`
|
|
70767
|
+
};
|
|
70768
|
+
} catch (error) {
|
|
70769
|
+
return {
|
|
70770
|
+
ok: false,
|
|
70771
|
+
id: null,
|
|
70772
|
+
error: error instanceof Error ? error.message : String(error)
|
|
70773
|
+
};
|
|
70774
|
+
}
|
|
70775
|
+
}
|
|
70776
|
+
/** The tiers this line knows how to name; anything else is "unknown", never a guess. */
|
|
70777
|
+
const CODING_PLAN_TIERS = [
|
|
70778
|
+
"go",
|
|
70779
|
+
"plus",
|
|
70780
|
+
"pro",
|
|
70781
|
+
"max"
|
|
70782
|
+
];
|
|
70783
|
+
/**
|
|
70784
|
+
* One window read strictly: a field that does not state what the card needs
|
|
70785
|
+
* makes the whole window unreadable, and an unreadable window is dropped rather
|
|
70786
|
+
* than patched with a plausible default. A guessed `limitCredits` is a made-up
|
|
70787
|
+
* denominator on a progress bar, which is precisely the failure this contract
|
|
70788
|
+
* exists to prevent.
|
|
70789
|
+
*
|
|
70790
|
+
* @param raw - One entry of the response's `windows`.
|
|
70791
|
+
* @returns The window, or null when any field it needs was not stated.
|
|
70792
|
+
*/
|
|
70793
|
+
function codingPlanWindowOf(raw) {
|
|
70794
|
+
if (!isRecord$2(raw)) return null;
|
|
70795
|
+
const policyId = pickString(raw, ["policyId"]);
|
|
70796
|
+
if (policyId === void 0) return null;
|
|
70797
|
+
const durationSeconds = raw.durationSeconds;
|
|
70798
|
+
if (typeof durationSeconds !== "number" || !Number.isSafeInteger(durationSeconds) || durationSeconds <= 0) return null;
|
|
70799
|
+
const remainingCredits = raw.remainingCredits;
|
|
70800
|
+
if (typeof remainingCredits !== "string" || !/^\d+$/.test(remainingCredits)) return null;
|
|
70801
|
+
const limitCredits = raw.limitCredits;
|
|
70802
|
+
if (typeof limitCredits !== "string" || !/^\d+$/.test(limitCredits)) return null;
|
|
70803
|
+
const limit = Number(limitCredits);
|
|
70804
|
+
if (!Number.isFinite(limit) || limit <= 0) return null;
|
|
70805
|
+
if (!Number.isFinite(Number(remainingCredits))) return null;
|
|
70806
|
+
const reset = codingPlanResetOf(raw.resetsAt);
|
|
70807
|
+
if (reset === void 0) return null;
|
|
70808
|
+
return {
|
|
70809
|
+
policyId,
|
|
70810
|
+
durationSeconds,
|
|
70811
|
+
remainingCredits,
|
|
70812
|
+
limitCredits,
|
|
70813
|
+
resetsAt: reset
|
|
70814
|
+
};
|
|
70815
|
+
}
|
|
70816
|
+
/**
|
|
70817
|
+
* A window's reset instant as the DTO states it.
|
|
70818
|
+
*
|
|
70819
|
+
* Absent and `null` both mean "the service stated no reset" and are kept as
|
|
70820
|
+
* null; a string has to parse as an instant, and anything else (a number, a
|
|
70821
|
+
* sentence) is `undefined` — the sentinel that makes the window unreadable.
|
|
70822
|
+
*
|
|
70823
|
+
* @param value - The raw field.
|
|
70824
|
+
* @returns The ISO string, null for "not stated", or undefined for "unreadable".
|
|
70825
|
+
*/
|
|
70826
|
+
function codingPlanResetOf(value) {
|
|
70827
|
+
if (value === null || value === void 0) return null;
|
|
70828
|
+
if (typeof value !== "string") return void 0;
|
|
70829
|
+
const trimmed = value.trim();
|
|
70830
|
+
return Number.isFinite(Date.parse(trimmed)) ? trimmed : void 0;
|
|
70831
|
+
}
|
|
70832
|
+
/**
|
|
70833
|
+
* The credential shapes to try, in order, for one coding-plan read.
|
|
70834
|
+
*
|
|
70835
|
+
* The console signs this call with a browser SESSION (`credentials: "include"`),
|
|
70836
|
+
* so which of this line's two keys the endpoint accepts is not public: a control
|
|
70837
|
+
* key returned 401 live, which is exactly the ambiguity this list settles by
|
|
70838
|
+
* trying rather than by guessing. The order is "most likely intended first":
|
|
70839
|
+
* the control key already reads the rest of the control plane, the runtime key is
|
|
70840
|
+
* the gateway's own, and `x-api-key` is the spelling some routes use instead of
|
|
70841
|
+
* a Bearer.
|
|
70842
|
+
*
|
|
70843
|
+
* A runtime key of `''` (a pasted key account, or a credential that never got
|
|
70844
|
+
* one) leaves the first shape alone rather than sending an empty header.
|
|
70845
|
+
*
|
|
70846
|
+
* @param controlKey - The account's `vela_ctrl_…` key.
|
|
70847
|
+
* @param runtimeKey - The account's `sk-amr-…` key, or ''.
|
|
70848
|
+
* @returns The shapes to try, one to three.
|
|
70849
|
+
*/
|
|
70850
|
+
function codingPlanAttempts(controlKey, runtimeKey) {
|
|
70851
|
+
const attempts = [{
|
|
70852
|
+
label: "bearer control key",
|
|
70853
|
+
apiKey: controlKey,
|
|
70854
|
+
headers: {}
|
|
70855
|
+
}];
|
|
70856
|
+
const runtime = runtimeKey.trim();
|
|
70857
|
+
if (runtime === "") return attempts;
|
|
70858
|
+
attempts.push({
|
|
70859
|
+
label: "bearer runtime key",
|
|
70860
|
+
apiKey: runtime,
|
|
70861
|
+
headers: {}
|
|
70862
|
+
});
|
|
70863
|
+
attempts.push({
|
|
70864
|
+
label: "x-api-key runtime key",
|
|
70865
|
+
apiKey: "",
|
|
70866
|
+
headers: { "x-api-key": runtime }
|
|
70867
|
+
});
|
|
70868
|
+
return attempts;
|
|
70869
|
+
}
|
|
70870
|
+
/**
|
|
70871
|
+
* A successful response body as the DTO.
|
|
70872
|
+
*
|
|
70873
|
+
* @param json - The decoded body.
|
|
70874
|
+
* @returns The plan, with `error` empty and only the windows that survived.
|
|
70875
|
+
*/
|
|
70876
|
+
function codingPlanOf(json) {
|
|
70877
|
+
const body = isRecord$2(json) ? json : {};
|
|
70878
|
+
const windows = [];
|
|
70879
|
+
for (const row of Array.isArray(body.windows) ? body.windows : []) {
|
|
70880
|
+
const window = codingPlanWindowOf(row);
|
|
70881
|
+
if (window !== null) windows.push(window);
|
|
70882
|
+
}
|
|
70883
|
+
const tierText = typeof body.tier === "string" ? body.tier.trim().toLowerCase() : "";
|
|
70884
|
+
return {
|
|
70885
|
+
tier: CODING_PLAN_TIERS.includes(tierText) ? tierText : null,
|
|
70886
|
+
windows,
|
|
70887
|
+
error: ""
|
|
70888
|
+
};
|
|
70889
|
+
}
|
|
70890
|
+
/**
|
|
70891
|
+
* Read one account's Design Plan allowance.
|
|
70892
|
+
*
|
|
70893
|
+
* This is the line's REAL quota surface — the 5-hour and 7-day windows the
|
|
70894
|
+
* console draws as "剩余 90% / 81%" — and it is the only endpoint that states a
|
|
70895
|
+
* bounded window at all: `/billing/summary` states credits and a tier, never a
|
|
70896
|
+
* rolling allowance.
|
|
70897
|
+
*
|
|
70898
|
+
* `GET {amr}/workspaces/{id}/billing/coding-plan-usage`, always the same URL with
|
|
70899
|
+
* the same `x-vela-workspace-id`, tried once per credential SHAPE (see
|
|
70900
|
+
* {@link codingPlanAttempts}). The retry rule is the important half: only
|
|
70901
|
+
* `401` / `403` — "these credentials are not accepted" — moves on to the next
|
|
70902
|
+
* shape, because that is the only verdict a different key could change. A `404` or
|
|
70903
|
+
* a `500`, and in particular `coding_plan_configuration_unavailable`, is the
|
|
70904
|
+
* endpoint's own verdict about the WORKSPACE, so it stops the search and travels
|
|
70905
|
+
* out verbatim.
|
|
70906
|
+
*
|
|
70907
|
+
* Exhausting every shape yields one sentence a user can act on: the last refusal
|
|
70908
|
+
* plus the shapes that were tried. A success carries NO trace of which credential
|
|
70909
|
+
* worked — the shape of a credential is not the card's business.
|
|
70910
|
+
*
|
|
70911
|
+
* It never throws and never fills a window nobody measured.
|
|
70912
|
+
*
|
|
70913
|
+
* @param options.fetchFn - Injected fetch.
|
|
70914
|
+
* @param options.controlKey - The account's `vela_ctrl_…` key.
|
|
70915
|
+
* @param options.runtimeKey - The account's `sk-amr-…` key; `''` skips the two
|
|
70916
|
+
* shapes that need one.
|
|
70917
|
+
* @param options.workspaceId - From {@link fetchWorkspaceId}.
|
|
70918
|
+
* @param options.apiBase - Control-plane base.
|
|
70919
|
+
* @param options.signal - Caller cancellation.
|
|
70920
|
+
* @param options.timeoutMs - Per-request budget.
|
|
70921
|
+
* @returns The plan, or the reason every shape it knew failed.
|
|
70922
|
+
*/
|
|
70923
|
+
async function fetchCodingPlanUsage({ fetchFn, controlKey, runtimeKey, workspaceId, apiBase = AMR_API_BASE, signal, timeoutMs = 15e3 }) {
|
|
70924
|
+
const url = `${trimBase$1(apiBase)}${WORKSPACES_PATH}/${encodeURIComponent(workspaceId)}${CODING_PLAN_USAGE_SUFFIX}`;
|
|
70925
|
+
const tried = [];
|
|
70926
|
+
let refusal = "";
|
|
70927
|
+
try {
|
|
70928
|
+
for (const attempt of codingPlanAttempts(controlKey, runtimeKey)) {
|
|
70929
|
+
const result = await sendJSON$1(fetchFn, url, {
|
|
70930
|
+
apiKey: attempt.apiKey,
|
|
70931
|
+
signal,
|
|
70932
|
+
timeoutMs,
|
|
70933
|
+
headers: {
|
|
70934
|
+
"x-vela-workspace-id": workspaceId,
|
|
70935
|
+
...attempt.headers
|
|
70936
|
+
}
|
|
70937
|
+
});
|
|
70938
|
+
if (result.ok) return codingPlanOf(result.json);
|
|
70939
|
+
refusal = httpRefusal(result.status, result.json, result.text);
|
|
70940
|
+
if (result.status !== 401 && result.status !== 403) return {
|
|
70941
|
+
tier: null,
|
|
70942
|
+
windows: [],
|
|
70943
|
+
error: refusal
|
|
70944
|
+
};
|
|
70945
|
+
tried.push(attempt.label);
|
|
70946
|
+
}
|
|
70947
|
+
return {
|
|
70948
|
+
tier: null,
|
|
70949
|
+
windows: [],
|
|
70950
|
+
error: (refusal === "" ? "the coding plan could not be read" : refusal) + ` (tried: ${tried.join("; ")})`
|
|
70951
|
+
};
|
|
70952
|
+
} catch (error) {
|
|
70953
|
+
return {
|
|
70954
|
+
tier: null,
|
|
70955
|
+
windows: [],
|
|
70956
|
+
error: error instanceof Error ? error.message : String(error)
|
|
70957
|
+
};
|
|
70958
|
+
}
|
|
70959
|
+
}
|
|
68083
70960
|
//#endregion
|
|
68084
70961
|
//#region src/host/opendesign/mapper.ts
|
|
68085
70962
|
/**
|
|
@@ -69800,10 +72677,45 @@ function registerOpenDesignRoutes(ctx, options) {
|
|
|
69800
72677
|
ok: false,
|
|
69801
72678
|
error: "No OAuth-signed-in account can be read. Plan, credits and wallet balances come from the control key a browser sign-in issues; a pasted API key has none."
|
|
69802
72679
|
});
|
|
72680
|
+
/**
|
|
72681
|
+
* The workspace id, resolved ONCE for this request and reused.
|
|
72682
|
+
*
|
|
72683
|
+
* Every account here asks the same control-plane question — "which
|
|
72684
|
+
* workspace is this key signed into" — and the coding-plan read needs
|
|
72685
|
+
* the answer only as an address. The first account to need it settles
|
|
72686
|
+
* the lookup; the rest await that same promise, so a request with four
|
|
72687
|
+
* readable accounts makes one `/workspaces/current` call rather than four.
|
|
72688
|
+
*
|
|
72689
|
+
* The settled answer is reused even when it FAILED: every account then
|
|
72690
|
+
* carries the same `error` in its own `codingPlan`, which is what a
|
|
72691
|
+
* user needs to see ("the workspace cannot be resolved") instead of
|
|
72692
|
+
* four identical timeouts. A failure never reaches the reading itself —
|
|
72693
|
+
* the wallet and billing halves are unaffected.
|
|
72694
|
+
*/
|
|
72695
|
+
let workspaceLookup = null;
|
|
72696
|
+
const readCodingPlan = async (controlKey, runtimeKey) => {
|
|
72697
|
+
if (workspaceLookup === null) workspaceLookup = fetchWorkspaceId({
|
|
72698
|
+
fetchFn,
|
|
72699
|
+
controlKey
|
|
72700
|
+
});
|
|
72701
|
+
const workspace = await workspaceLookup;
|
|
72702
|
+
if (!workspace.ok || workspace.id === null) return {
|
|
72703
|
+
tier: null,
|
|
72704
|
+
windows: [],
|
|
72705
|
+
error: workspace.error === "" ? "no workspace id for the coding-plan read" : workspace.error
|
|
72706
|
+
};
|
|
72707
|
+
return await fetchCodingPlanUsage({
|
|
72708
|
+
fetchFn,
|
|
72709
|
+
controlKey,
|
|
72710
|
+
runtimeKey,
|
|
72711
|
+
workspaceId: workspace.id
|
|
72712
|
+
});
|
|
72713
|
+
};
|
|
69803
72714
|
const readings = await Promise.all(readable.map(async (account) => {
|
|
69804
72715
|
const fetchedAt = Date.now();
|
|
69805
72716
|
try {
|
|
69806
|
-
const
|
|
72717
|
+
const credentials = await accountPool.getFreshCredential(account.id, fetchFn);
|
|
72718
|
+
const controlKey = controlKeyFor(credentials);
|
|
69807
72719
|
if (controlKey === void 0) return {
|
|
69808
72720
|
accountId: account.id,
|
|
69809
72721
|
alias: account.alias,
|
|
@@ -69811,15 +72723,24 @@ function registerOpenDesignRoutes(ctx, options) {
|
|
|
69811
72723
|
balanceUsd: null,
|
|
69812
72724
|
updatedAt: null,
|
|
69813
72725
|
error: "no control key on this account",
|
|
69814
|
-
fetchedAt
|
|
72726
|
+
fetchedAt,
|
|
72727
|
+
codingPlan: {
|
|
72728
|
+
tier: null,
|
|
72729
|
+
windows: [],
|
|
72730
|
+
error: "no control key on this account"
|
|
72731
|
+
}
|
|
69815
72732
|
};
|
|
69816
|
-
const [wallet, billing] = await Promise.all([
|
|
69817
|
-
|
|
69818
|
-
|
|
69819
|
-
|
|
69820
|
-
|
|
69821
|
-
|
|
69822
|
-
|
|
72733
|
+
const [wallet, billing, codingPlan] = await Promise.all([
|
|
72734
|
+
fetchWalletBalance({
|
|
72735
|
+
fetchFn,
|
|
72736
|
+
controlKey
|
|
72737
|
+
}),
|
|
72738
|
+
fetchBillingSummary({
|
|
72739
|
+
fetchFn,
|
|
72740
|
+
controlKey
|
|
72741
|
+
}),
|
|
72742
|
+
readCodingPlan(controlKey, bearerFor(credentials))
|
|
72743
|
+
]);
|
|
69823
72744
|
const balanceUsd = wallet.balanceUsd ?? billing.billing?.totalAvailableCreditsUsd ?? null;
|
|
69824
72745
|
return {
|
|
69825
72746
|
accountId: account.id,
|
|
@@ -69829,7 +72750,8 @@ function registerOpenDesignRoutes(ctx, options) {
|
|
|
69829
72750
|
updatedAt: wallet.updatedAt,
|
|
69830
72751
|
error: wallet.error,
|
|
69831
72752
|
fetchedAt,
|
|
69832
|
-
...billing.billing === null ? {} : { billing: billing.billing }
|
|
72753
|
+
...billing.billing === null ? {} : { billing: billing.billing },
|
|
72754
|
+
codingPlan
|
|
69833
72755
|
};
|
|
69834
72756
|
} catch (error) {
|
|
69835
72757
|
return {
|
|
@@ -69839,7 +72761,12 @@ function registerOpenDesignRoutes(ctx, options) {
|
|
|
69839
72761
|
balanceUsd: null,
|
|
69840
72762
|
updatedAt: null,
|
|
69841
72763
|
error: firstLine(error instanceof Error ? error.message : error, 240),
|
|
69842
|
-
fetchedAt
|
|
72764
|
+
fetchedAt,
|
|
72765
|
+
codingPlan: {
|
|
72766
|
+
tier: null,
|
|
72767
|
+
windows: [],
|
|
72768
|
+
error: "the account could not be read, so its coding plan was not read either"
|
|
72769
|
+
}
|
|
69843
72770
|
};
|
|
69844
72771
|
}
|
|
69845
72772
|
}));
|
|
@@ -73741,7 +76668,8 @@ const DISABLED_OUTPUT_TEXT = "Progress updates are disabled; the update was not
|
|
|
73741
76668
|
/** 工具描述的最终全文。 */
|
|
73742
76669
|
const PROGRESS_UPDATE_DESCRIPTION = [
|
|
73743
76670
|
"Report progress to the user without ending your turn.",
|
|
73744
|
-
"
|
|
76671
|
+
"Two updates are part of the job, not optional narration: send one BEFORE your first tool call of a multi-step task (the plan), and one BEFORE your final answer (what was done and what was verified).",
|
|
76672
|
+
"Use it during the rest of the work so the user can follow along and correct course early:",
|
|
73745
76673
|
"state your plan near the beginning of the task (what you will do, and in what order);",
|
|
73746
76674
|
"report a meaningful conclusion the moment you have it (a key fact, the root cause, a verified assumption) rather than narrating every step;",
|
|
73747
76675
|
"give a stage judgement when one phase ends and the next begins (where things stand, what happens next);",
|
|
@@ -73894,6 +76822,13 @@ function stringOf$1(value) {
|
|
|
73894
76822
|
* 文本用函数形式提供(而不是静态字符串):开关关闭时返回空串,装配期就地
|
|
73895
76823
|
* 抹掉引导,不需要重启会话、也不需要重挂插件行。
|
|
73896
76824
|
*
|
|
76825
|
+
* ## 为什么是英文
|
|
76826
|
+
*
|
|
76827
|
+
* 提示词是写给模型看的,不是写给用户看的:同一段要求在英文下最短、歧义最少,
|
|
76828
|
+
* 也和工具描述(`tool.ts` 的 `PROGRESS_UPDATE_DESCRIPTION`,本来就是英文)保持
|
|
76829
|
+
* 同一套措辞。用户看到的语言由 {@link progressLanguageDirective} 单独钉住——
|
|
76830
|
+
* 「指令用英文」与「输出用中文」是两件事,不要混在一起。
|
|
76831
|
+
*
|
|
73897
76832
|
* @module dsh-chatgpt-subscription/host/progress-updates/prompt
|
|
73898
76833
|
*/
|
|
73899
76834
|
/**
|
|
@@ -73903,38 +76838,70 @@ function stringOf$1(value) {
|
|
|
73903
76838
|
* 而变成第二个分节。
|
|
73904
76839
|
*/
|
|
73905
76840
|
const PROGRESS_SECTION_NAME = "plugin:progress-updates";
|
|
76841
|
+
/** 允许的取值,供设置解析做白名单校验(配置里写错就等于没写)。 */
|
|
76842
|
+
const PROGRESS_UPDATE_LANGUAGES = [
|
|
76843
|
+
"auto",
|
|
76844
|
+
"zh",
|
|
76845
|
+
"en"
|
|
76846
|
+
];
|
|
73906
76847
|
/**
|
|
73907
76848
|
* 引导词正文。写法对齐 Kimi 的「进展更新」:先说清多步任务早期的思路,再报
|
|
73908
76849
|
* 有意义的结论、阶段判断、长等待与阻塞;每条给一行标题,细节放正文;不重复
|
|
73909
|
-
*
|
|
76850
|
+
* 没有变化的状态。开头与结尾两条被提成硬要求——没有它们,面板在任务的两端
|
|
76851
|
+
* 都是空的。
|
|
73910
76852
|
*/
|
|
73911
76853
|
const PROGRESS_GUIDANCE = [
|
|
73912
|
-
"##
|
|
76854
|
+
"## Progress updates",
|
|
76855
|
+
"",
|
|
76856
|
+
"During multi-step work you can call `progress_update` to report progress without ending your turn and without spending a question: report, then keep working.",
|
|
76857
|
+
"",
|
|
76858
|
+
"**Two updates are mandatory — missing either one means the job is not finished:**",
|
|
73913
76859
|
"",
|
|
73914
|
-
"
|
|
76860
|
+
"- **Opening**: before the first tool call of the task, send one update stating the plan (which steps, what you touch first). The user must be able to redirect you before you disappear into execution.",
|
|
76861
|
+
"- **Closing**: after the work is done and before your final answer, send one update stating the result (what changed, what was verified, what is left). The panel and the conversation both close on this line.",
|
|
73915
76862
|
"",
|
|
73916
|
-
"
|
|
73917
|
-
"-
|
|
73918
|
-
"-
|
|
73919
|
-
"-
|
|
73920
|
-
"-
|
|
73921
|
-
"-
|
|
76863
|
+
"Beyond those two, these moments are worth a report:",
|
|
76864
|
+
"- **Early planning**: the opening update covers it; report again only when the plan really changes (different approach, smaller scope, a broken premise).",
|
|
76865
|
+
"- **A meaningful conclusion**: a key fact, a root cause, a verified assumption — report the conclusion itself, not \"I just read a file\".",
|
|
76866
|
+
"- **A stage judgement**: one phase ends and the next begins — one line on where things stand and what happens next.",
|
|
76867
|
+
"- **Before a long wait**: a test run, an install, a build — say what you are waiting for and roughly how long.",
|
|
76868
|
+
"- **When blocked**: missing information, missing permission, an external failure — say where you are stuck, what you will try next, or what you need from the user.",
|
|
73922
76869
|
"",
|
|
73923
|
-
"
|
|
73924
|
-
"-
|
|
73925
|
-
"-
|
|
73926
|
-
"-
|
|
73927
|
-
"-
|
|
76870
|
+
"How to write one:",
|
|
76871
|
+
"- One short title line (plain text, no Markdown, no trailing period); the details go in the message body.",
|
|
76872
|
+
"- The body uses light Markdown: one paragraph of prose plus a few bullets. **When several rows share fields, use a GFM pipe table** (header row plus a `| --- |` separator row) instead of one item per line.",
|
|
76873
|
+
"- Do not repeat a status that has not changed: if the last update already said it is running, it needs no second report.",
|
|
76874
|
+
"- A progress update is for the user, not for the log: write it in human words, do not restate tool arguments.",
|
|
73928
76875
|
"",
|
|
73929
|
-
"
|
|
76876
|
+
"When you run as a subagent: describe only your own subtask progress. Finishing your subtask is not the same as finishing the overall task — do not report the overall progress as done."
|
|
73930
76877
|
].join("\n");
|
|
73931
76878
|
/**
|
|
76879
|
+
* 每个语言档位对应的一句指令。
|
|
76880
|
+
*
|
|
76881
|
+
* 只约束输出的标题与正文,不约束引导词本身;三条都必须自洽——`auto` 不许在
|
|
76882
|
+
* 用户说中文时滑回英文,`zh`/`en` 则明确压过对话语言。
|
|
76883
|
+
*/
|
|
76884
|
+
const PROGRESS_LANGUAGE_DIRECTIVES = {
|
|
76885
|
+
auto: "Write the title and the message of every progress update in the language the user is writing in, whatever it is. Follow the conversation; do not fall back to English unless the user does.",
|
|
76886
|
+
zh: "Write the title and the message of every progress update in Simplified Chinese, even when the conversation is in another language.",
|
|
76887
|
+
en: "Write the title and the message of every progress update in English, even when the conversation is in another language."
|
|
76888
|
+
};
|
|
76889
|
+
/**
|
|
76890
|
+
* 取一个语言档位的指令文本;未知取值按 `auto` 处理。
|
|
76891
|
+
* @param language - 配置里读到的语言档位。
|
|
76892
|
+
* @returns 追加到引导词末尾的一句指令。
|
|
76893
|
+
*/
|
|
76894
|
+
function progressLanguageDirective(language) {
|
|
76895
|
+
return PROGRESS_LANGUAGE_DIRECTIVES[language] ?? PROGRESS_LANGUAGE_DIRECTIVES.auto;
|
|
76896
|
+
}
|
|
76897
|
+
/**
|
|
73932
76898
|
* 生成提示词分节的文本提供者。
|
|
73933
76899
|
* @param isEnabled - 读取当前开关状态的函数(每次装配重新求值)。
|
|
73934
|
-
* @
|
|
76900
|
+
* @param language - 读取当前输出语言的函数;缺省按 `auto` 处理。
|
|
76901
|
+
* @returns 开关打开时返回引导词加语言指令,关闭时返回空串。
|
|
73935
76902
|
*/
|
|
73936
|
-
function progressSectionText(isEnabled) {
|
|
73937
|
-
return () => isEnabled() ? PROGRESS_GUIDANCE : "";
|
|
76903
|
+
function progressSectionText(isEnabled, language = () => "auto") {
|
|
76904
|
+
return () => isEnabled() ? PROGRESS_GUIDANCE + "\n\n" + progressLanguageDirective(language()) : "";
|
|
73938
76905
|
}
|
|
73939
76906
|
//#endregion
|
|
73940
76907
|
//#region src/host/progress-updates/projection.ts
|
|
@@ -74261,9 +77228,11 @@ function readProgressConfigField(field) {
|
|
|
74261
77228
|
function resolveProgressUpdatesSettings(config) {
|
|
74262
77229
|
const enabled = readProgressConfigField(config?.progressUpdates);
|
|
74263
77230
|
const threshold = readProgressConfigField(config?.progressUpdatesNudgeThreshold);
|
|
77231
|
+
const language = readProgressConfigField(config?.progressUpdatesLanguage);
|
|
74264
77232
|
return {
|
|
74265
77233
|
enabled: enabled !== false,
|
|
74266
|
-
nudgeThreshold: typeof threshold === "number" && Number.isSafeInteger(threshold) && threshold > 0 ? threshold : 8
|
|
77234
|
+
nudgeThreshold: typeof threshold === "number" && Number.isSafeInteger(threshold) && threshold > 0 ? threshold : 8,
|
|
77235
|
+
language: language !== void 0 && PROGRESS_UPDATE_LANGUAGES.includes(language) ? language : "auto"
|
|
74267
77236
|
};
|
|
74268
77237
|
}
|
|
74269
77238
|
/** 血缘回溯的深度上限:与 `nudge.ts` 的 `counterSessionIdOf` 同一个数。 */
|
|
@@ -74521,7 +77490,7 @@ function installProgressUpdates(ctx, options) {
|
|
|
74521
77490
|
return systemPrompt.section({
|
|
74522
77491
|
name: PROGRESS_SECTION_NAME,
|
|
74523
77492
|
order: 220,
|
|
74524
|
-
text: progressSectionText(() => options.settings().enabled)
|
|
77493
|
+
text: progressSectionText(() => options.settings().enabled, () => options.settings().language)
|
|
74525
77494
|
});
|
|
74526
77495
|
}, "dsh-chatgpt-subscription: progress update prompt section");
|
|
74527
77496
|
track(() => {
|
|
@@ -74577,7 +77546,12 @@ const Config = z.object({
|
|
|
74577
77546
|
noAnswerResume: z.union([z.object({}), z.const(false)]).default({}),
|
|
74578
77547
|
codexStreamIdleTimeoutMs: z.number().min(1).max(MAX_TIMER_DELAY_MS).step(1).default(DEFAULT_STREAM_IDLE_TIMEOUT_MS$2),
|
|
74579
77548
|
progressUpdates: z.boolean().default(true).volatile().description("启用进展更新:允许 agent 用 progress_update 工具主动汇报多步任务的进展,并在侧栏展示。"),
|
|
74580
|
-
progressUpdatesNudgeThreshold: z.number().step(1).min(1).max(50).default(8).volatile().description("催促阈值:连续这么多轮带工具调用却没有进展更新时,提醒 agent 发一条进展。")
|
|
77549
|
+
progressUpdatesNudgeThreshold: z.number().step(1).min(1).max(50).default(8).volatile().description("催促阈值:连续这么多轮带工具调用却没有进展更新时,提醒 agent 发一条进展。"),
|
|
77550
|
+
progressUpdatesLanguage: z.union([
|
|
77551
|
+
z.const("auto"),
|
|
77552
|
+
z.const("zh"),
|
|
77553
|
+
z.const("en")
|
|
77554
|
+
]).default("auto").volatile().description("进展更新的输出语言:auto 跟随对话语言,zh 中文,en 英文。")
|
|
74581
77555
|
});
|
|
74582
77556
|
const inject = [
|
|
74583
77557
|
"webServer",
|
|
@@ -74873,6 +77847,35 @@ function apply(ctx, pluginConfig = {}) {
|
|
|
74873
77847
|
const snapshot = usage.snapshotFor(account.id, account.credentials);
|
|
74874
77848
|
return snapshot === void 0 ? void 0 : codexAccountQuota(snapshot);
|
|
74875
77849
|
});
|
|
77850
|
+
registerWidgetBoard(ctx, { lines: {
|
|
77851
|
+
origin: () => webServerOrigin(ctx.webServer),
|
|
77852
|
+
pools: {
|
|
77853
|
+
codex: codexAccountPool,
|
|
77854
|
+
claude: claudeAccountPool,
|
|
77855
|
+
"glm-coding-plan": glmCodingPlanAccountPool,
|
|
77856
|
+
"step-code": stepCodeAccountPool,
|
|
77857
|
+
qoder: qoderAccountPool,
|
|
77858
|
+
"cline-pass": clinePassLine.accountPool,
|
|
77859
|
+
opendesign: openDesignLine.accountPool,
|
|
77860
|
+
"kimi-code": kimiCodeAccountPool,
|
|
77861
|
+
"minimax-code": minimaxCodeAccountPool,
|
|
77862
|
+
antigravity: antigravityAccountPool,
|
|
77863
|
+
"command-code": commandCodeAccountPool
|
|
77864
|
+
},
|
|
77865
|
+
accountQuotaClients: {
|
|
77866
|
+
qoder: async (credentials, { fetchFn, accountId }) => {
|
|
77867
|
+
const read = await loadQoderQuota(credentials, {
|
|
77868
|
+
fetchFn,
|
|
77869
|
+
cacheKey: accountId
|
|
77870
|
+
});
|
|
77871
|
+
return read.ok ? read.snapshot : null;
|
|
77872
|
+
},
|
|
77873
|
+
"minimax-code": async (credentials, { fetchFn, accountId }) => await fetchTokenPlanQuota(credentials, {
|
|
77874
|
+
fetchFn,
|
|
77875
|
+
accountId
|
|
77876
|
+
})
|
|
77877
|
+
}
|
|
77878
|
+
} });
|
|
74876
77879
|
const adapter = new CodexChatGptAdapter(new ResponsesClient(oauth, ctx.attachments, {
|
|
74877
77880
|
fetchFn: modelFetch["codex-chatgpt"],
|
|
74878
77881
|
accountPool: codexAccountPool,
|