dsh-connect-workbuddy 3.1.0 → 3.4.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.
@@ -416,6 +416,19 @@ const CLIENT_UA = "CLI/2.63.2 CodeBuddy/2.63.2";
416
416
  const DESKTOP_UA = "WorkBuddy/5.5.2";
417
417
  const JSON_TIMEOUT_MS = 3e4;
418
418
  const ERROR_BODY_LIMIT = 4096;
419
+ /**
420
+ * Ceiling for one real-volume probe to get a RESPONSE, which is not a metadata
421
+ * call.
422
+ *
423
+ * Much larger than {@link JSON_TIMEOUT_MS} on purpose: a probe posts ~25k input
424
+ * tokens, so prompt processing legitimately takes a while before the first byte
425
+ * comes back. It exists only to stop a connection that will NEVER answer — the
426
+ * pool's batch is serial, so one hung member would otherwise block all the rest
427
+ * with nothing in the UI to say which one it was. Ten seconds is comfortably
428
+ * above a working gateway's first byte and far below a user's patience; the
429
+ * budget covers the response only, never the one-line body that follows.
430
+ */
431
+ const PROBE_TIMEOUT_MS = 1e4;
419
432
  /** Insufficient-credit markers, ASCII lowercase plus the original Chinese. */
420
433
  const HARD_CREDIT_MARKERS = [
421
434
  "insufficient credit",
@@ -637,6 +650,67 @@ function normalizeToolChoice(obj) {
637
650
  delete obj["tool_choice"];
638
651
  }
639
652
  /**
653
+ * What a PREPARED request body declares, as the input to DSML recovery.
654
+ *
655
+ * This exists so the recovery path can answer the one question its gates turn
656
+ * on — "was this tool name offered in THIS request?" — without a second parse
657
+ * of the body and without a second source of truth. `prepareChatBody` has
658
+ * already parsed it; this reads the same prepared JSON.
659
+ *
660
+ * Two facts make it cheap and exact:
661
+ *
662
+ * - `tool_choice: "none"` deletes `tools` outright in
663
+ * {@link normalizeToolChoice}, so "no tools declared" is directly
664
+ * observable here rather than a separate condition to remember;
665
+ * - a pinned `tool_choice` arrives as the bare function name (the object form
666
+ * was flattened), so anything that is not `auto`/`required`/`none` is a pin.
667
+ *
668
+ * Returns `undefined` when the body declares nothing usable — malformed JSON,
669
+ * no `tools` array, or an empty one. Callers must read that as "recovery is
670
+ * off" (gate 3), not as "no information, so guess".
671
+ */
672
+ function declaredTools(bodyJson) {
673
+ let body;
674
+ try {
675
+ body = JSON.parse(bodyJson);
676
+ } catch {
677
+ return;
678
+ }
679
+ if (typeof body !== "object" || body === null || Array.isArray(body)) return void 0;
680
+ const obj = body;
681
+ const tools = obj["tools"];
682
+ if (!Array.isArray(tools)) return void 0;
683
+ const names = /* @__PURE__ */ new Set();
684
+ const requiredParameters = /* @__PURE__ */ new Map();
685
+ for (const value of tools) {
686
+ if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
687
+ const fn = value["function"];
688
+ if (typeof fn !== "object" || fn === null || Array.isArray(fn)) continue;
689
+ const definition = fn;
690
+ const name = typeof definition["name"] === "string" ? definition["name"] : "";
691
+ if (name === "") continue;
692
+ names.add(name);
693
+ const parameters = definition["parameters"];
694
+ if (typeof parameters !== "object" || parameters === null || Array.isArray(parameters)) continue;
695
+ const required = parameters["required"];
696
+ if (!Array.isArray(required)) continue;
697
+ const list = required.filter((entry) => typeof entry === "string" && entry !== "");
698
+ if (list.length > 0) requiredParameters.set(name, list);
699
+ }
700
+ if (names.size === 0) return void 0;
701
+ const pinned = typeof obj["tool_choice"] === "string" ? obj["tool_choice"].trim() : "";
702
+ const pinnedToolName = pinned !== "" && ![
703
+ "auto",
704
+ "required",
705
+ "none"
706
+ ].includes(pinned.toLowerCase()) ? pinned : void 0;
707
+ return {
708
+ names,
709
+ requiredParameters,
710
+ ...pinnedToolName === void 0 ? {} : { pinnedToolName }
711
+ };
712
+ }
713
+ /**
640
714
  * Gateway (openresty/APISIX) rejection of a token it no longer accepts.
641
715
  *
642
716
  * The business APIs answer JSON; an edge rejection answers an HTML error page
@@ -885,9 +959,26 @@ var WorkBuddyUpstreamClient = class {
885
959
  *
886
960
  * The caller MUST drain {@link WorkBuddyProbeAnswer.response}; an unread body
887
961
  * holds the connection open.
962
+ *
963
+ * ALWAYS bounded in time. The pool's batch runner is serial and passes no
964
+ * signal, so without a ceiling here one stalled connection blocks every account
965
+ * behind it — the batch looks hung with no way to tell which member did it.
966
+ * A supplied signal is COMBINED with the timeout rather than replacing it, so
967
+ * the single-model route keeps its cancellation behaviour and still cannot
968
+ * hang forever.
969
+ *
970
+ * The ceiling covers ONLY the wait for a response — it is cleared the moment
971
+ * `fetch` resolves. Holding a timer over the body would put a healthy but slow
972
+ * model on the same clock as a dead endpoint, and the whole point of the
973
+ * period is to answer "did the upstream answer at all". A probe asks for a
974
+ * single token, so the body that follows is one SSE line.
888
975
  */
889
976
  async probeChat(credential, bodyJson, signal) {
890
977
  let response;
978
+ const ceiling = new AbortController();
979
+ const timer = setTimeout(() => {
980
+ ceiling.abort(/* @__PURE__ */ new Error(`no response within ${PROBE_TIMEOUT_MS}ms`));
981
+ }, PROBE_TIMEOUT_MS);
891
982
  try {
892
983
  response = await fetch(`${chatBase(credential)}/v2/chat/completions`, {
893
984
  method: "POST",
@@ -896,7 +987,7 @@ var WorkBuddyUpstreamClient = class {
896
987
  "Authorization": `Bearer ${credential.accessToken}`
897
988
  },
898
989
  body: bodyJson,
899
- ...signal === void 0 ? {} : { signal }
990
+ signal: signal === void 0 ? ceiling.signal : AbortSignal.any([signal, ceiling.signal])
900
991
  });
901
992
  } catch (error) {
902
993
  return {
@@ -905,6 +996,8 @@ var WorkBuddyUpstreamClient = class {
905
996
  retryAfter: null,
906
997
  body: `transport error: ${String(error)}`
907
998
  };
999
+ } finally {
1000
+ clearTimeout(timer);
908
1001
  }
909
1002
  const retryAfter = response.headers.get("retry-after");
910
1003
  if (response.ok) return {
@@ -2459,7 +2552,7 @@ function generationOf(version) {
2459
2552
  //#endregion
2460
2553
  //#region src/version.ts
2461
2554
  /** The npm package version this build was produced from. */
2462
- const WORKBUDDY_CONNECT_VERSION = "3.1.0";
2555
+ const WORKBUDDY_CONNECT_VERSION = "3.4.0";
2463
2556
  //#endregion
2464
2557
  //#region src/host-heartbeat.ts
2465
2558
  /**
@@ -2617,4 +2710,4 @@ async function clearHostHeartbeat() {
2617
2710
  await rm(workbuddyHostHeartbeatPath(), { force: true });
2618
2711
  }
2619
2712
  //#endregion
2620
- export { workbuddyAppExecutableCandidates as $, CREDENTIAL_REJECTED_CODE as A, regionOf as B, defaultDesktopAuthPath as C, parseWorkBuddyAuth as D, legacyWorkbuddyOwnAuthPath as E, isCredentialRejectedError as F, fetchAtRestKeyPayload as G, clearAtRestKeyCache as H, parseCreditMultiplier as I, isWorkbuddyBundle as J, findWorkbuddyAppExecutable as K, parseReasoning as L, WorkBuddyCredentialRejectedError as M, WorkBuddyUpstreamClient as N, workbuddyAccountId as O, classifyUpstreamError as P, readAtRestKey as Q, parseUpstreamModel as R, defaultDesktopAuthDirs as S, isEncryptedCredentialError as T, deriveAtRestKey as U, WORKBUDDY_APP_EXECUTABLE_ENV as V, deriveAtRestKeyId as W, macosNestedAppBundles as X, macosBundleExecutable as Y, openEncryptedField as Z, WORKBUDDY_AUTH_FILE_ENV as _, readHostHeartbeat as a, authFileName as b, WORKBUDDY_CONNECT_VERSION as c, FALLBACK_WORKBUDDY_MODELS_GLOBAL as d, WorkBuddyCatalog as f, WORKBUDDY_AUTH_FILENAME as g, ENCRYPTED_CREDENTIAL_CODE as h, processStartTimeMs as i, WORKBUDDY_FALLBACK_SYSTEM_PROMPT as j, workbuddyOwnAuthPath as k, piAiRuntimeInfo as l, fallbackModelsFor as m, clearHostHeartbeat as n, workbuddyHostHeartbeatPath as o, deriveCatalog as p, isEncryptedFieldWrapper as q, isHeartbeatProcessAlive as r, writeHostHeartbeat as s, WORKBUDDY_HOST_HEARTBEAT_FILENAME as t, FALLBACK_WORKBUDDY_MODELS as u, WorkBuddyCredentialStore as v, hasEncryptedCredentialFields as w, defaultDesktopAuthCandidates as x, WorkBuddyEncryptedCredentialError as y, prepareChatBody as z };
2713
+ export { readAtRestKey as $, CREDENTIAL_REJECTED_CODE as A, prepareChatBody as B, defaultDesktopAuthPath as C, parseWorkBuddyAuth as D, legacyWorkbuddyOwnAuthPath as E, declaredTools as F, deriveAtRestKeyId as G, WORKBUDDY_APP_EXECUTABLE_ENV as H, isCredentialRejectedError as I, isEncryptedFieldWrapper as J, fetchAtRestKeyPayload as K, parseCreditMultiplier as L, WorkBuddyCredentialRejectedError as M, WorkBuddyUpstreamClient as N, workbuddyAccountId as O, classifyUpstreamError as P, openEncryptedField as Q, parseReasoning as R, defaultDesktopAuthDirs as S, isEncryptedCredentialError as T, clearAtRestKeyCache as U, regionOf as V, deriveAtRestKey as W, macosBundleExecutable as X, isWorkbuddyBundle as Y, macosNestedAppBundles as Z, WORKBUDDY_AUTH_FILE_ENV as _, readHostHeartbeat as a, authFileName as b, WORKBUDDY_CONNECT_VERSION as c, FALLBACK_WORKBUDDY_MODELS_GLOBAL as d, workbuddyAppExecutableCandidates as et, WorkBuddyCatalog as f, WORKBUDDY_AUTH_FILENAME as g, ENCRYPTED_CREDENTIAL_CODE as h, processStartTimeMs as i, WORKBUDDY_FALLBACK_SYSTEM_PROMPT as j, workbuddyOwnAuthPath as k, piAiRuntimeInfo as l, fallbackModelsFor as m, clearHostHeartbeat as n, workbuddyHostHeartbeatPath as o, deriveCatalog as p, findWorkbuddyAppExecutable as q, isHeartbeatProcessAlive as r, writeHostHeartbeat as s, WORKBUDDY_HOST_HEARTBEAT_FILENAME as t, FALLBACK_WORKBUDDY_MODELS as u, WorkBuddyCredentialStore as v, hasEncryptedCredentialFields as w, defaultDesktopAuthCandidates as x, WorkBuddyEncryptedCredentialError as y, parseUpstreamModel as z };
package/lib/index.d.ts CHANGED
@@ -631,6 +631,19 @@ declare class WorkBuddyUpstreamClient {
631
631
  *
632
632
  * The caller MUST drain {@link WorkBuddyProbeAnswer.response}; an unread body
633
633
  * holds the connection open.
634
+ *
635
+ * ALWAYS bounded in time. The pool's batch runner is serial and passes no
636
+ * signal, so without a ceiling here one stalled connection blocks every account
637
+ * behind it — the batch looks hung with no way to tell which member did it.
638
+ * A supplied signal is COMBINED with the timeout rather than replacing it, so
639
+ * the single-model route keeps its cancellation behaviour and still cannot
640
+ * hang forever.
641
+ *
642
+ * The ceiling covers ONLY the wait for a response — it is cleared the moment
643
+ * `fetch` resolves. Holding a timer over the body would put a healthy but slow
644
+ * model on the same clock as a dead endpoint, and the whole point of the
645
+ * period is to answer "did the upstream answer at all". A probe asks for a
646
+ * single token, so the body that follows is one SSE line.
634
647
  */
635
648
  probeChat(credential: WorkBuddyCredential, bodyJson: string, signal?: AbortSignal): Promise<WorkBuddyProbeAnswer>;
636
649
  /** POST the token-refresh endpoint; the caller merges the outcome. */
@@ -793,12 +806,62 @@ interface WorkBuddyShim {
793
806
  /** Stop serving and destroy open connections. */
794
807
  close(): Promise<void>;
795
808
  }
809
+ /**
810
+ * Supplies the credential to retry a failed chat request with.
811
+ *
812
+ * Called with every account id already tried for THIS request, the one that just
813
+ * failed LAST, and resolves to the next candidate — or `undefined` when the pool
814
+ * is off, empty, or exhausted, in which case the original failure is reported
815
+ * unchanged.
816
+ *
817
+ * The plugin owns the policy (which pool members count as usable, in what
818
+ * order); the shim owns only the retry loop and the wire-level rule that a
819
+ * retry may happen before anything has been written to the client.
820
+ */
821
+ type WorkBuddyFailoverAccount = (triedAccountIds: readonly string[]) => Promise<WorkBuddyCredential | undefined>;
796
822
  /** Constructor dependencies. */
797
823
  interface WorkBuddyShimOptions {
798
824
  store: WorkBuddyCredentialStore;
799
825
  client: Pick<WorkBuddyUpstreamClient, 'chatStream'>;
800
826
  catalog: WorkBuddyCatalog;
801
827
  logger?: ShimLogger;
828
+ /**
829
+ * Pool failover for chat requests. Absent means no failover at all — the
830
+ * pre-pool behaviour, where the selected account serves and its failure is
831
+ * reported as-is.
832
+ */
833
+ failoverAccount?: WorkBuddyFailoverAccount;
834
+ /**
835
+ * Called before the FIRST attempt of every chat request, so the plugin can
836
+ * point the store at the account the pool's ranking says should serve.
837
+ *
838
+ * It exists because "who serves" and "who to retry with" are the same decision
839
+ * made at two moments: the ranking picks the first account, and after a failure
840
+ * picks the next one. The shim resolves the credential itself right after this
841
+ * returns, so the ranking never has to produce a credential — which keeps
842
+ * token REFRESH in one place (`store.resolve()`) instead of duplicating it on
843
+ * a path that would silently send expired tokens.
844
+ *
845
+ * Absent means the store's own selection always serves.
846
+ */
847
+ prepareAccount?: () => Promise<void>;
848
+ /**
849
+ * Called for every attempt that failed upstream, before the next one is tried.
850
+ *
851
+ * Exists so the plugin can RECORD the failure as a measurement. The ranking
852
+ * that picks the serving account reads the pool's STORED measurements, so
853
+ * without this an account that just answered 429 still reads as untested — and
854
+ * the next request picks it again and pays the same failed round trip before
855
+ * failing over.
856
+ *
857
+ * Fired without awaiting: a slow write must never delay the retry the user is
858
+ * waiting on, and the measurement only matters for LATER requests.
859
+ */
860
+ onAccountFailure?: (accountId: string, failure: {
861
+ status: number;
862
+ kind: UpstreamErrorKind;
863
+ message: string;
864
+ }) => void;
802
865
  }
803
866
  /**
804
867
  * Start the loopback endpoint. Requests must carry the shim's shared secret;
@@ -1468,6 +1531,20 @@ interface WorkBuddyPoolProbe {
1468
1531
  * must never be turned into a locally invented countdown.
1469
1532
  */
1470
1533
  retryAtMs?: number;
1534
+ /**
1535
+ * The upstream's own words on the failure, already redacted.
1536
+ *
1537
+ * Kept so the card can say WHY an account left the pool instead of repeating
1538
+ * one generic sentence for a DNS failure, a gateway 502 and a request the user
1539
+ * cancelled alike. Those three used to render as the SAME line, which left the
1540
+ * user with a message they could not act on and no way to tell a broken
1541
+ * account from a broken network.
1542
+ *
1543
+ * ABSENT for a success, and for any measurement written before this field
1544
+ * existed: an `outcome` without a `message` is a complete, valid record, so
1545
+ * old probe files stay readable rather than being discarded.
1546
+ */
1547
+ message?: string;
1471
1548
  }
1472
1549
  /** One account's live credit situation, as the pool ranks it. */
1473
1550
  interface WorkBuddyPoolCredits {
@@ -1874,6 +1951,15 @@ interface WorkBuddyWebPoolAccount {
1874
1951
  atMs: number;
1875
1952
  /** Only when the upstream named a time; never invented locally. */
1876
1953
  retryAtMs?: number;
1954
+ /**
1955
+ * Why it failed, in the upstream's own redacted words.
1956
+ *
1957
+ * Carried so the pool table can distinguish a DNS failure from a gateway
1958
+ * 502 from a request the user cancelled, instead of repeating one generic
1959
+ * sentence for all three. ABSENT on success and on measurements written
1960
+ * before this field existed, so the card must render the bare outcome then.
1961
+ */
1962
+ message?: string;
1877
1963
  };
1878
1964
  /** Set when this account cannot serve right now. */
1879
1965
  excludedBy?: WorkBuddyWebPoolExclusion;
@@ -1907,8 +1993,13 @@ interface WorkBuddyWebPoolAccount {
1907
1993
  */
1908
1994
  interface WorkBuddyWebPool {
1909
1995
  enabled: boolean;
1910
- rotateByCredits: boolean;
1911
- autoTestIntervalMinutes: number;
1996
+ /**
1997
+ * Whether this region offers the daily check-in at all.
1998
+ *
1999
+ * The card hides the action entirely when false, rather than rendering a
2000
+ * button whose only outcome is a refusal.
2001
+ */
2002
+ checkinSupported: boolean;
1912
2003
  /** The model a test would use right now; absent when none can be resolved. */
1913
2004
  targetModelId?: string;
1914
2005
  /**
@@ -1936,15 +2027,6 @@ interface WorkBuddyWebPool {
1936
2027
  * ran on ZERO accounts and reported success.
1937
2028
  */
1938
2029
  effectiveMemberAccountIds?: readonly string[];
1939
- /**
1940
- * The account rotation most recently moved billing TO, when it moved.
1941
- *
1942
- * Reported so the card can record a switch it did not perform: rotation
1943
- * happens on the Host (including on a timer), and the card is otherwise
1944
- * unable to tell a switch from an unchanged state — which would make
1945
- * "every switch is recorded" a promise the UI could not keep.
1946
- */
1947
- rotatedToAccountId?: string;
1948
2030
  accounts: readonly WorkBuddyWebPoolAccount[];
1949
2031
  /**
1950
2032
  * The models a manual target can be chosen from.
@@ -2093,8 +2175,6 @@ interface WorkBuddyPoolDeps {
2093
2175
  /** The region's stored preferences, resolved from the Host config. */
2094
2176
  preferences(region: WorkBuddyRegion): {
2095
2177
  enabled: boolean;
2096
- rotateByCredits: boolean;
2097
- autoTestIntervalMinutes: number;
2098
2178
  targetModelId: string;
2099
2179
  memberAccountIds: readonly string[];
2100
2180
  };
@@ -2110,14 +2190,6 @@ interface WorkBuddyPoolDeps {
2110
2190
  * checked) instead of passing the guard and running a batch over nothing.
2111
2191
  */
2112
2192
  effectiveMemberAccountIds?(region: WorkBuddyRegion): Promise<readonly string[]>;
2113
- /**
2114
- * The account rotation last redirected billing to, when it did.
2115
- *
2116
- * Reported so the card can log a switch that happened on the Host (possibly
2117
- * on a timer) — without it, "every switch leaves a record" is a promise the
2118
- * browser half has no way to keep.
2119
- */
2120
- rotatedToAccountId?(region: WorkBuddyRegion): Promise<string | undefined>;
2121
2193
  /**
2122
2194
  * Today's check-in state per account id.
2123
2195
  *
@@ -2125,6 +2197,8 @@ interface WorkBuddyPoolDeps {
2125
2197
  * entry) means "not read", which the card renders as unknown rather than as a
2126
2198
  * definite "not checked in" — a claim that would contradict the check-in the
2127
2199
  * user just performed.
2200
+ *
2201
+ * Only the CN region has a check-in to report; see {@link checkinSupported}.
2128
2202
  */
2129
2203
  checkedInToday?(region: WorkBuddyRegion): Promise<Readonly<Record<string, boolean>>>;
2130
2204
  /**
@@ -2137,6 +2211,16 @@ interface WorkBuddyPoolDeps {
2137
2211
  }[]>;
2138
2212
  /** The account currently billing traffic for the region, when known. */
2139
2213
  currentAccountId?(region: WorkBuddyRegion): Promise<string | undefined>;
2214
+ /**
2215
+ * Whether this region offers the daily check-in action at all.
2216
+ *
2217
+ * The CN app rewards a daily check-in; the international region is not offered
2218
+ * one here, so its card must not show the button and its route must refuse the
2219
+ * action. A capability rather than a region name compared in the browser: the
2220
+ * policy belongs in the Host, and the card should not have to know which
2221
+ * regions happen to have the feature.
2222
+ */
2223
+ checkinSupported?(region: WorkBuddyRegion): boolean;
2140
2224
  /**
2141
2225
  * Claim the daily reward for every account of one region.
2142
2226
  *
@@ -2145,8 +2229,15 @@ interface WorkBuddyPoolDeps {
2145
2229
  * reports "unavailable" instead of appearing to succeed.
2146
2230
  */
2147
2231
  checkin?(region: WorkBuddyRegion): Promise<readonly WorkBuddyPoolCheckinRow[]>;
2148
- /** Test every account of one region against the resolved target model. */
2149
- test?(region: WorkBuddyRegion, modelId: string): Promise<readonly WorkBuddyPoolTestRow[]>;
2232
+ /**
2233
+ * Test every account of one region against the resolved target model.
2234
+ *
2235
+ * `onRow` is optional and exists so the route can STREAM each account as it
2236
+ * finishes. An older Host ignores it and answers with the whole batch at once;
2237
+ * the route handles both, because a missing reporter must degrade to the
2238
+ * previous behaviour rather than to no rows at all.
2239
+ */
2240
+ test?(region: WorkBuddyRegion, modelId: string, onRow?: (row: WorkBuddyPoolTestRow) => void): Promise<readonly WorkBuddyPoolTestRow[]>;
2150
2241
  /** The region's live catalog, used to resolve the free target model. */
2151
2242
  catalog?(region: WorkBuddyRegion): readonly WorkBuddyModelInfo[];
2152
2243
  }
@@ -2224,18 +2315,21 @@ interface WorkBuddyRegionState {
2224
2315
  * measurement living in the same slot would be rolled back by the next save.
2225
2316
  */
2226
2317
  interface WorkBuddyPoolPreferences {
2227
- /** Whether the pool is active for this region. Opt-in: default false. */
2228
- enabled?: boolean;
2229
2318
  /**
2230
- * Whether the plugin may choose the billed account by credits.
2319
+ * Whether the pool is active for this region — which now means the
2320
+ * FAILOVER switch, not a credit ranking.
2231
2321
  *
2232
- * Opt-in, and mutually exclusive with manual selection: while it is on, the
2233
- * card disables the account dropdown, because two writers deciding the same
2234
- * slot is exactly how "the dropdown says A but B is billed" happens.
2322
+ * Opt-in: default false. While on, a chat request that fails upstream is
2323
+ * retried against the other usable members of this region's pool, in order,
2324
+ * until one serves it or none is left (see `src/shim.ts`). While off the
2325
+ * plugin behaves exactly as it did before the pool existed: the account the
2326
+ * user selected serves every request, and a failure is reported as-is.
2327
+ *
2328
+ * It deliberately does NOT change which account serves NEXT: failover is
2329
+ * per-request borrowing, so the user's selection stays in effect and the
2330
+ * card can keep reporting who really served.
2235
2331
  */
2236
- rotateByCredits?: boolean;
2237
- /** Minutes between automatic tests; clamped to 5..1440. */
2238
- autoTestIntervalMinutes?: number;
2332
+ enabled?: boolean;
2239
2333
  /** Model id to test; `''` means auto-pick a free model from this region. */
2240
2334
  targetModelId?: string;
2241
2335
  /**