@oh-my-pi/pi-ai 17.3.2 → 17.3.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [17.3.4] - 2026-08-14
6
+
7
+ ### Fixed
8
+
9
+ - Fixed `omp usage invalidate` to discard stale OAuth and API-key usage snapshots, then force a cache-bypassing, per-provider serialized refresh with a broker request budget sized for the full unfiltered account batch, so upgraded subscriptions do not silently retain pre-change quota data.
10
+ - Fixed quota reporting and Cookie capture guidance for China (Beijing) Alibaba Token Plan credentials ([#8509](https://github.com/can1357/oh-my-pi/issues/8509)).
11
+
12
+ ## [17.3.3] - 2026-08-14
13
+
14
+ ### Fixed
15
+
16
+ - Distinguished Gemini thought-only `STOP` responses from empty transports, avoiding repeated identical reasoning requests and duplicate Antigravity endpoint streams while surfacing the missing final output for session-level recovery.
17
+
5
18
  ## [17.3.2] - 2026-08-13
6
19
 
7
20
  ### Fixed
@@ -66,7 +66,14 @@ export declare class AuthBrokerClient {
66
66
  openSnapshotStream(opts?: {
67
67
  signal?: AbortSignal;
68
68
  }): AsyncGenerator<SnapshotStreamEvent>;
69
- fetchUsage(signal?: AbortSignal): Promise<UsageResponse>;
69
+ /**
70
+ * Fetch aggregate broker usage with a timeout sized for serialized
71
+ * same-provider account probes.
72
+ */
73
+ fetchUsage(options?: {
74
+ signal?: AbortSignal;
75
+ maxAccountsPerProvider?: number;
76
+ }): Promise<UsageResponse>;
70
77
  /** Recorded usage-limit snapshots from the broker host, oldest first. */
71
78
  fetchUsageHistory(query?: {
72
79
  sinceMs?: number;
@@ -1111,10 +1111,9 @@ export declare class AuthStorage {
1111
1111
  signal?: AbortSignal;
1112
1112
  }): Promise<ResetCreditRedeemOutcome>;
1113
1113
  /**
1114
- * Force-invalidate cached usage reports so the next fetch retrieves fresh
1115
- * values from upstream providers. If `provider` is specified, only that
1116
- * provider's credentials are invalidated; otherwise, all credentials in the
1117
- * store are invalidated.
1114
+ * Discard cached usage reports before a user-requested refresh. The next
1115
+ * read probes upstream serially per provider; a failure reports no fresh
1116
+ * usage instead of replaying an invalidated last-good snapshot.
1118
1117
  */
1119
1118
  invalidateUsageCache(provider?: string, signal?: AbortSignal): Promise<void>;
1120
1119
  invalidateCredentialMatching(provider: string, apiKey: string, options?: InvalidateCredentialMatchingOptions): Promise<boolean>;
@@ -8,6 +8,7 @@ export declare const Flag: {
8
8
  readonly StaleResponsesItem: 1048576;
9
9
  readonly MalformedFunctionCall: 2097152;
10
10
  readonly ProviderFinishError: 4194304;
11
+ readonly EmptyResponse: 8192;
11
12
  readonly ContentBlocked: 32768;
12
13
  /** Account-scoped provider policy denial that may succeed with another credential. */
13
14
  readonly AccountPolicy: 16384;
@@ -7,6 +7,8 @@ export type ProviderResponseErrorKind =
7
7
  | "output"
8
8
  /** Response body was empty/missing when content was required. */
9
9
  | "empty-body"
10
+ /** Response completed without actionable output (for example, thoughts only). */
11
+ | "empty-output"
10
12
  /** Malformed wire envelope (unexpected message ordering / shape). */
11
13
  | "envelope"
12
14
  /** Content was blocked by a provider safety filter. */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-ai",
4
- "version": "17.3.2",
4
+ "version": "17.3.4",
5
5
  "description": "Unified LLM API with automatic model discovery and provider configuration",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -38,10 +38,10 @@
38
38
  },
39
39
  "dependencies": {
40
40
  "@bufbuild/protobuf": "^2.12.1",
41
- "@oh-my-pi/omptype": "17.3.2",
42
- "@oh-my-pi/pi-catalog": "17.3.2",
43
- "@oh-my-pi/pi-utils": "17.3.2",
44
- "@oh-my-pi/pi-wire": "17.3.2"
41
+ "@oh-my-pi/omptype": "17.3.4",
42
+ "@oh-my-pi/pi-catalog": "17.3.4",
43
+ "@oh-my-pi/pi-utils": "17.3.4",
44
+ "@oh-my-pi/pi-wire": "17.3.4"
45
45
  },
46
46
  "devDependencies": {
47
47
  "@bufbuild/protoc-gen-es": "^2.12.1",
@@ -249,12 +249,23 @@ export class AuthBrokerClient {
249
249
  }
250
250
  }
251
251
 
252
- fetchUsage(signal?: AbortSignal): Promise<UsageResponse> {
253
- // Validates the envelope (`generatedAt`, `reports[].provider`, `limits`,
254
- // `metadata`) but leaves provider-specific extension fields permissive so
255
- // the broker can ship new shapes ahead of the client. `raw` is accepted
256
- // but normally stripped by the broker before send.
257
- return this.#request<UsageResponse>("GET", "/v1/usage", { schema: "usageResponseSchema", signal });
252
+ /**
253
+ * Fetch aggregate broker usage with a timeout sized for serialized
254
+ * same-provider account probes.
255
+ */
256
+ fetchUsage(options: { signal?: AbortSignal; maxAccountsPerProvider?: number } = {}): Promise<UsageResponse> {
257
+ const requestedAccountCount = options.maxAccountsPerProvider;
258
+ const accountCount =
259
+ typeof requestedAccountCount === "number" && Number.isFinite(requestedAccountCount)
260
+ ? Math.max(1, Math.floor(requestedAccountCount))
261
+ : 1;
262
+ const perAccountTimeoutMs = Math.max(DEFAULT_TIMEOUT_MS, this.#timeoutMs);
263
+ const timeoutMs = perAccountTimeoutMs * (accountCount + 1);
264
+ return this.#request<UsageResponse>("GET", "/v1/usage", {
265
+ schema: "usageResponseSchema",
266
+ signal: options.signal,
267
+ timeoutMs,
268
+ });
258
269
  }
259
270
 
260
271
  /** Recorded usage-limit snapshots from the broker host, oldest first. */
@@ -369,7 +380,13 @@ export class AuthBrokerClient {
369
380
  async #request<t>(
370
381
  method: "GET" | "POST" | "DELETE",
371
382
  path: string,
372
- opts: { schema: AuthBrokerResponseSchemaName; auth?: boolean; body?: unknown; signal?: AbortSignal },
383
+ opts: {
384
+ schema: AuthBrokerResponseSchemaName;
385
+ auth?: boolean;
386
+ body?: unknown;
387
+ signal?: AbortSignal;
388
+ timeoutMs?: number;
389
+ },
373
390
  ): Promise<t> {
374
391
  const response = await this.#fetchRaw(method, path, opts);
375
392
  const text = await response.text();
@@ -253,6 +253,9 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
253
253
  #usageInflight?: Promise<UsageReport[] | null>;
254
254
  #credentialBlockReconcileAfter: Map<string, number> = new Map();
255
255
  #usageCacheEpoch = 0;
256
+ /** Raw broker credentials retained to size aggregate usage requests before account-pool filtering. */
257
+ #brokerUsageProviderByCredentialId = new Map<number, Provider>();
258
+ #brokerUsageAccountCounts = new Map<Provider, number>();
256
259
  /** Per-snapshot lookup of oauth credentials by provider; rebuilt when `#snapshot` is replaced. */
257
260
  #usageFilterLookup?: { snapshot: SnapshotResponse; byProvider: Map<Provider, OAuthCredential[]> };
258
261
  /** Memoized `#filterUsageReports` output, keyed on (input identity, lookup identity). */
@@ -296,6 +299,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
296
299
 
297
300
  #applySnapshot(snapshot: SnapshotResponse, generation: number, protectNewBlocks = true): void {
298
301
  const nowMs = Date.now();
302
+ this.#replaceBrokerUsageAccounts(snapshot.credentials);
299
303
  const previousCredentials = this.#snapshot.credentials;
300
304
  const credentials = snapshot.credentials
301
305
  .filter(entry => isCredentialInAccountPool(entry, this.#accountPool))
@@ -427,8 +431,9 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
427
431
  generation: number,
428
432
  serverNowMs: number,
429
433
  ): void {
434
+ this.#upsertBrokerUsageAccount(entry);
430
435
  if (!isCredentialInAccountPool(entry, this.#accountPool)) {
431
- this.#removeStreamCredential(entry.id, refresher, generation, serverNowMs);
436
+ this.#removeStreamCredential(entry.id, refresher, generation, serverNowMs, { retainBrokerUsageAccount: true });
432
437
  return;
433
438
  }
434
439
  const incoming = this.#normalizeSnapshotEntryBlocks(entry, Date.now());
@@ -446,7 +451,14 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
446
451
  this.#snapshotReceivedAt = Date.now();
447
452
  }
448
453
 
449
- #removeStreamCredential(id: number, refresher: RefresherSchedule, generation: number, serverNowMs: number): void {
454
+ #removeStreamCredential(
455
+ id: number,
456
+ refresher: RefresherSchedule,
457
+ generation: number,
458
+ serverNowMs: number,
459
+ options?: { retainBrokerUsageAccount?: boolean },
460
+ ): void {
461
+ if (!options?.retainBrokerUsageAccount) this.#removeBrokerUsageAccount(id);
450
462
  const removed = this.#snapshot.credentials.find(entry => entry.id === id);
451
463
  if (removed?.blocks && removed.blocks.length > 0) this.#invalidateUsageCache();
452
464
  const credentials = this.#snapshot.credentials.filter(entry => entry.id !== id);
@@ -1066,6 +1078,39 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
1066
1078
  });
1067
1079
  }
1068
1080
 
1081
+ #replaceBrokerUsageAccounts(entries: readonly SnapshotEntry[]): void {
1082
+ this.#brokerUsageProviderByCredentialId.clear();
1083
+ this.#brokerUsageAccountCounts.clear();
1084
+ for (const entry of entries) this.#upsertBrokerUsageAccount(entry);
1085
+ }
1086
+
1087
+ #upsertBrokerUsageAccount(entry: Pick<SnapshotEntry, "id" | "provider">): void {
1088
+ const previous = this.#brokerUsageProviderByCredentialId.get(entry.id);
1089
+ if (previous === entry.provider) return;
1090
+ if (previous !== undefined) {
1091
+ const count = this.#brokerUsageAccountCounts.get(previous) ?? 0;
1092
+ if (count <= 1) this.#brokerUsageAccountCounts.delete(previous);
1093
+ else this.#brokerUsageAccountCounts.set(previous, count - 1);
1094
+ }
1095
+ this.#brokerUsageProviderByCredentialId.set(entry.id, entry.provider);
1096
+ this.#brokerUsageAccountCounts.set(entry.provider, (this.#brokerUsageAccountCounts.get(entry.provider) ?? 0) + 1);
1097
+ }
1098
+
1099
+ #removeBrokerUsageAccount(id: number): void {
1100
+ const provider = this.#brokerUsageProviderByCredentialId.get(id);
1101
+ if (provider === undefined) return;
1102
+ this.#brokerUsageProviderByCredentialId.delete(id);
1103
+ const count = this.#brokerUsageAccountCounts.get(provider) ?? 0;
1104
+ if (count <= 1) this.#brokerUsageAccountCounts.delete(provider);
1105
+ else this.#brokerUsageAccountCounts.set(provider, count - 1);
1106
+ }
1107
+
1108
+ #maxBrokerUsageAccounts(): number {
1109
+ let maximum = 1;
1110
+ for (const count of this.#brokerUsageAccountCounts.values()) maximum = Math.max(maximum, count);
1111
+ return maximum;
1112
+ }
1113
+
1069
1114
  #loadUsageReports(): Promise<UsageReport[] | null> {
1070
1115
  const cached = this.#usageCache;
1071
1116
  if (cached && Date.now() - cached.fetchedAt < USAGE_CACHE_TTL_MS) {
@@ -1074,7 +1119,7 @@ export class RemoteAuthCredentialStore implements AuthCredentialStore {
1074
1119
  if (this.#usageInflight) return this.#usageInflight;
1075
1120
  const epoch = this.#usageCacheEpoch;
1076
1121
  const inflight = this.#client
1077
- .fetchUsage()
1122
+ .fetchUsage({ maxAccountsPerProvider: this.#maxBrokerUsageAccounts() })
1078
1123
  .then(body => {
1079
1124
  if (epoch !== this.#usageCacheEpoch) return this.#loadUsageReports();
1080
1125
  this.#usageCache = { reports: body.reports, fetchedAt: Date.now() };
@@ -741,7 +741,7 @@ export function startAuthBroker(opts: AuthBrokerServerOptions): AuthBrokerServer
741
741
  }
742
742
  if (req.method === "POST" && pathname === "/v1/usage/stale") {
743
743
  try {
744
- opts.storage.invalidateUsageCache?.();
744
+ await opts.storage.invalidateUsageCache?.();
745
745
  logger.info("auth-broker usage cache invalidated", { peer });
746
746
  return json(200, { ok: true });
747
747
  } catch (error) {
@@ -677,6 +677,7 @@ const DEFAULT_USAGE_PROVIDER_MAP = new Map<Provider, UsageProvider>(
677
677
  );
678
678
 
679
679
  const USAGE_CACHE_PREFIX = "usage_cache:";
680
+ const USAGE_FORCE_REFRESH_CACHE_PREFIX = "force-refresh:";
680
681
  const USAGE_HEADER_INGEST_INTERVAL_MS = 60_000;
681
682
  const USAGE_LAST_GOOD_RETENTION_MS = 24 * 60 * 60_000;
682
683
  /**
@@ -686,6 +687,12 @@ const USAGE_LAST_GOOD_RETENTION_MS = 24 * 60 * 60_000;
686
687
  * on the next poll.
687
688
  */
688
689
  const USAGE_FAILURE_BACKOFF_MS = 10_000;
690
+ /**
691
+ * A manual invalidation persists across the next CLI process and serializes
692
+ * same-provider probes, avoiding a cold-account burst against IP-limited
693
+ * upstream usage endpoints.
694
+ */
695
+ const USAGE_FORCE_REFRESH_TTL_MS = 5 * 60_000;
689
696
  // Bumped from 3s — Claude usage retries up to 3 times with exponential backoff
690
697
  // (~3.5s total worst case); a tight per-request budget aborts retries mid-cycle.
691
698
  const DEFAULT_USAGE_REQUEST_TIMEOUT_MS = 10_000;
@@ -782,6 +789,7 @@ interface UsageCache {
782
789
  get<T>(key: string): UsageCacheEntry<T> | undefined;
783
790
  getStale<T>(key: string): UsageCacheEntry<T> | undefined;
784
791
  set<T>(key: string, entry: UsageCacheEntry<T>): void;
792
+ deletePrefix(prefix: string): boolean;
785
793
  cleanup?(): void;
786
794
  }
787
795
 
@@ -791,6 +799,11 @@ type UsageRequestDescriptor = {
791
799
  baseUrl?: string;
792
800
  };
793
801
 
802
+ type ForcedUsageRefresh = {
803
+ all: boolean;
804
+ providers: Set<Provider>;
805
+ };
806
+
794
807
  type AuthApiKeyOptions = {
795
808
  baseUrl?: string;
796
809
  modelId?: string;
@@ -1185,6 +1198,12 @@ class AuthStorageUsageCache implements UsageCache {
1185
1198
  this.store.setCache(`${USAGE_CACHE_PREFIX}${key}`, payload, Math.floor(durableExpiresAt / 1000));
1186
1199
  }
1187
1200
 
1201
+ deletePrefix(prefix: string): boolean {
1202
+ if (!this.store.deleteCachePrefix) return false;
1203
+ this.store.deleteCachePrefix(`${USAGE_CACHE_PREFIX}${prefix}`);
1204
+ return true;
1205
+ }
1206
+
1188
1207
  cleanup(): void {
1189
1208
  this.store.cleanExpiredCache();
1190
1209
  }
@@ -2955,20 +2974,63 @@ export class AuthStorage {
2955
2974
  return baseUrl?.trim().replace(/\/+$/, "") ?? "";
2956
2975
  }
2957
2976
 
2977
+ #usageCacheProviderKey(provider: Provider): string {
2978
+ const versionOverride = USAGE_REPORT_CACHE_KEY_VERSION_OVERRIDES[provider];
2979
+ return versionOverride === undefined ? provider : `${versionOverride}:${provider}`;
2980
+ }
2981
+
2982
+ #usageForceRefreshCacheKey(provider?: Provider): string {
2983
+ return provider
2984
+ ? `${USAGE_FORCE_REFRESH_CACHE_PREFIX}provider:${provider}`
2985
+ : `${USAGE_FORCE_REFRESH_CACHE_PREFIX}all`;
2986
+ }
2987
+
2988
+ #markUsageForceRefresh(provider?: Provider): void {
2989
+ this.#usageCache.set(this.#usageForceRefreshCacheKey(provider), {
2990
+ value: true,
2991
+ expiresAt: Date.now() + USAGE_FORCE_REFRESH_TTL_MS,
2992
+ });
2993
+ }
2994
+
2995
+ #hasUsageForceRefresh(provider?: Provider): boolean {
2996
+ const key = this.#usageForceRefreshCacheKey(provider);
2997
+ const entry = this.#usageCache.get<boolean>(key);
2998
+ if (entry?.value !== true) return false;
2999
+ if (entry.expiresAt > Date.now()) return true;
3000
+ this.#usageCache.set(key, { value: null, expiresAt: 0 });
3001
+ return false;
3002
+ }
3003
+
3004
+ #usageForceRefresh(requests: readonly UsageRequestDescriptor[]): ForcedUsageRefresh {
3005
+ const all = this.#hasUsageForceRefresh();
3006
+ const providers = new Set<Provider>();
3007
+ for (const request of requests) providers.add(request.provider);
3008
+ if (!all) {
3009
+ for (const provider of providers) {
3010
+ if (!this.#hasUsageForceRefresh(provider)) providers.delete(provider);
3011
+ }
3012
+ }
3013
+ return { all, providers };
3014
+ }
3015
+
3016
+ #clearUsageForceRefresh(refresh: ForcedUsageRefresh): void {
3017
+ if (refresh.all) this.#usageCache.set(this.#usageForceRefreshCacheKey(), { value: null, expiresAt: 0 });
3018
+ for (const provider of refresh.providers) {
3019
+ this.#usageCache.set(this.#usageForceRefreshCacheKey(provider), { value: null, expiresAt: 0 });
3020
+ }
3021
+ }
3022
+
2958
3023
  #buildUsageReportCacheKey(request: UsageRequestDescriptor): string {
2959
3024
  const baseUrl = this.#normalizeUsageBaseUrl(request.baseUrl) || "default";
2960
3025
  const identity = this.#buildUsageCacheIdentity(request.credential);
2961
- const versionOverride = USAGE_REPORT_CACHE_KEY_VERSION_OVERRIDES[request.provider];
2962
- const providerKey = versionOverride === undefined ? request.provider : `${versionOverride}:${request.provider}`;
3026
+ const providerKey = this.#usageCacheProviderKey(request.provider);
2963
3027
  return `report:${providerKey}:${baseUrl}:${identity}`;
2964
3028
  }
2965
3029
 
2966
3030
  #buildUsageReportsCacheKey(requests: ReadonlyArray<UsageRequestDescriptor>): string {
2967
3031
  const snapshot = requests
2968
3032
  .map(request => {
2969
- const versionOverride = USAGE_REPORT_CACHE_KEY_VERSION_OVERRIDES[request.provider];
2970
- const providerKey =
2971
- versionOverride === undefined ? request.provider : `${versionOverride}:${request.provider}`;
3033
+ const providerKey = this.#usageCacheProviderKey(request.provider);
2972
3034
  return `${providerKey}:${this.#normalizeUsageBaseUrl(request.baseUrl) || "default"}:${this.#buildUsageCacheIdentity(request.credential)}`;
2973
3035
  })
2974
3036
  .sort()
@@ -3210,19 +3272,24 @@ export class AuthStorage {
3210
3272
  }
3211
3273
  }
3212
3274
 
3213
- async #fetchUsageCached(request: UsageRequestDescriptor, timeoutMs?: number): Promise<UsageReport | null> {
3275
+ async #fetchUsageCached(
3276
+ request: UsageRequestDescriptor,
3277
+ options: { timeoutMs?: number; forceRefresh?: boolean } = {},
3278
+ ): Promise<UsageReport | null> {
3279
+ const timeoutMs = options.timeoutMs;
3280
+ const forceRefresh = options.forceRefresh ?? false;
3214
3281
  const cacheKey = this.#buildUsageReportCacheKey(request);
3215
3282
  const now = Date.now();
3216
- const cached = this.#usageCache.get<UsageReport | null>(cacheKey);
3283
+ const cached = forceRefresh ? undefined : this.#usageCache.get<UsageReport | null>(cacheKey);
3217
3284
  // Fresh cache hit: return whatever's there (success or null fallback).
3218
3285
  if (cached && cached.expiresAt > now) {
3219
3286
  return cached.value;
3220
3287
  }
3221
3288
 
3222
- const inFlight = this.#usageRequestInFlight.get(cacheKey);
3223
- if (inFlight) return inFlight;
3224
-
3225
3289
  const usageCacheEpoch = this.#usageCacheEpoch;
3290
+ const inFlightKey = `${cacheKey}\0${usageCacheEpoch}`;
3291
+ const inFlight = this.#usageRequestInFlight.get(inFlightKey);
3292
+ if (inFlight) return inFlight;
3226
3293
  const promise = (async () => {
3227
3294
  const report = await this.#fetchUsageUncached(request, timeoutMs);
3228
3295
  if (usageCacheEpoch !== this.#usageCacheEpoch) return report;
@@ -3242,7 +3309,8 @@ export class AuthStorage {
3242
3309
  // re-hit the endpoint on every poll. Most providers serve the last good
3243
3310
  // value through transient failures. Session-cookie providers can opt out
3244
3311
  // so an expired login does not display stale quota indefinitely.
3245
- const retainLastGood = this.#usageProviderResolver?.(request.provider)?.retainLastGoodOnFailure !== false;
3312
+ const retainLastGood =
3313
+ !forceRefresh && this.#usageProviderResolver?.(request.provider)?.retainLastGoodOnFailure !== false;
3246
3314
  const lastGood = retainLastGood
3247
3315
  ? (this.#usageCache.getStale<UsageReport | null>(cacheKey)?.value ?? null)
3248
3316
  : null;
@@ -3251,10 +3319,10 @@ export class AuthStorage {
3251
3319
  this.#usageCache.set(cacheKey, { value: lastGood, expiresAt: coolDown });
3252
3320
  return lastGood;
3253
3321
  })().finally(() => {
3254
- this.#usageRequestInFlight.delete(cacheKey);
3322
+ this.#usageRequestInFlight.delete(inFlightKey);
3255
3323
  });
3256
3324
 
3257
- this.#usageRequestInFlight.set(cacheKey, promise);
3325
+ this.#usageRequestInFlight.set(inFlightKey, promise);
3258
3326
  return promise;
3259
3327
  }
3260
3328
 
@@ -3730,10 +3798,9 @@ export class AuthStorage {
3730
3798
  if (!resolvedApiKey) return null;
3731
3799
  usageCredential.apiKey = resolvedApiKey;
3732
3800
  }
3733
- return this.#fetchUsageCached(
3734
- this.#buildUsageRequest(provider, usageCredential, options?.baseUrl),
3735
- options?.timeoutMs ?? this.#usageRequestTimeoutMs,
3736
- );
3801
+ return this.#fetchUsageCached(this.#buildUsageRequest(provider, usageCredential, options?.baseUrl), {
3802
+ timeoutMs: options?.timeoutMs ?? this.#usageRequestTimeoutMs,
3803
+ });
3737
3804
  }
3738
3805
 
3739
3806
  /**
@@ -3927,6 +3994,37 @@ export class AuthStorage {
3927
3994
  return true;
3928
3995
  }
3929
3996
 
3997
+ /**
3998
+ * Fetch every requested report, keeping normal polls parallel while a
3999
+ * manually invalidated provider probes accounts one at a time.
4000
+ */
4001
+ #fetchUsageRequests(
4002
+ requests: readonly UsageRequestDescriptor[],
4003
+ serializedProviders: ReadonlySet<Provider>,
4004
+ ): Promise<Array<UsageReport | null>> {
4005
+ const tails = new Map<Provider, Promise<void>>();
4006
+ return Promise.all(
4007
+ requests.map(request => {
4008
+ const forceRefresh = serializedProviders.has(request.provider);
4009
+ if (!forceRefresh) {
4010
+ return this.#fetchUsageCached(request, { timeoutMs: this.#usageRequestTimeoutMs });
4011
+ }
4012
+ const tail = tails.get(request.provider) ?? Promise.resolve();
4013
+ const current = tail.then(() =>
4014
+ this.#fetchUsageCached(request, { timeoutMs: this.#usageRequestTimeoutMs, forceRefresh: true }),
4015
+ );
4016
+ tails.set(
4017
+ request.provider,
4018
+ current.then(
4019
+ () => undefined,
4020
+ () => undefined,
4021
+ ),
4022
+ );
4023
+ return current;
4024
+ }),
4025
+ );
4026
+ }
4027
+
3930
4028
  async fetchUsageReports(options?: {
3931
4029
  baseUrlResolver?: (provider: Provider) => string | undefined;
3932
4030
  /** Caller's cancel signal; only rejects this caller, never the shared upstream fetch. */
@@ -3945,15 +4043,15 @@ export class AuthStorage {
3945
4043
  // dispatch + credential selection) coalesce into one upstream call.
3946
4044
  // Each caller's `signal` only cancels THAT caller's await; the
3947
4045
  // shared upstream fetch runs to completion so peers aren't punished.
3948
- const OVERRIDE_KEY = "__override__";
3949
- let shared = this.#usageReportsInFlight.get(OVERRIDE_KEY);
4046
+ const overrideKey = `__override__\0${this.#usageCacheEpoch}`;
4047
+ let shared = this.#usageReportsInFlight.get(overrideKey);
3950
4048
  if (!shared) {
3951
4049
  // Don't forward the caller signal into the shared fetch — first caller's
3952
4050
  // abort would otherwise cancel the upstream for every peer.
3953
4051
  shared = override().finally(() => {
3954
- this.#usageReportsInFlight.delete(OVERRIDE_KEY);
4052
+ this.#usageReportsInFlight.delete(overrideKey);
3955
4053
  });
3956
- this.#usageReportsInFlight.set(OVERRIDE_KEY, shared);
4054
+ this.#usageReportsInFlight.set(overrideKey, shared);
3957
4055
  }
3958
4056
  const reports = await raceUsageWithSignal(shared, options?.signal);
3959
4057
  if (shouldReconcileStoreHookReports && reports) this.#reconcileCodexUsageBlocksFromReports(reports);
@@ -3973,7 +4071,8 @@ export class AuthStorage {
3973
4071
  // a single decorrelation snapshot for 30s, defeating the jitter (some
3974
4072
  // accounts can be missing from one fetch and present in the next; the
3975
4073
  // aggregate cache freezes whichever set landed first).
3976
- const cacheKey = this.#buildUsageReportsCacheKey(requests);
4074
+ const forcedRefresh = this.#usageForceRefresh(requests);
4075
+ const cacheKey = `${this.#buildUsageReportsCacheKey(requests)}\0${this.#usageCacheEpoch}`;
3977
4076
 
3978
4077
  const inFlight = this.#usageReportsInFlight.get(cacheKey);
3979
4078
  if (inFlight) return inFlight;
@@ -3989,9 +4088,7 @@ export class AuthStorage {
3989
4088
  });
3990
4089
  }
3991
4090
 
3992
- const results = await Promise.all(
3993
- requests.map(request => this.#fetchUsageCached(request, this.#usageRequestTimeoutMs)),
3994
- );
4091
+ const results = await this.#fetchUsageRequests(requests, forcedRefresh.providers);
3995
4092
  const reports = results.filter((report): report is UsageReport => report !== null);
3996
4093
  const deduped = this.#dedupeUsageReports(reports);
3997
4094
  // no outer cache write — see comment above.
@@ -4012,6 +4109,7 @@ export class AuthStorage {
4012
4109
  };
4013
4110
  }),
4014
4111
  });
4112
+ this.#clearUsageForceRefresh(forcedRefresh);
4015
4113
  return resolved;
4016
4114
  })().finally(() => {
4017
4115
  this.#usageReportsInFlight.delete(cacheKey);
@@ -5729,31 +5827,32 @@ export class AuthStorage {
5729
5827
  }
5730
5828
 
5731
5829
  /**
5732
- * Force-invalidate cached usage reports so the next fetch retrieves fresh
5733
- * values from upstream providers. If `provider` is specified, only that
5734
- * provider's credentials are invalidated; otherwise, all credentials in the
5735
- * store are invalidated.
5830
+ * Drop report snapshots for a user-requested refresh so a failed probe
5831
+ * cannot replay the pre-invalidation last-good value. The persisted marker
5832
+ * makes the next same-provider refresh serial rather than a cold fan-out.
5736
5833
  */
5737
- async invalidateUsageCache(provider?: string, signal?: AbortSignal): Promise<void> {
5738
- if (provider) {
5739
- this.#invalidateUsageReportCache(provider);
5740
- } else {
5741
- this.#usageCacheEpoch += 1;
5742
- const expired = Date.now() - 1;
5743
- try {
5744
- const credentials = this.#store.listAuthCredentials();
5745
- for (const entry of credentials) {
5746
- if (entry.credential.type !== "oauth") continue;
5747
- const cacheKey = this.#buildUsageReportCacheKey(
5748
- this.#buildUsageRequestForOauth(entry.provider, entry.credential),
5749
- );
5750
- const existing = this.#usageCache.getStale<UsageReport | null>(cacheKey);
5751
- this.#usageCache.set(cacheKey, { value: existing?.value ?? null, expiresAt: expired });
5752
- }
5753
- } catch (err) {
5754
- logger.debug("Failed to list auth credentials for complete usage cache invalidation", { err });
5834
+ async #clearUsageReportCache(provider?: string): Promise<void> {
5835
+ this.#usageCacheEpoch += 1;
5836
+ const prefix = provider ? `report:${this.#usageCacheProviderKey(provider)}:` : "report:";
5837
+ if (!this.#usageCache.deletePrefix(prefix)) {
5838
+ // Third-party stores may not support prefix deletion. Clear every active
5839
+ // request key instead, including API-key and environment credentials.
5840
+ const requests = await this.#collectUsageRequests();
5841
+ for (const request of requests) {
5842
+ if (provider && request.provider !== provider) continue;
5843
+ this.#usageCache.set(this.#buildUsageReportCacheKey(request), { value: null, expiresAt: 0 });
5755
5844
  }
5756
5845
  }
5846
+ if (!this.#fetchUsageReportsOverride && !this.#store.fetchUsageReports) this.#markUsageForceRefresh(provider);
5847
+ }
5848
+
5849
+ /**
5850
+ * Discard cached usage reports before a user-requested refresh. The next
5851
+ * read probes upstream serially per provider; a failure reports no fresh
5852
+ * usage instead of replaying an invalidated last-good snapshot.
5853
+ */
5854
+ async invalidateUsageCache(provider?: string, signal?: AbortSignal): Promise<void> {
5855
+ await this.#clearUsageReportCache(provider);
5757
5856
 
5758
5857
  if (this.#store.invalidateUsageCache) {
5759
5858
  await this.#store.invalidateUsageCache(signal).catch(err => {
@@ -24,6 +24,7 @@ export const Flag = {
24
24
  StaleResponsesItem: 0x0010_0000,
25
25
  MalformedFunctionCall: 0x0020_0000,
26
26
  ProviderFinishError: 0x0040_0000,
27
+ EmptyResponse: 0x0000_2000,
27
28
  ContentBlocked: 0x0000_8000,
28
29
  /** Account-scoped provider policy denial that may succeed with another credential. */
29
30
  AccountPolicy: 0x0000_4000,
@@ -50,6 +51,7 @@ const KIND_MASK =
50
51
  Flag.StaleResponsesItem |
51
52
  Flag.MalformedFunctionCall |
52
53
  Flag.ProviderFinishError |
54
+ Flag.EmptyResponse |
53
55
  Flag.ContentBlocked |
54
56
  Flag.AccountPolicy |
55
57
  Flag.ContextOverflow |
@@ -62,7 +64,12 @@ const KIND_MASK =
62
64
  Flag.OAuthExpiry;
63
65
 
64
66
  const RETRIABLE_KINDS =
65
- Flag.Transient | Flag.UsageLimit | Flag.ThinkingLoop | Flag.StaleResponsesItem | Flag.ProviderFinishError;
67
+ Flag.Transient |
68
+ Flag.UsageLimit |
69
+ Flag.ThinkingLoop |
70
+ Flag.StaleResponsesItem |
71
+ Flag.ProviderFinishError |
72
+ Flag.EmptyResponse;
66
73
 
67
74
  const OVERFLOW_PATTERNS = [
68
75
  /prompt is too long/i, // Anthropic
@@ -104,6 +111,7 @@ const AUTH_FAILURE_PATTERN =
104
111
  /\b(?:401|403|unauthorized|forbidden|authentication|auth[_ ]?unavailable|no auth available|(?:invalid|no)[_ ]?api[_ ]?key)\b/i;
105
112
  const MALFORMED_FUNCTION_CALL_PATTERN = /\bmalformed.?function.?call\b/i;
106
113
  const PROVIDER_FINISH_ERROR_PATTERN = /\bProvider (?:returned error finish_reason|finish_reason:\s*error)\b/i;
114
+ const EMPTY_RESPONSE_PATTERN = /\bthought-only response without final output\b/i;
107
115
  const CONTENT_FILTER_PATTERN = /\b(?:incomplete:\s*)?content_filter\b/i;
108
116
  const ACCOUNT_POLICY_PATTERN = /\bcyber_policy\b|trusted access for cyber/i;
109
117
  const STALE_RESPONSE_ITEM_PATTERNS = [/\bItem with id ['"][^'"]+['"] not found\.?/i, /previous[ _]?response/i] as const;
@@ -197,6 +205,7 @@ const ERROR_KIND_LABELS: readonly [Flag, string][] = [
197
205
  [Flag.StaleResponsesItem, "stale-responses-item"],
198
206
  [Flag.MalformedFunctionCall, "malformed-function-call"],
199
207
  [Flag.ProviderFinishError, "provider-finish-error"],
208
+ [Flag.EmptyResponse, "empty-response"],
200
209
  [Flag.ContentBlocked, "content-blocked"],
201
210
  [Flag.AccountPolicy, "account-policy"],
202
211
  [Flag.ContextOverflow, "context-overflow"],
@@ -340,6 +349,7 @@ function classifyText(errorMessage: string | undefined, errorStatus: number | un
340
349
  if (matchesOverflowText(errorMessage)) kinds |= Flag.ContextOverflow;
341
350
  if (isMalformedFunctionCallText(errorMessage)) kinds |= Flag.MalformedFunctionCall;
342
351
  if (isProviderFinishErrorText(errorMessage)) kinds |= Flag.ProviderFinishError;
352
+ if (EMPTY_RESPONSE_PATTERN.test(errorMessage)) kinds |= Flag.EmptyResponse | Flag.Transient;
343
353
  if (isContentBlockedText(errorMessage)) kinds |= Flag.ContentBlocked;
344
354
  if (ACCOUNT_POLICY_PATTERN.test(errorMessage)) kinds |= Flag.AccountPolicy | Flag.ContentBlocked;
345
355
  if (isAuthFailureText(errorMessage)) kinds |= Flag.AuthFailed;
@@ -9,6 +9,8 @@ export type ProviderResponseErrorKind =
9
9
  | "output"
10
10
  /** Response body was empty/missing when content was required. */
11
11
  | "empty-body"
12
+ /** Response completed without actionable output (for example, thoughts only). */
13
+ | "empty-output"
12
14
  /** Malformed wire envelope (unexpected message ordering / shape). */
13
15
  | "envelope"
14
16
  /** Content was blocked by a provider safety filter. */
@@ -38,11 +40,10 @@ export class ProviderResponseError extends Error {
38
40
  this.kind = options.kind ?? "output";
39
41
  // A safety filter block is terminal and intentionally non-retryable.
40
42
  if (this.kind === "content-blocked") attach(this, create(Flag.ContentBlocked));
41
- // An incomplete stream (connection dropped / truncated before any terminal
42
- // event) or an empty body never produced any content the request didn't
43
- // complete, so it is safe to retry and eligible for model fallback. The
44
- // retry layer's replay-unsafe guard still blocks a retry when partial tool
45
- // output was already emitted.
43
+ // A logically empty completed output needs a session-level reminder that
44
+ // asks for the missing final answer. Empty bodies and incomplete streams
45
+ // stay on the generic transient retry/model-fallback path.
46
+ else if (this.kind === "empty-output") attach(this, create(Flag.Transient, Flag.EmptyResponse));
46
47
  else if (this.kind === "incomplete-stream" || this.kind === "empty-body") attach(this, create(Flag.Transient));
47
48
  }
48
49
  }
@@ -622,11 +622,8 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
622
622
  const isFlashLeakModel = model.id.includes("flash");
623
623
 
624
624
  let started = false;
625
- // Tracks whether *visible* content (text delta or tool call) has been
626
- // pushed downstream. `started` alone is a poor failover guard because a
627
- // hidden thought part also flips it (via `ensureStarted`); a thinking-only
628
- // STOP must still fail over to the alternate Antigravity endpoint (#8480).
629
- let emittedVisibleContent = false;
625
+ // Once any stream event starts, the endpoint is committed downstream.
626
+ // Failover remains safe only while `started` is false.
630
627
  let sawFinishReason = false;
631
628
  let lastResponseId: string | undefined;
632
629
  const ensureStarted = () => {
@@ -705,7 +702,6 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
705
702
 
706
703
  const emitVisibleText = (delta: string, thoughtSignature?: string): void => {
707
704
  if (!delta) return;
708
- emittedVisibleContent = true;
709
705
  const block = startTextBlock();
710
706
  block.text += delta;
711
707
  block.textSignature = retainThoughtSignature(block.textSignature, thoughtSignature);
@@ -864,7 +860,6 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
864
860
  };
865
861
 
866
862
  output.content.push(toolCall);
867
- emittedVisibleContent = true;
868
863
  ensureStarted();
869
864
  pushToolCallEvents(toolCall, blockIndex(), output, stream);
870
865
  }
@@ -932,13 +927,17 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
932
927
  };
933
928
 
934
929
  let receivedContent = false;
930
+ const hasThinkingOutput = () =>
931
+ output.content.some(
932
+ block =>
933
+ block.type === "thinking" && (block.thinking.trim().length > 0 || Boolean(block.thinkingSignature)),
934
+ );
935
935
 
936
936
  for (let i = 0; i < endpoints.length; i++) {
937
937
  const endpoint = endpoints[i];
938
938
  const isLastEndpoint = i === endpoints.length - 1;
939
939
  try {
940
940
  started = false;
941
- emittedVisibleContent = false;
942
941
  resetOutput();
943
942
 
944
943
  // Per attempt: arm a pre-response (TTFT) timer, cleared the instant
@@ -1022,17 +1021,26 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
1022
1021
  }
1023
1022
 
1024
1023
  const streamed = await streamResponse(currentResponse);
1025
- // Only accept an empty STOP as valid silence once every fallback
1026
- // endpoint is exhausted: an earlier endpoint returning empty
1027
- // successful streams must still fail over (Antigravity auto mode)
1028
- // rather than be recorded as a real silent review.
1024
+ // Eventless silence may fail over to the alternate Antigravity
1025
+ // endpoint. Once thinking has streamed, the endpoint is already
1026
+ // committed downstream; Advisor mode may accept that silence,
1027
+ // while normal sessions surface it to final-output recovery.
1028
+ const thoughtOnly = hasThinkingOutput();
1029
1029
  const acceptedSilence =
1030
- options?.acceptEmptyResponse === true && !streamed.strippedPlanningLeak && isLastEndpoint;
1030
+ options?.acceptEmptyResponse === true &&
1031
+ !streamed.strippedPlanningLeak &&
1032
+ (isLastEndpoint || thoughtOnly);
1031
1033
  if (output.stopReason !== "stop" || streamed.meaningful || acceptedSilence) {
1032
1034
  receivedContent = streamed.meaningful || acceptedSilence;
1033
1035
  break;
1034
1036
  }
1035
1037
 
1038
+ // A thought-only STOP is a complete provider response, not a
1039
+ // transiently empty transport. Replaying the identical request
1040
+ // burns another full reasoning pass; let session recovery add
1041
+ // an explicit final-output reminder instead.
1042
+ if (thoughtOnly) break;
1043
+
1036
1044
  if (emptyAttempt < MAX_EMPTY_STREAM_RETRIES) {
1037
1045
  resetOutput();
1038
1046
  }
@@ -1046,10 +1054,16 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
1046
1054
  }
1047
1055
 
1048
1056
  if (!receivedContent) {
1049
- throw new AIError.ProviderResponseError("Cloud Code Assist API returned an empty response", {
1050
- provider: model.provider,
1051
- kind: "empty-body",
1052
- });
1057
+ const thoughtOnly = hasThinkingOutput();
1058
+ throw new AIError.ProviderResponseError(
1059
+ thoughtOnly
1060
+ ? "Cloud Code Assist API returned a thought-only response without final output"
1061
+ : "Cloud Code Assist API returned an empty response",
1062
+ {
1063
+ provider: model.provider,
1064
+ kind: thoughtOnly ? "empty-output" : "empty-body",
1065
+ },
1066
+ );
1053
1067
  }
1054
1068
 
1055
1069
  if (options?.signal?.aborted) {
@@ -1081,7 +1095,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = (
1081
1095
  const status = extractHttpStatusFromError(error);
1082
1096
  if (
1083
1097
  !isLastEndpoint &&
1084
- !emittedVisibleContent &&
1098
+ !started &&
1085
1099
  (AIError.isTransientStatus(status) ||
1086
1100
  (status === undefined &&
1087
1101
  !(error instanceof AIError.ProviderResponseError && error.kind === "output") &&
@@ -86,9 +86,13 @@ export async function loginAlibabaTokenPlan(options: OAuthController): Promise<s
86
86
  fetch: options.fetch,
87
87
  });
88
88
 
89
+ const cookieRequestHost =
90
+ baseUrl === ALIBABA_TOKEN_PLAN_CN_BASE_URL ? "bailian-cs.console.aliyun.com" : "cs-data.qwencloud.com";
89
91
  const rawCookie = await options.onPrompt({
90
92
  message:
91
- "Optional quota reporting: open browser DevTools → Network, reload the Token Plan page, filter for api.json, and select the cs-data.qwencloud.com/data/api.json request whose api query ends in /tokenplan/personal/api/v2/usage. Copy Request Headers → Cookie, then paste the complete name=value; ... value here, or press Enter to skip.",
93
+ baseUrl === ALIBABA_TOKEN_PLAN_CN_BASE_URL
94
+ ? "Optional quota reporting: open browser DevTools → Network, reload the Token Plan page, filter for api.json, and select the bailian-cs.console.aliyun.com/data/api.json request whose api query ends in /tokenplan/personal/api/v2/usage. Copy Request Headers → Cookie, then paste the complete name=value; ... value here, or press Enter to skip."
95
+ : "Optional quota reporting: open browser DevTools → Network, reload the Token Plan page, filter for api.json, and select the cs-data.qwencloud.com/data/api.json request whose api query ends in /tokenplan/personal/api/v2/usage. Copy Request Headers → Cookie, then paste the complete name=value; ... value here, or press Enter to skip.",
92
96
  placeholder: "name=value; name=value; ...",
93
97
  allowEmpty: true,
94
98
  });
@@ -107,7 +111,7 @@ export async function loginAlibabaTokenPlan(options: OAuthController): Promise<s
107
111
  })
108
112
  ) {
109
113
  throw new AIError.ConfigurationError(
110
- "Invalid QwenCloud Cookie header. Copy the complete Cookie request header from the cs-data.qwencloud.com usage request, not a single cookie value.",
114
+ `Invalid QwenCloud Cookie header. Copy the complete Cookie request header from the ${cookieRequestHost} usage request, not a single cookie value.`,
111
115
  );
112
116
  }
113
117
 
@@ -5,10 +5,10 @@ import { scheduler } from "node:timers/promises";
5
5
  import { getBundledModels } from "@oh-my-pi/pi-catalog/models";
6
6
  import {
7
7
  COPILOT_API_HEADERS,
8
+ discoverGitHubCopilotApiEndpoint,
8
9
  getGitHubCopilotBaseUrl,
9
10
  isPublicGitHubHost,
10
11
  normalizeDomain,
11
- normalizeGitHubCopilotApiEndpoint,
12
12
  normalizeGitHubCopilotEnterpriseDomain,
13
13
  OPENCODE_HEADERS,
14
14
  } from "@oh-my-pi/pi-catalog/wire/github-copilot";
@@ -226,27 +226,6 @@ export function refreshGitHubCopilotToken(
226
226
  };
227
227
  }
228
228
 
229
- async function discoverGitHubCopilotApiEndpoint(token: string, fetchImpl: FetchImpl): Promise<string | undefined> {
230
- try {
231
- const data = await fetchJson(
232
- "https://api.github.com/copilot_internal/user",
233
- {
234
- headers: {
235
- Accept: "application/json",
236
- Authorization: `token ${token}`,
237
- ...OPENCODE_HEADERS,
238
- },
239
- },
240
- fetchImpl,
241
- );
242
- if (!data || typeof data !== "object") return undefined;
243
- const endpoints = (data as { endpoints?: { api?: unknown } }).endpoints;
244
- return typeof endpoints?.api === "string" ? normalizeGitHubCopilotApiEndpoint(endpoints.api) : undefined;
245
- } catch {
246
- return undefined;
247
- }
248
- }
249
-
250
229
  /**
251
230
  * Enable a model for the user's GitHub Copilot account.
252
231
  * This is required for some models (like Claude, Grok) before they can be used.
@@ -1,5 +1,8 @@
1
1
  import { toNumber } from "@oh-my-pi/pi-catalog/utils";
2
- import { parseAlibabaTokenPlanCredential } from "@oh-my-pi/pi-catalog/wire/alibaba-token-plan";
2
+ import {
3
+ ALIBABA_TOKEN_PLAN_CN_BASE_URL,
4
+ parseAlibabaTokenPlanCredential,
5
+ } from "@oh-my-pi/pi-catalog/wire/alibaba-token-plan";
3
6
  import type {
4
7
  CredentialRankingStrategy,
5
8
  UsageFetchContext,
@@ -12,21 +15,45 @@ import { isRecord } from "../utils";
12
15
  import { HOUR_MS, parsePositiveTimestamp, WEEK_MS } from "./shared";
13
16
 
14
17
  const PROVIDER = "alibaba-token-plan";
15
- const CONSOLE_ORIGIN = "https://home.qwencloud.com";
16
- const DASHBOARD_URL = `${CONSOLE_ORIGIN}/billing/subscription/token-plan-individual`;
17
- const USER_INFO_URL = `${CONSOLE_ORIGIN}/tool/user/info.json`;
18
- const GATEWAY_ACTION = "IntlBroadScopeAspnGateway";
19
18
  const USAGE_API = "zeldaHttp.apikeyMgr./tokenplan/personal/api/v2/usage";
20
- const USAGE_URL = `https://cs-data.qwencloud.com/data/api.json?product=sfm_bailian&action=${GATEWAY_ACTION}&api=${encodeURIComponent(USAGE_API)}`;
21
19
  const BROWSER_USER_AGENT =
22
20
  "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36";
23
- const CONSOLE_CORNERSTONE_PARAM = {
24
- domain: "home.qwencloud.com",
25
- consoleSite: "QWENCLOUD",
26
- console: "ONE_CONSOLE",
27
- xsp_lang: "en-US",
28
- protocol: "V2",
29
- productCode: "p_efm",
21
+ const INTERNATIONAL_CONSOLE = {
22
+ origin: "https://home.qwencloud.com",
23
+ dashboardUrl: "https://home.qwencloud.com/billing/subscription/token-plan-individual",
24
+ sessionUrl: "https://home.qwencloud.com/tool/user/info.json",
25
+ gatewayAction: "IntlBroadScopeAspnGateway",
26
+ region: "ap-southeast-1",
27
+ usageUrl: `https://cs-data.qwencloud.com/data/api.json?product=sfm_bailian&action=IntlBroadScopeAspnGateway&api=${encodeURIComponent(USAGE_API)}`,
28
+ cornerstoneParam: {
29
+ domain: "home.qwencloud.com",
30
+ consoleSite: "QWENCLOUD",
31
+ console: "ONE_CONSOLE",
32
+ xsp_lang: "en-US",
33
+ protocol: "V2",
34
+ productCode: "p_efm",
35
+ },
36
+ } as const;
37
+ const CHINA_CONSOLE = {
38
+ origin: "https://bailian.console.aliyun.com",
39
+ dashboardUrl: "https://bailian.console.aliyun.com/cn-beijing?tab=plan",
40
+ sessionUrl: "https://bailian.console.aliyun.com/cn-beijing?tab=plan",
41
+ gatewayAction: "BroadScopeAspnGateway",
42
+ region: "cn-beijing",
43
+ usageUrl: `https://bailian-cs.console.aliyun.com/data/api.json?action=BroadScopeAspnGateway&product=sfm_bailian&api=${encodeURIComponent(USAGE_API)}`,
44
+ cornerstoneParam: {
45
+ feURL: "https://bailian.console.aliyun.com/cn-beijing?tab=plan#/efm/subscription/token-plan/personal",
46
+ protocol: "V2",
47
+ console: "ONE_CONSOLE",
48
+ productCode: "p_efm",
49
+ switchAgent: 12608464,
50
+ switchUserType: 3,
51
+ domain: "bailian.console.aliyun.com",
52
+ consoleSite: "BAILIAN_ALIYUN",
53
+ userNickName: "",
54
+ userPrincipalName: "",
55
+ xsp_lang: "zh-CN",
56
+ },
30
57
  } as const;
31
58
 
32
59
  function extractCookieValue(header: string, name: string): string | undefined {
@@ -102,35 +129,56 @@ async function fetchAlibabaTokenPlanUsage(
102
129
  const credential = parseAlibabaTokenPlanCredential(params.credential.apiKey);
103
130
  if (!credential?.cookie) return null;
104
131
  const cookie = credential.cookie;
132
+ const isChina = credential.baseUrl === ALIBABA_TOKEN_PLAN_CN_BASE_URL;
133
+ const consoleConfig = isChina ? CHINA_CONSOLE : INTERNATIONAL_CONSOLE;
105
134
 
106
135
  try {
107
- const userResponse = await ctx.fetch(USER_INFO_URL, {
136
+ const sessionResponse = await ctx.fetch(consoleConfig.sessionUrl, {
108
137
  headers: {
109
- Accept: "application/json, text/plain, */*",
138
+ Accept: isChina
139
+ ? "text/html,application/xhtml+xml,application/json;q=0.9,*/*;q=0.8"
140
+ : "application/json, text/plain, */*",
110
141
  Cookie: cookie,
111
- Referer: `${CONSOLE_ORIGIN}/`,
142
+ Referer: `${consoleConfig.origin}/`,
112
143
  "User-Agent": BROWSER_USER_AGENT,
113
144
  },
114
145
  redirect: "manual",
115
146
  signal: params.signal,
116
147
  });
117
- if (!userResponse.ok) {
118
- ctx.logger?.warn("QwenCloud session lookup failed", { provider: PROVIDER, status: userResponse.status });
148
+ if (!sessionResponse.ok) {
149
+ ctx.logger?.warn("Alibaba Token Plan session lookup failed", {
150
+ provider: PROVIDER,
151
+ status: sessionResponse.status,
152
+ });
119
153
  return null;
120
154
  }
121
- const userPayload: unknown = await userResponse.json();
122
- if (!isRecord(userPayload) || !isRecord(userPayload.data) || typeof userPayload.data.secToken !== "string") {
123
- ctx.logger?.warn("QwenCloud session response invalid", { provider: PROVIDER });
124
- return null;
155
+
156
+ let secToken: string | undefined;
157
+ let accountId: string | undefined;
158
+ if (isChina) {
159
+ const html = await sessionResponse.text();
160
+ secToken = /\bSEC_TOKEN\s*:\s*"([^"]+)"/.exec(html)?.[1];
161
+ if (!secToken) {
162
+ ctx.logger?.warn("Alibaba Token Plan China session response invalid", { provider: PROVIDER });
163
+ return null;
164
+ }
165
+ } else {
166
+ const userPayload: unknown = await sessionResponse.json();
167
+ if (!isRecord(userPayload) || !isRecord(userPayload.data) || typeof userPayload.data.secToken !== "string") {
168
+ ctx.logger?.warn("QwenCloud session response invalid", { provider: PROVIDER });
169
+ return null;
170
+ }
171
+ secToken = userPayload.data.secToken;
172
+ accountId = accountIdFromUserData(userPayload.data);
125
173
  }
126
- const secToken = userPayload.data.secToken;
174
+
127
175
  const csrf = extractCookieValue(cookie, "login_aliyunid_csrf") ?? extractCookieValue(cookie, "csrf");
128
176
  const headers: Record<string, string> = {
129
177
  Accept: "application/json, text/plain, */*",
130
178
  "Content-Type": "application/x-www-form-urlencoded",
131
179
  Cookie: cookie,
132
- Origin: CONSOLE_ORIGIN,
133
- Referer: DASHBOARD_URL,
180
+ Origin: consoleConfig.origin,
181
+ Referer: consoleConfig.dashboardUrl,
134
182
  "User-Agent": BROWSER_USER_AGENT,
135
183
  "X-Requested-With": "XMLHttpRequest",
136
184
  };
@@ -140,16 +188,21 @@ async function fetchAlibabaTokenPlanUsage(
140
188
  }
141
189
  const body = new URLSearchParams({
142
190
  product: "sfm_bailian",
143
- action: GATEWAY_ACTION,
144
- region: "ap-southeast-1",
191
+ action: consoleConfig.gatewayAction,
192
+ region: consoleConfig.region,
145
193
  sec_token: secToken,
146
194
  params: JSON.stringify({
147
195
  Api: USAGE_API,
148
- Data: { cornerstoneParam: CONSOLE_CORNERSTONE_PARAM },
196
+ Data: {
197
+ cornerstoneParam: {
198
+ ...(isChina ? { feTraceId: crypto.randomUUID() } : {}),
199
+ ...consoleConfig.cornerstoneParam,
200
+ },
201
+ },
149
202
  V: "1.0",
150
203
  }),
151
204
  });
152
- const usageResponse = await ctx.fetch(USAGE_URL, {
205
+ const usageResponse = await ctx.fetch(consoleConfig.usageUrl, {
153
206
  method: "POST",
154
207
  headers,
155
208
  body,
@@ -157,16 +210,18 @@ async function fetchAlibabaTokenPlanUsage(
157
210
  signal: params.signal,
158
211
  });
159
212
  if (!usageResponse.ok) {
160
- ctx.logger?.warn("QwenCloud usage fetch failed", { provider: PROVIDER, status: usageResponse.status });
213
+ ctx.logger?.warn("Alibaba Token Plan usage fetch failed", {
214
+ provider: PROVIDER,
215
+ status: usageResponse.status,
216
+ });
161
217
  return null;
162
218
  }
163
219
  const payload: unknown = await usageResponse.json();
164
220
  if (!isRecord(payload) || payload.successResponse === false || !isRecord(payload.data)) {
165
- ctx.logger?.warn("QwenCloud usage response invalid", { provider: PROVIDER });
221
+ ctx.logger?.warn("Alibaba Token Plan usage response invalid", { provider: PROVIDER });
166
222
  return null;
167
223
  }
168
224
  const responseData = unwrapGatewayData(payload.data);
169
- const accountId = accountIdFromUserData(userPayload.data);
170
225
  const limits = [
171
226
  buildLimit(
172
227
  "5h",
@@ -190,10 +245,10 @@ async function fetchAlibabaTokenPlanUsage(
190
245
  provider: PROVIDER,
191
246
  fetchedAt: Date.now(),
192
247
  limits,
193
- metadata: { source: "qwencloud-console", ...(accountId ? { accountId } : {}) },
248
+ metadata: { source: isChina ? "bailian-console" : "qwencloud-console", ...(accountId ? { accountId } : {}) },
194
249
  };
195
250
  } catch (error) {
196
- ctx.logger?.warn("QwenCloud usage request failed", {
251
+ ctx.logger?.warn("Alibaba Token Plan usage request failed", {
197
252
  provider: PROVIDER,
198
253
  error: error instanceof Error ? error.name : "unknown",
199
254
  });