@jingyi0605/codingns4dsh 0.2.1-rc.2 → 0.2.1-rc.3

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.
Files changed (202) hide show
  1. package/README.en.md +6 -4
  2. package/README.md +6 -6
  3. package/data/build/dist/bootstrap/dsh-connection-adapter.d.ts +1 -1
  4. package/data/build/dist/bootstrap/index.d.ts +1 -1
  5. package/data/build/dist/client/bundle.js +5306 -493
  6. package/data/build/dist/client/bundle.js.map +1 -1
  7. package/data/build/dist/client/client.magpie-model-tag.js +155 -5
  8. package/data/build/dist/client/client.magpie-model-tag.js.map +1 -1
  9. package/data/build/dist/client/client.native-workspace-store.js +30 -4
  10. package/data/build/dist/client/client.native-workspace-store.js.map +1 -1
  11. package/data/build/dist/client/client.transport.js +725 -48
  12. package/data/build/dist/client/client.transport.js.map +1 -1
  13. package/data/build/dist/client/desktop-assistant.js +931 -185
  14. package/data/build/dist/client/feature-requirement.d.ts +43 -0
  15. package/data/build/dist/client/feature-requirement.d.ts.map +1 -0
  16. package/data/build/dist/client/feature-requirement.js +105 -0
  17. package/data/build/dist/client/feature-requirement.js.map +1 -0
  18. package/data/build/dist/client/features/index.d.ts +1 -0
  19. package/data/build/dist/client/features/index.d.ts.map +1 -1
  20. package/data/build/dist/client/features/index.js +3 -0
  21. package/data/build/dist/client/features/index.js.map +1 -1
  22. package/data/build/dist/client/features/magpie-management-controls.d.ts +46 -0
  23. package/data/build/dist/client/features/magpie-management-controls.d.ts.map +1 -0
  24. package/data/build/dist/client/features/magpie-management-controls.js +759 -0
  25. package/data/build/dist/client/features/magpie-management-controls.js.map +1 -0
  26. package/data/build/dist/client/features/magpie-management-page.d.ts +124 -0
  27. package/data/build/dist/client/features/magpie-management-page.d.ts.map +1 -0
  28. package/data/build/dist/client/features/magpie-management-page.js +1256 -0
  29. package/data/build/dist/client/features/magpie-management-page.js.map +1 -0
  30. package/data/build/dist/client/features/magpie-management.d.ts +7 -0
  31. package/data/build/dist/client/features/magpie-management.d.ts.map +1 -0
  32. package/data/build/dist/client/features/magpie-management.js +154 -0
  33. package/data/build/dist/client/features/magpie-management.js.map +1 -0
  34. package/data/build/dist/client/features/magpie-mark.d.ts +10 -0
  35. package/data/build/dist/client/features/magpie-mark.d.ts.map +1 -0
  36. package/data/build/dist/client/features/magpie-mark.js +21 -0
  37. package/data/build/dist/client/features/magpie-mark.js.map +1 -0
  38. package/data/build/dist/client/features/magpie-provider-logo.d.ts +11 -0
  39. package/data/build/dist/client/features/magpie-provider-logo.d.ts.map +1 -0
  40. package/data/build/dist/client/features/magpie-provider-logo.js +35 -0
  41. package/data/build/dist/client/features/magpie-provider-logo.js.map +1 -0
  42. package/data/build/dist/client/features/subscription-quota-groups.d.ts +193 -0
  43. package/data/build/dist/client/features/subscription-quota-groups.d.ts.map +1 -0
  44. package/data/build/dist/client/features/subscription-quota-groups.js +548 -0
  45. package/data/build/dist/client/features/subscription-quota-groups.js.map +1 -0
  46. package/data/build/dist/client/git-management.d.ts.map +1 -1
  47. package/data/build/dist/client/git-management.js +11 -9
  48. package/data/build/dist/client/git-management.js.map +1 -1
  49. package/data/build/dist/client/git-panel-styles.js +1 -0
  50. package/data/build/dist/client/git-panel-styles.js.map +1 -1
  51. package/data/build/dist/client/index.d.ts +1 -1
  52. package/data/build/dist/client/index.d.ts.map +1 -1
  53. package/data/build/dist/client/index.js +1 -1
  54. package/data/build/dist/client/index.js.map +1 -1
  55. package/data/build/dist/client/locale.d.ts.map +1 -1
  56. package/data/build/dist/client/locale.js +7 -0
  57. package/data/build/dist/client/locale.js.map +1 -1
  58. package/data/build/dist/client/locales/magpie.d.ts +4 -0
  59. package/data/build/dist/client/locales/magpie.d.ts.map +1 -0
  60. package/data/build/dist/client/locales/magpie.js +622 -0
  61. package/data/build/dist/client/locales/magpie.js.map +1 -0
  62. package/data/build/dist/client/locales/usage.d.ts.map +1 -1
  63. package/data/build/dist/client/locales/usage.js +52 -0
  64. package/data/build/dist/client/locales/usage.js.map +1 -1
  65. package/data/build/dist/client/magpie-model-tag.d.ts +9 -1
  66. package/data/build/dist/client/magpie-model-tag.d.ts.map +1 -1
  67. package/data/build/dist/client/magpie-model-tag.js +15 -3
  68. package/data/build/dist/client/magpie-model-tag.js.map +1 -1
  69. package/data/build/dist/client/provider-icons.d.ts +8 -0
  70. package/data/build/dist/client/provider-icons.d.ts.map +1 -1
  71. package/data/build/dist/client/provider-icons.js +122 -2
  72. package/data/build/dist/client/provider-icons.js.map +1 -1
  73. package/data/build/dist/client/settings-bridge.d.ts +1 -0
  74. package/data/build/dist/client/settings-bridge.d.ts.map +1 -1
  75. package/data/build/dist/client/settings-bridge.js +29 -10
  76. package/data/build/dist/client/settings-bridge.js.map +1 -1
  77. package/data/build/dist/client/settings-section.d.ts.map +1 -1
  78. package/data/build/dist/client/settings-section.js +21 -3
  79. package/data/build/dist/client/settings-section.js.map +1 -1
  80. package/data/build/dist/client/style.css +224 -221
  81. package/data/build/dist/client/subscription-slot.d.ts +20 -0
  82. package/data/build/dist/client/subscription-slot.d.ts.map +1 -1
  83. package/data/build/dist/client/subscription-slot.js +321 -18
  84. package/data/build/dist/client/subscription-slot.js.map +1 -1
  85. package/data/build/dist/dsh-capabilities/client/global-panel-adapter.d.ts +27 -0
  86. package/data/build/dist/dsh-capabilities/client/global-panel-adapter.d.ts.map +1 -0
  87. package/data/build/dist/dsh-capabilities/client/global-panel-adapter.js +109 -0
  88. package/data/build/dist/dsh-capabilities/client/global-panel-adapter.js.map +1 -0
  89. package/data/build/dist/dsh-capabilities/host/session-title-adapter.d.ts +2 -1
  90. package/data/build/dist/dsh-capabilities/host/session-title-adapter.d.ts.map +1 -1
  91. package/data/build/dist/dsh-capabilities/host/session-title-adapter.js +32 -2
  92. package/data/build/dist/dsh-capabilities/host/session-title-adapter.js.map +1 -1
  93. package/data/build/dist/dsh-capabilities/matrix.d.ts.map +1 -1
  94. package/data/build/dist/dsh-capabilities/matrix.js +12 -11
  95. package/data/build/dist/dsh-capabilities/matrix.js.map +1 -1
  96. package/data/build/dist/dsh-capabilities/routes.d.ts.map +1 -1
  97. package/data/build/dist/dsh-capabilities/routes.js +12 -9
  98. package/data/build/dist/dsh-capabilities/routes.js.map +1 -1
  99. package/data/build/dist/dsh-capabilities/types.d.ts +1 -1
  100. package/data/build/dist/dsh-capabilities/types.d.ts.map +1 -1
  101. package/data/build/dist/dsh-capabilities/types.js.map +1 -1
  102. package/data/build/dist/host/cli-adapters/acp-cli-driver.d.ts +27 -0
  103. package/data/build/dist/host/cli-adapters/acp-cli-driver.d.ts.map +1 -1
  104. package/data/build/dist/host/cli-adapters/acp-cli-driver.js +88 -13
  105. package/data/build/dist/host/cli-adapters/acp-cli-driver.js.map +1 -1
  106. package/data/build/dist/host/cli-adapters/codex-driver.d.ts +10 -0
  107. package/data/build/dist/host/cli-adapters/codex-driver.d.ts.map +1 -1
  108. package/data/build/dist/host/cli-adapters/codex-driver.js +32 -1
  109. package/data/build/dist/host/cli-adapters/codex-driver.js.map +1 -1
  110. package/data/build/dist/host/cli-adapters/command-code-acp-loader.d.ts +6 -5
  111. package/data/build/dist/host/cli-adapters/command-code-acp-loader.d.ts.map +1 -1
  112. package/data/build/dist/host/cli-adapters/command-code-acp-loader.js +72 -12
  113. package/data/build/dist/host/cli-adapters/command-code-acp-loader.js.map +1 -1
  114. package/data/build/dist/host/cli-adapters/command-code-driver.d.ts.map +1 -1
  115. package/data/build/dist/host/cli-adapters/command-code-driver.js +26 -1
  116. package/data/build/dist/host/cli-adapters/command-code-driver.js.map +1 -1
  117. package/data/build/dist/host/cli-adapters/dsh-message-projector.d.ts.map +1 -1
  118. package/data/build/dist/host/cli-adapters/dsh-message-projector.js +15 -2
  119. package/data/build/dist/host/cli-adapters/dsh-message-projector.js.map +1 -1
  120. package/data/build/dist/host/cli-adapters/feature.d.ts +13 -0
  121. package/data/build/dist/host/cli-adapters/feature.d.ts.map +1 -1
  122. package/data/build/dist/host/cli-adapters/feature.js +390 -6
  123. package/data/build/dist/host/cli-adapters/feature.js.map +1 -1
  124. package/data/build/dist/host/cli-adapters/grok-driver.js +5 -0
  125. package/data/build/dist/host/cli-adapters/grok-driver.js.map +1 -1
  126. package/data/build/dist/host/cli-adapters/magpie-detection.d.ts +72 -0
  127. package/data/build/dist/host/cli-adapters/magpie-detection.d.ts.map +1 -0
  128. package/data/build/dist/host/cli-adapters/magpie-detection.js +298 -0
  129. package/data/build/dist/host/cli-adapters/magpie-detection.js.map +1 -0
  130. package/data/build/dist/host/cli-adapters/magpie-management.d.ts +50 -0
  131. package/data/build/dist/host/cli-adapters/magpie-management.d.ts.map +1 -0
  132. package/data/build/dist/host/cli-adapters/magpie-management.js +1268 -0
  133. package/data/build/dist/host/cli-adapters/magpie-management.js.map +1 -0
  134. package/data/build/dist/host/cli-adapters/magpie-update.d.ts +59 -0
  135. package/data/build/dist/host/cli-adapters/magpie-update.d.ts.map +1 -0
  136. package/data/build/dist/host/cli-adapters/magpie-update.js +249 -0
  137. package/data/build/dist/host/cli-adapters/magpie-update.js.map +1 -0
  138. package/data/build/dist/host/cli-adapters/magpie-usage.d.ts +66 -8
  139. package/data/build/dist/host/cli-adapters/magpie-usage.d.ts.map +1 -1
  140. package/data/build/dist/host/cli-adapters/magpie-usage.js +846 -122
  141. package/data/build/dist/host/cli-adapters/magpie-usage.js.map +1 -1
  142. package/data/build/dist/host/cli-adapters/magpie-web.d.ts +54 -0
  143. package/data/build/dist/host/cli-adapters/magpie-web.d.ts.map +1 -0
  144. package/data/build/dist/host/cli-adapters/magpie-web.js +407 -0
  145. package/data/build/dist/host/cli-adapters/magpie-web.js.map +1 -0
  146. package/data/build/dist/host/cli-adapters/process-utils.d.ts +2 -0
  147. package/data/build/dist/host/cli-adapters/process-utils.d.ts.map +1 -1
  148. package/data/build/dist/host/cli-adapters/process-utils.js +4 -0
  149. package/data/build/dist/host/cli-adapters/process-utils.js.map +1 -1
  150. package/data/build/dist/host/dsh-gateway-feature.d.ts.map +1 -1
  151. package/data/build/dist/host/dsh-gateway-feature.js +26 -3
  152. package/data/build/dist/host/dsh-gateway-feature.js.map +1 -1
  153. package/data/build/dist/host/features/host-status.d.ts +25 -0
  154. package/data/build/dist/host/features/host-status.d.ts.map +1 -1
  155. package/data/build/dist/host/features/host-status.js +60 -6
  156. package/data/build/dist/host/features/host-status.js.map +1 -1
  157. package/data/build/dist/host/features/lan-access-dsh.d.ts.map +1 -1
  158. package/data/build/dist/host/features/lan-access-dsh.js +61 -10
  159. package/data/build/dist/host/features/lan-access-dsh.js.map +1 -1
  160. package/data/build/dist/host/features/session-translation-protection.d.ts.map +1 -1
  161. package/data/build/dist/host/features/session-translation-protection.js +2 -1
  162. package/data/build/dist/host/features/session-translation-protection.js.map +1 -1
  163. package/data/build/dist/host/lan-access-dsh.d.ts.map +1 -1
  164. package/data/build/dist/host/lan-access-dsh.js +6 -2
  165. package/data/build/dist/host/lan-access-dsh.js.map +1 -1
  166. package/data/build/dist/host/modules/pwa/pwa-session-notifications.js +2 -1
  167. package/data/build/dist/host/modules/pwa/pwa-session-notifications.js.map +1 -1
  168. package/data/build/dist/host/rpc.d.ts +15 -2
  169. package/data/build/dist/host/rpc.d.ts.map +1 -1
  170. package/data/build/dist/host/rpc.js +47 -6
  171. package/data/build/dist/host/rpc.js.map +1 -1
  172. package/data/build/dist/host/settings.d.ts.map +1 -1
  173. package/data/build/dist/host/settings.js +5 -0
  174. package/data/build/dist/host/settings.js.map +1 -1
  175. package/data/build/dist/shared/contracts/config.d.ts +18 -0
  176. package/data/build/dist/shared/contracts/config.d.ts.map +1 -1
  177. package/data/build/dist/shared/contracts/config.js +8 -0
  178. package/data/build/dist/shared/contracts/config.js.map +1 -1
  179. package/data/build/dist/shared/contracts/errors.d.ts +2 -2
  180. package/data/build/dist/shared/contracts/feature.d.ts +7 -0
  181. package/data/build/dist/shared/contracts/feature.d.ts.map +1 -1
  182. package/data/build/dist/shared/contracts/magpie-management.d.ts +395 -0
  183. package/data/build/dist/shared/contracts/magpie-management.d.ts.map +1 -0
  184. package/data/build/dist/shared/contracts/magpie-management.js +2 -0
  185. package/data/build/dist/shared/contracts/magpie-management.js.map +1 -0
  186. package/data/build/dist/shared/contracts/subscription.d.ts +75 -1
  187. package/data/build/dist/shared/contracts/subscription.d.ts.map +1 -1
  188. package/data/build/dist/shared/contracts/subscription.js.map +1 -1
  189. package/data/build/dist/shared/contracts/version.d.ts +4 -4
  190. package/data/build/dist/shared/contracts/version.js +3 -3
  191. package/data/build/dist/shared/index.d.ts +2 -1
  192. package/data/build/dist/shared/index.d.ts.map +1 -1
  193. package/data/build/dist/shared/index.js +1 -1
  194. package/data/build/dist/shared/index.js.map +1 -1
  195. package/data/build/dist/shared/session-title.d.ts.map +1 -1
  196. package/data/build/dist/shared/session-title.js +10 -4
  197. package/data/build/dist/shared/session-title.js.map +1 -1
  198. package/package.json +29 -29
  199. package/profile/README.md +2 -2
  200. package/profile/package.json +3 -3
  201. package/profile/version.json +3 -3
  202. package/version.json +3 -3
@@ -7,8 +7,10 @@ const MAX_MAGPIE_MODEL_METADATA_CACHE_ENTRIES = 4;
7
7
  const MAGPIE_MINIMUM_CLI_VERSION = [0, 1, 1295];
8
8
  const MAGPIE_CLI_TIMEOUT_MS = 10_000;
9
9
  const MAGPIE_HTTP_TIMEOUT_MS = 3_000;
10
+ const MAGPIE_DASHBOARD_CACHE_TTL_MS = 15_000;
10
11
  const MAX_MAGPIE_CLI_OUTPUT_BYTES = 512 * 1024;
11
12
  const MAX_MAGPIE_RESPONSE_BYTES = 2 * 1024 * 1024;
13
+ const MAX_MAGPIE_ICON_FETCHES = 64;
12
14
  const MAX_MAGPIE_MODELS = 10_000;
13
15
  const MAX_MODEL_TOKEN_LIMIT = 1_000_000_000;
14
16
  const MAGPIE_MODEL_ID_PATTERN = '[A-Za-z0-9][A-Za-z0-9._-]*(?:/[A-Za-z0-9][A-Za-z0-9._:/-]*)+';
@@ -39,11 +41,7 @@ const MAGPIE_USAGE_AGENT_ALIASES = {
39
41
  // usage ledger, while its verified model catalog uses `commandcode`.
40
42
  commandcode: ['commandcode', 'codingns4dsh'],
41
43
  };
42
- /**
43
- * Reads Magpie's public local Gateway API only after its CLI confirms that the
44
- * current Agent model is in that Agent's catalog. No Agent or Magpie config
45
- * files are opened here; the provider session ID is supplied by the Host.
46
- */
44
+ /** Reads usage from the authenticated local Magpie Web API and exact-matches session identity. */
47
45
  export class MagpieUsageService {
48
46
  readModels;
49
47
  readVersion;
@@ -51,6 +49,8 @@ export class MagpieUsageService {
51
49
  now;
52
50
  timeoutMs;
53
51
  lifetimeSignal;
52
+ webApi;
53
+ onDashboardTiming;
54
54
  /** Agent CLI verification remains isolated; model metadata below is Gateway-wide and shared. */
55
55
  catalogs = new Map();
56
56
  catalogLoads = new Map();
@@ -59,6 +59,9 @@ export class MagpieUsageService {
59
59
  modelMetadataLoads = new Map();
60
60
  versionCheck;
61
61
  versionLoad;
62
+ dashboardCache = new Map();
63
+ dashboardRefreshes = new Map();
64
+ quotaLogoCache = new Map();
62
65
  constructor(options = {}) {
63
66
  this.readModels = options.readModels ?? readMagpieModels;
64
67
  // An injected catalog reader represents the whole fake CLI boundary in
@@ -70,6 +73,8 @@ export class MagpieUsageService {
70
73
  this.now = options.now ?? Date.now;
71
74
  this.timeoutMs = options.timeoutMs ?? MAGPIE_HTTP_TIMEOUT_MS;
72
75
  this.lifetimeSignal = options.lifetimeSignal;
76
+ this.webApi = options.webApi;
77
+ this.onDashboardTiming = options.onDashboardTiming;
73
78
  }
74
79
  async read(session, signal, includeRoute = false, dshSessionId) {
75
80
  signal?.throwIfAborted();
@@ -77,10 +82,6 @@ export class MagpieUsageService {
77
82
  const requestedModelId = cleanText(session.modelId);
78
83
  if (agent === null || requestedModelId === null)
79
84
  return null;
80
- const catalog = await this.loadCatalog(agent, signal);
81
- const modelId = resolveCatalogModelId(agent, requestedModelId, catalog, requestedModelId);
82
- if (catalog === null || modelId === null)
83
- return null;
84
85
  const providerSessionId = cleanText(session.providerSessionId);
85
86
  const nativeSessionId = cleanText(dshSessionId);
86
87
  // Native DSH requests use the DSH session ID as their Magpie session
@@ -88,7 +89,19 @@ export class MagpieUsageService {
88
89
  const sessionId = providerSessionId !== null && providerSessionId !== nativeSessionId
89
90
  ? providerSessionId
90
91
  : agent === 'dsh' ? nativeSessionId : null;
91
- return this.readFromGateway(agent, requestedModelId, catalog.gatewayOrigin, sessionId, signal, includeRoute);
92
+ // Command Code's ACP child tags loopback Magpie requests with the DSH
93
+ // session ID injected by the Host, so the ledger key differs from the ACP
94
+ // session ID shown in the panel. Older Hosts without that ID keep the
95
+ // provider ID so the panel still reports the same "no usage" state.
96
+ const magpieSessionId = agent === 'commandcode' ? nativeSessionId ?? sessionId : sessionId;
97
+ // During a rolling update the ACP child may still tag requests with its ACP
98
+ // session ID; fall back to it when the DSH-ID ledger row is absent.
99
+ const fallbackSessionId = agent === 'commandcode' && nativeSessionId !== null && sessionId !== null && nativeSessionId !== sessionId
100
+ ? sessionId
101
+ : null;
102
+ if (magpieSessionId === null)
103
+ return null;
104
+ return this.readFromWeb(agent, requestedModelId, sessionId, magpieSessionId, fallbackSessionId, signal, includeRoute);
92
105
  }
93
106
  /**
94
107
  * Mark Magpie models and enrich their public metadata. The marker is
@@ -174,47 +187,201 @@ export class MagpieUsageService {
174
187
  const metadata = await this.loadModelMetadata(catalog, signal);
175
188
  return metadata?.get(id) ?? null;
176
189
  }
177
- async readFromGateway(agent, modelId, gatewayOrigin, sessionId, signal, includeRoute) {
190
+ /** Read bounded, graph-ready usage and quota data through authenticated Magpie Web. */
191
+ async readDashboard(period, signal) {
192
+ signal?.throwIfAborted();
193
+ const cached = this.dashboardCache.get(period);
194
+ if (cached !== undefined) {
195
+ if (this.now() - cached.loadedAt >= MAGPIE_DASHBOARD_CACHE_TTL_MS) {
196
+ void this.refreshDashboard(period).catch(() => undefined);
197
+ }
198
+ return cached.dashboard;
199
+ }
200
+ return this.refreshDashboard(period, signal);
201
+ }
202
+ /** Stream cumulative dashboard snapshots as each independent Web API part is ready. */
203
+ async *readDashboardStream(period, signal, force = false) {
204
+ signal?.throwIfAborted();
205
+ const cached = this.dashboardCache.get(period);
206
+ if (!force && cached !== undefined) {
207
+ const stale = this.now() - cached.loadedAt >= MAGPIE_DASHBOARD_CACHE_TTL_MS;
208
+ yield { part: 'cache', dashboard: cached.dashboard, stale };
209
+ if (!stale)
210
+ return;
211
+ }
212
+ let latest;
213
+ for await (const chunk of this.readDashboardNetwork(period, signal)) {
214
+ latest = chunk.dashboard;
215
+ yield chunk;
216
+ }
217
+ if (latest !== undefined)
218
+ this.dashboardCache.set(period, { dashboard: latest, loadedAt: this.now() });
219
+ }
220
+ async refreshDashboard(period, signal) {
221
+ const pending = this.dashboardRefreshes.get(period);
222
+ if (pending !== undefined)
223
+ return awaitWithSignal(pending, signal);
224
+ const request = (async () => {
225
+ let latest;
226
+ for await (const chunk of this.readDashboardNetwork(period, signal))
227
+ latest = chunk.dashboard;
228
+ if (latest === undefined)
229
+ throw new Error('Magpie dashboard returned no snapshot');
230
+ this.dashboardCache.set(period, { dashboard: latest, loadedAt: this.now() });
231
+ return latest;
232
+ })().finally(() => {
233
+ if (this.dashboardRefreshes.get(period) === request)
234
+ this.dashboardRefreshes.delete(period);
235
+ });
236
+ this.dashboardRefreshes.set(period, request);
237
+ return awaitWithSignal(request, signal);
238
+ }
239
+ async *readDashboardNetwork(period, signal) {
240
+ const capturedAt = new Date(this.now()).toISOString();
241
+ let dashboard = emptyMagpieDashboard(period, capturedAt);
242
+ let quotas = null;
243
+ let logoLoad;
244
+ const results = new Map([
245
+ ['usage', this.timedDashboardRead('usage', () => this.readWebJson(`/api/usage?period=${magpieWebPeriod(period)}`, signal))],
246
+ ['quotas', this.timedDashboardRead('quotas', () => this.readWebJson('/api/usage/quotas', signal))],
247
+ ['history', this.timedDashboardRead('history', () => this.readWebJson('/api/usage/quotas/history?days=30', signal))],
248
+ ]);
249
+ const completed = new Map();
250
+ while (results.size > 0) {
251
+ const result = await Promise.race(results.values());
252
+ results.delete(result.part);
253
+ const response = asRecord(result.value);
254
+ const partOk = result.ok && response?.ok === true;
255
+ const partValue = partOk ? response.value : undefined;
256
+ completed.set(result.part, partOk);
257
+ if (result.part === 'usage') {
258
+ const parsed = partOk ? parseMagpieDashboardUsage(partValue) : null;
259
+ dashboard = {
260
+ ...dashboard,
261
+ usageStatus: parsed === null ? 'unavailable' : 'available',
262
+ aggregate: parsed?.aggregate ?? null,
263
+ series: parsed?.series ?? [],
264
+ agents: parsed?.agents ?? [],
265
+ providers: parsed?.providers ?? [],
266
+ models: parsed?.models ?? [],
267
+ accounts: parsed?.accounts ?? [],
268
+ sessions: parsed?.sessions ?? [],
269
+ sessionCount: parsed?.sessionCount ?? null,
270
+ };
271
+ }
272
+ else if (result.part === 'quotas') {
273
+ quotas = partOk ? parseMagpieQuotaCards(partValue) : null;
274
+ dashboard = { ...dashboard, quotaStatus: quotas === null ? 'unavailable' : 'available', quotas: quotas ?? [] };
275
+ if (quotas !== null)
276
+ logoLoad = this.timedDashboardRead('quota-logos', () => this.attachQuotaLogos(quotas, signal)).then((result) => result.value);
277
+ }
278
+ else {
279
+ const parsed = partOk ? parseMagpieQuotaHistory(partValue) : null;
280
+ dashboard = { ...dashboard, quotaHistoryStatus: parsed === null ? 'unavailable' : 'available', quotaHistory: parsed ?? [] };
281
+ }
282
+ yield { part: result.part, dashboard, stale: false };
283
+ }
284
+ dashboard = { ...dashboard, availabilityIssue: [...completed.values()].every((ok) => !ok) ? 'web-api-unavailable' : null };
285
+ yield { part: 'complete', dashboard, stale: false };
286
+ if (logoLoad !== undefined) {
287
+ dashboard = { ...dashboard, quotas: await logoLoad };
288
+ yield { part: 'logos', dashboard, stale: false };
289
+ }
290
+ }
291
+ async timedDashboardRead(part, read) {
292
+ const started = this.now();
293
+ try {
294
+ return { part: part === 'quota-logos' ? 'logos' : part, value: await read(), ok: true };
295
+ }
296
+ finally {
297
+ this.onDashboardTiming?.({ part, elapsedMs: Math.max(0, this.now() - started) });
298
+ }
299
+ }
300
+ /** Read one bounded Web API ledger page and exact-match session identities on Host. */
301
+ async readRequestPage(options, signal) {
302
+ signal?.throwIfAborted();
303
+ if (!isMagpieDashboardPeriod(options.period)
304
+ || !Number.isSafeInteger(options.offset) || options.offset < 0 || options.offset > 1_000_000
305
+ || !Number.isSafeInteger(options.limit) || options.limit < 1 || options.limit > 100)
306
+ return null;
307
+ const sessionId = options.sessionId === undefined ? null : cleanText(options.sessionId);
308
+ const sessionAgent = options.sessionAgent === undefined ? null : cleanText(options.sessionAgent);
309
+ if ((options.sessionId !== undefined && sessionId === null)
310
+ || (options.sessionAgent !== undefined && (sessionAgent === null || !isMagpieAgentId(sessionAgent)))
311
+ || ((sessionId === null) !== (sessionAgent === null)))
312
+ return null;
313
+ const query = new URLSearchParams({ period: magpieWebPeriod(options.period), limit: String(options.limit), offset: String(options.offset) });
314
+ if (sessionId !== null)
315
+ query.set('q', sessionId);
316
+ const response = await this.readWebJson(`/api/usage/requests?${query.toString()}`, signal);
317
+ if (!response.ok)
318
+ return null;
319
+ const root = asRecord(response.value);
320
+ const rawRows = root?.rows;
321
+ if (!Array.isArray(rawRows) || rawRows.length > 500)
322
+ return null;
323
+ const candidates = rawRows.flatMap((row) => {
324
+ const record = asRecord(row);
325
+ if (record === null)
326
+ return [];
327
+ const candidateSession = cleanText(record.session);
328
+ const nativeSession = cleanText(record.native_session);
329
+ const agent = cleanText(record.agent);
330
+ const acceptedAgents = sessionAgent === null ? [] : MAGPIE_USAGE_AGENT_ALIASES[sessionAgent] ?? [sessionAgent];
331
+ if (sessionId !== null
332
+ && (candidateSession !== sessionId && nativeSession !== sessionId || agent === null || !acceptedAgents.includes(agent)))
333
+ return [];
334
+ const item = parseMagpieRequest(record);
335
+ return item === null ? [] : [sessionId === null ? item : { ...item, sessionId }];
336
+ });
337
+ const total = sessionId === null ? safeCount(root?.total) : null;
338
+ return {
339
+ period: options.period,
340
+ offset: options.offset,
341
+ limit: options.limit,
342
+ total,
343
+ hasMore: total === null ? rawRows.length >= options.limit : options.offset + rawRows.length < total,
344
+ sessionId,
345
+ sessionAgent,
346
+ items: candidates.slice(0, options.limit),
347
+ };
348
+ }
349
+ async readFromWeb(agent, modelId, sessionId, magpieSessionId, fallbackSessionId, signal, includeRoute) {
178
350
  const capturedAt = new Date(this.now()).toISOString();
179
- if (gatewayOrigin === null)
180
- return unavailableMagpieSnapshot(agent, modelId, sessionId, this.now(), capturedAt);
181
351
  const [usageResult, quotaResult] = await Promise.all([
182
- this.readGatewayJson(gatewayOrigin, '/v1/magpie/usage?period=today', signal),
183
- this.readGatewayJson(gatewayOrigin, '/v1/magpie/quotas', signal),
352
+ this.readWebJson('/api/usage?period=today', signal),
353
+ this.readWebJson('/api/usage/quotas', signal),
184
354
  ]);
185
355
  const aggregate = usageResult.ok ? readTotals(usageResult.value) : null;
186
- const sessionUsage = usageResult.ok && sessionId !== null
187
- ? readSession(usageResult.value, agent, sessionId)
356
+ const primarySession = usageResult.ok && magpieSessionId !== null
357
+ ? readSession(usageResult.value, agent, magpieSessionId)
358
+ : null;
359
+ const fallbackSession = primarySession === null && usageResult.ok && fallbackSessionId !== null
360
+ ? readSession(usageResult.value, agent, fallbackSessionId)
188
361
  : null;
189
- // A providerSessionId is not enough to prove that Magpie knows this Agent
190
- // session. Require the exact Agent + ID pair in Magpie's own usage result
191
- // before asking the route endpoint, and then verify its echoed identity.
192
- const canReadRoute = includeRoute && sessionId !== null && sessionId.length <= 128 && sessionUsage !== null;
193
- const routeResultPromise = canReadRoute
194
- ? this.readGatewayJson(gatewayOrigin, `/v1/magpie/route?session=${encodeURIComponent(sessionId)}`, signal)
195
- : Promise.resolve({ ok: false });
196
- const routeHistoryResultPromise = canReadRoute
197
- ? this.readGatewayJson(gatewayOrigin, `/v1/magpie/usage/requests?period=today&q=${encodeURIComponent(sessionId)}&limit=100`, signal)
198
- : Promise.resolve({ ok: false });
199
- const [routeResult, routeHistoryResult] = await Promise.all([routeResultPromise, routeHistoryResultPromise]);
200
- const routeRead = sessionId === null
201
- ? { status: 'session-unavailable', route: null }
202
- : !includeRoute
203
- ? { status: 'not-requested', route: null }
204
- : !canReadRoute
205
- ? { status: 'unavailable', route: null }
206
- : !routeResult.ok
207
- ? { status: 'unavailable', route: null }
208
- : readRoute(routeResult.value, sessionId);
209
- const routeHistory = routeHistoryResult.ok && sessionId !== null && sessionUsage !== null
210
- ? readSessionRouteHistory(routeHistoryResult.value, agent, sessionId)
362
+ const sessionUsage = primarySession ?? fallbackSession;
363
+ const resolvedSessionId = primarySession !== null ? magpieSessionId : fallbackSession !== null ? fallbackSessionId : magpieSessionId;
364
+ if (sessionUsage === null)
365
+ return null;
366
+ const canReadRoute = includeRoute && resolvedSessionId !== null && resolvedSessionId.length <= 128;
367
+ const routeHistoryResult = canReadRoute
368
+ ? await this.readWebJson(`/api/usage/requests?period=today&offset=0&limit=100&q=${encodeURIComponent(resolvedSessionId)}`, signal)
369
+ : { ok: false };
370
+ const routeRows = routeHistoryResult.ok ? asRecord(routeHistoryResult.value)?.rows : null;
371
+ const routeHistoryValid = Array.isArray(routeRows) && routeRows.length <= 500;
372
+ const routeHistory = routeHistoryResult.ok && routeHistoryValid && resolvedSessionId !== null
373
+ ? readSessionRouteHistory(routeHistoryResult.value, agent, resolvedSessionId)
211
374
  : [];
212
- const route = routeRead.route === null
213
- ? routeHistory[0]?.route ?? null
214
- : enrichRouteWithHistory(routeRead.route, routeHistory);
375
+ const route = routeHistory[0]?.route ?? null;
215
376
  const routeStatus = route !== null
216
377
  ? 'available'
217
- : routeRead.status;
378
+ : resolvedSessionId === null
379
+ ? 'session-unavailable'
380
+ : !includeRoute
381
+ ? 'not-requested'
382
+ : !canReadRoute || !routeHistoryValid
383
+ ? 'unavailable'
384
+ : 'no-route';
218
385
  const allQuotas = quotaResult.ok ? parseMagpieQuotas(quotaResult.value) : null;
219
386
  const routeProviders = [...new Set([
220
387
  providerFromRoute(route),
@@ -224,9 +391,15 @@ export class MagpieUsageService {
224
391
  ? routeProviders
225
392
  : [providerFromModelId(modelId, agent)].filter((provider) => provider !== null);
226
393
  const upstreamProvider = upstreamProviders[0] ?? null;
227
- const quotas = allQuotas === null
394
+ const sessionQuotas = allQuotas === null
228
395
  ? []
229
396
  : filterSessionQuotas(allQuotas, upstreamProviders, routeHistory);
397
+ const matchingQuotas = markSessionAccounts(sessionQuotas, routeHistory, route);
398
+ const quotas = allQuotas === null ? [] : await this.attachQuotaLogos(matchingQuotas, signal);
399
+ // 同厂商的其它账号单独下发:客户端分组展示,但绝不参与本会话余量。
400
+ const siblingQuotas = allQuotas === null
401
+ ? []
402
+ : await this.attachQuotaLogos(otherProviderAccounts(allQuotas, upstreamProviders, sessionQuotas), signal);
230
403
  return {
231
404
  agent,
232
405
  modelId,
@@ -241,8 +414,47 @@ export class MagpieUsageService {
241
414
  upstreamProviders,
242
415
  quotaStatus: allQuotas === null ? 'unavailable' : upstreamProviders.length === 0 ? 'provider-unavailable' : 'available',
243
416
  quotas,
417
+ ...(siblingQuotas.length === 0 ? {} : { providerAccounts: siblingQuotas }),
244
418
  };
245
419
  }
420
+ async attachQuotaLogos(quotas, signal) {
421
+ const requestAssetDataUrl = this.webApi?.requestAssetDataUrl;
422
+ if (typeof requestAssetDataUrl !== 'function')
423
+ return quotas;
424
+ const icons = [...new Set(quotas
425
+ .map((quota) => quota.icon)
426
+ .filter((icon) => icon !== undefined))].slice(0, MAX_MAGPIE_ICON_FETCHES);
427
+ if (icons.length === 0)
428
+ return quotas;
429
+ const logos = new Map();
430
+ await Promise.all(icons.map(async (icon) => {
431
+ const cached = this.quotaLogoCache.get(icon);
432
+ const logo = cached === undefined ? await this.readMagpieLogoDataUrl(icon, signal) : cached;
433
+ if (cached === undefined)
434
+ this.quotaLogoCache.set(icon, logo);
435
+ logos.set(icon, logo);
436
+ }));
437
+ return quotas.map((quota) => {
438
+ const logoDataUrl = quota.icon === undefined ? null : logos.get(quota.icon) ?? null;
439
+ return logoDataUrl === null ? quota : { ...quota, logoDataUrl };
440
+ });
441
+ }
442
+ async readMagpieLogoDataUrl(icon, signal) {
443
+ const requestAssetDataUrl = this.webApi?.requestAssetDataUrl;
444
+ if (typeof requestAssetDataUrl !== 'function')
445
+ return null;
446
+ for (const path of magpieIconAssetPaths(icon)) {
447
+ try {
448
+ const value = await requestAssetDataUrl.call(this.webApi, path, signal === undefined ? {} : { signal });
449
+ if (value !== null && value !== '')
450
+ return value;
451
+ }
452
+ catch {
453
+ signal?.throwIfAborted();
454
+ }
455
+ }
456
+ return null;
457
+ }
246
458
  async loadCatalog(agent, signal) {
247
459
  const cached = this.catalogs.get(agent);
248
460
  const cacheTtl = cached?.catalog === null ? MAGPIE_FAILURE_CACHE_TTL_MS : MAGPIE_CATALOG_TTL_MS;
@@ -264,7 +476,12 @@ export class MagpieUsageService {
264
476
  .catch(() => null);
265
477
  const [supported, catalog] = await Promise.all([supportedRequest, catalogRequest]);
266
478
  const result = supported ? catalog : null;
267
- this.catalogs.set(agent, { catalog: result, loadedAt: this.now() });
479
+ const unavailableIssue = result !== null
480
+ ? null
481
+ : !supported
482
+ ? this.versionCheck?.executableAvailable === true ? 'cli-version-unsupported' : 'cli-unavailable'
483
+ : 'model-catalog-unavailable';
484
+ this.catalogs.set(agent, { catalog: result, unavailableIssue, loadedAt: this.now() });
268
485
  return result;
269
486
  })
270
487
  .catch(() => null)
@@ -320,13 +537,13 @@ export class MagpieUsageService {
320
537
  if (this.versionLoad !== undefined)
321
538
  return awaitWithSignal(this.versionLoad, signal);
322
539
  const load = this.readVersion(this.lifetimeSignal)
323
- .then((value) => parseMagpieCliVersion(value))
324
- .then((supported) => {
325
- this.versionCheck = { supported, checkedAt: this.now() };
540
+ .then((value) => {
541
+ const supported = parseMagpieCliVersion(value);
542
+ this.versionCheck = { supported, executableAvailable: value !== null, checkedAt: this.now() };
326
543
  return supported;
327
544
  })
328
545
  .catch(() => {
329
- this.versionCheck = { supported: false, checkedAt: this.now() };
546
+ this.versionCheck = { supported: false, executableAvailable: false, checkedAt: this.now() };
330
547
  return false;
331
548
  })
332
549
  .finally(() => { if (this.versionLoad === load)
@@ -363,22 +580,17 @@ export class MagpieUsageService {
363
580
  return { ok: false };
364
581
  }
365
582
  }
366
- }
367
- function unavailableMagpieSnapshot(agent, modelId, sessionId, now, capturedAt = new Date(now).toISOString()) {
368
- return {
369
- agent,
370
- modelId,
371
- capturedAt,
372
- usageStatus: 'unavailable',
373
- aggregate: null,
374
- sessionId,
375
- session: null,
376
- routeStatus: sessionId === null ? 'session-unavailable' : 'unavailable',
377
- route: null,
378
- upstreamProvider: providerFromModelId(modelId, agent),
379
- quotaStatus: 'unavailable',
380
- quotas: [],
381
- };
583
+ async readWebJson(path, signal) {
584
+ if (this.webApi === undefined)
585
+ return { ok: false };
586
+ try {
587
+ return { ok: true, value: await this.webApi.requestJson(path, signal === undefined ? {} : { signal }) };
588
+ }
589
+ catch {
590
+ signal?.throwIfAborted();
591
+ return { ok: false };
592
+ }
593
+ }
382
594
  }
383
595
  function awaitWithSignal(task, signal) {
384
596
  if (signal === undefined)
@@ -635,13 +847,71 @@ async function runMagpieCommand(args, signal) {
635
847
  }
636
848
  /**
637
849
  * Magpie IDs normally match adapter IDs. Keep naming differences explicit;
638
- * every result is still checked against Magpie's own catalog output before
639
- * any Gateway request is made.
850
+ * it is used for exact Agent identity matching in Magpie usage records.
640
851
  */
641
852
  export function magpieAgentForAdapter(adapterId) {
642
853
  const agent = MAGPIE_AGENT_BY_ADAPTER[adapterId];
643
854
  return agent !== undefined && isMagpieAgentId(agent) ? agent : null;
644
855
  }
856
+ /** Return the exact Magpie Agent identities accepted for one adapter. */
857
+ export function magpieUsageAgentAliases(adapterId) {
858
+ const agent = magpieAgentForAdapter(adapterId);
859
+ return agent === null ? [] : MAGPIE_USAGE_AGENT_ALIASES[agent] ?? [agent];
860
+ }
861
+ /**
862
+ * Enrich dashboard session rows only after an exact `(Agent, session ID)` match.
863
+ * Provider session IDs and DSH IDs are both candidates because Command Code and
864
+ * native DSH use different identities across Magpie versions.
865
+ */
866
+ export function attachMagpieSessionNames(dashboard, bindings) {
867
+ if (!Array.isArray(dashboard.sessions) || bindings.length === 0)
868
+ return dashboard;
869
+ const names = new Map();
870
+ const ambiguous = new Set();
871
+ for (const binding of bindings) {
872
+ const name = cleanSessionName(binding.name);
873
+ const dshSessionId = cleanText(binding.dshSessionId);
874
+ if (name === null || dshSessionId === null)
875
+ continue;
876
+ // Match the identity strategy used by compact usage: native DSH uses its
877
+ // DSH ID, Command Code may use either injected DSH or ACP ID, and other
878
+ // adapters use only their Provider session ID.
879
+ const providerSessionId = cleanText(binding.providerSessionId);
880
+ const ids = new Set((binding.adapterId === 'dsh'
881
+ ? [dshSessionId]
882
+ : binding.adapterId === 'command-code'
883
+ ? [dshSessionId, providerSessionId]
884
+ : [providerSessionId]).filter((value) => value !== null));
885
+ for (const agent of magpieUsageAgentAliases(binding.adapterId)) {
886
+ for (const id of ids) {
887
+ const key = `${agent}\u0000${id}`;
888
+ if (ambiguous.has(key))
889
+ continue;
890
+ const previous = names.get(key);
891
+ if (previous !== undefined && previous !== name) {
892
+ names.delete(key);
893
+ ambiguous.add(key);
894
+ }
895
+ else {
896
+ names.set(key, name);
897
+ }
898
+ }
899
+ }
900
+ }
901
+ if (names.size === 0)
902
+ return dashboard;
903
+ let changed = false;
904
+ const sessions = dashboard.sessions.map((session) => {
905
+ if (session.dshSessionName !== undefined)
906
+ return session;
907
+ const name = names.get(`${session.agent}\u0000${session.id}`);
908
+ if (name === undefined)
909
+ return session;
910
+ changed = true;
911
+ return { ...session, dshSessionName: name };
912
+ });
913
+ return changed ? { ...dashboard, sessions } : dashboard;
914
+ }
645
915
  function isMagpieAgentId(value) {
646
916
  return value.length <= 64 && /^[a-z0-9][a-z0-9-]*$/u.test(value);
647
917
  }
@@ -728,46 +998,6 @@ function readSession(value, agent, sessionId) {
728
998
  const usageAgent = cleanText(record?.agent);
729
999
  return record !== null && usageAgent !== null && totals !== null ? { id: sessionId, agent: usageAgent, ...totals } : null;
730
1000
  }
731
- function readRoute(value, expectedSessionId) {
732
- const envelope = asRecord(value);
733
- if (envelope === null || cleanText(envelope.session) !== expectedSessionId || !('route' in envelope))
734
- return { status: 'unavailable', route: null };
735
- if (envelope.route === null)
736
- return { status: 'no-route', route: null };
737
- const record = asRecord(envelope.route);
738
- if (record === null)
739
- return { status: 'unavailable', route: null };
740
- const tries = Array.isArray(record.tries)
741
- ? record.tries.slice(0, 20).flatMap((item) => {
742
- const attempt = asRecord(item);
743
- if (attempt === null)
744
- return [];
745
- const model = routeText(attempt.model);
746
- const provider = routeText(attempt.provider);
747
- return [{
748
- model: model !== null && provider !== null && !model.includes('/') ? `${provider}/${model}` : model ?? provider,
749
- failed: routeText(attempt.fail) !== null,
750
- }];
751
- })
752
- : [];
753
- return {
754
- status: 'available',
755
- route: {
756
- asked: routeText(record.asked),
757
- group: routeText(record.group),
758
- rule: readRule(record.rule),
759
- model: routeText(record.model),
760
- effort: routeText(record.effort),
761
- tries,
762
- done: typeof record.done === 'boolean' ? record.done : null,
763
- status: routeStatusText(record.status),
764
- served: routeText(record.served),
765
- routeId: safeRouteId(record.id),
766
- time: routeText(record.time),
767
- providerAccount: routeText(record.providerAccount),
768
- },
769
- };
770
- }
771
1001
  /** Read the durable request ledger when Magpie's short in-memory trace has expired. */
772
1002
  function readSessionRouteHistory(value, agent, sessionId) {
773
1003
  const rows = asRecord(value)?.rows;
@@ -814,18 +1044,19 @@ function routeTime(value) {
814
1044
  const time = Date.parse(value);
815
1045
  return Number.isFinite(time) ? time : 0;
816
1046
  }
817
- function enrichRouteWithHistory(route, history) {
818
- if (route.routeId === undefined || route.routeId === null)
819
- return route;
820
- const record = history.find((item) => item.route.routeId === route.routeId);
821
- if (record === undefined)
822
- return route;
823
- return {
824
- ...route,
825
- time: route.time ?? record.route.time ?? null,
826
- providerAccount: route.providerAccount ?? record.account,
827
- };
828
- }
1047
+ /**
1048
+ * 选出本会话确实使用过的上游账号配额。
1049
+ *
1050
+ * 归属必须严格:请求历史里没有出现的账号不能算作本会话的配额。
1051
+ *
1052
+ * - 账号维度能对上:只留命中的账号。
1053
+ * - 上游不提供账号名(Codex 这类量贩账号没有 `user`),无从按账号筛选:保留该厂商
1054
+ * 全部候选。过去这里返回空数组,`quotaStatus` 仍是 `available`,界面就只剩下
1055
+ * "上游没有报告配额窗口"的空态 —— 明明识别出了 `glorinfo-api-platform`,却看不到
1056
+ * 它的额度。
1057
+ * - 有账号记录但一条都不匹配:返回空,宁可显示"没有可用配额"也不能把别的账号的额度
1058
+ * 说成本会话的。
1059
+ */
829
1060
  function filterSessionQuotas(quotas, providers, history) {
830
1061
  return providers.flatMap((provider) => {
831
1062
  const candidates = quotas.filter((quota) => quota.provider === provider);
@@ -834,7 +1065,53 @@ function filterSessionQuotas(quotas, providers, history) {
834
1065
  .map((record) => record.account));
835
1066
  if (accounts.size === 0)
836
1067
  return candidates;
837
- return candidates.filter((quota) => quota.user !== null && accounts.has(quota.user));
1068
+ const matched = candidates.filter((quota) => quota.user !== null && accounts.has(quota.user));
1069
+ return matched.length > 0 ? matched : candidates.filter((quota) => quota.user === null);
1070
+ });
1071
+ }
1072
+ /**
1073
+ * 与本会话同一厂商、但本会话没用过的其它账号。
1074
+ *
1075
+ * 单独返回而不是并进 `quotas`:客户端用它们做"同厂商账号分组"的补充展示,
1076
+ * 但这些账号的额度绝不参与本会话余量判断。顺序沿用上游。
1077
+ */
1078
+ function otherProviderAccounts(quotas, providers, sessionQuotas) {
1079
+ const claimed = new Set(sessionQuotas.map((quota) => `${quota.provider}\u0000${quota.user ?? ''}`));
1080
+ return providers.flatMap((provider) => quotas
1081
+ .filter((quota) => quota.provider === provider)
1082
+ .filter((quota) => quota.user !== null && !claimed.has(`${quota.provider}\u0000${quota.user}`)));
1083
+ }
1084
+ /**
1085
+ * 标记本会话实际使用过的账号。
1086
+ *
1087
+ * 只有能追溯到会话请求记录的账号才标 `active`;route 的账号字段优先于请求历史,
1088
+ * 因为它来自 Magpie 对这一次请求的精确归属。
1089
+ *
1090
+ * 账号名比对必须同时限定厂商:不同厂商的账号名会重名(WorkBuddy 与 WorkBuddy AI 都有
1091
+ * `zj59122704501@gmail.com`),只比名字会把别的厂商的账号标成本会话正在使用。
1092
+ * 无法确认时保持未标记,客户端据此显示"未知活动账号",而不是随便挑一个账号当"正在使用"。
1093
+ */
1094
+ function markSessionAccounts(quotas, history, route) {
1095
+ const used = new Set(history
1096
+ .filter((record) => record.account !== null)
1097
+ .map((record) => `${record.provider}\u0000${record.account}`));
1098
+ const routedProvider = providerFromRoute(route);
1099
+ const routedAccount = route?.providerAccount ?? null;
1100
+ const routedKey = routedAccount === null || routedProvider === null ? null : `${routedProvider}\u0000${routedAccount}`;
1101
+ if (used.size === 0 && routedKey === null)
1102
+ return quotas;
1103
+ // 每个厂商只承认一个活动账号:精确路由优先,其次是首个命中的历史账号。
1104
+ const claimed = new Set();
1105
+ return quotas.map((quota) => {
1106
+ if (quota.user === null)
1107
+ return quota;
1108
+ const key = `${quota.provider}\u0000${quota.user}`;
1109
+ if (claimed.has(key))
1110
+ return quota;
1111
+ if (key !== routedKey && !used.has(key))
1112
+ return quota;
1113
+ claimed.add(key);
1114
+ return { ...quota, active: true };
838
1115
  });
839
1116
  }
840
1117
  function safeRouteId(value) {
@@ -866,6 +1143,100 @@ export function parseMagpieQuotas(value) {
866
1143
  quotas.push({
867
1144
  provider,
868
1145
  name,
1146
+ ...optionalQuotaIcon(record.icon),
1147
+ kind: routeText(record.kind),
1148
+ user: routeText(record.user),
1149
+ plan: routeText(record.plan),
1150
+ alias: routeText(record.alias),
1151
+ balance: routeText(record.balance),
1152
+ readAt: routeText(record.readAt),
1153
+ windows,
1154
+ ...sessionQuotaDaily(record),
1155
+ ...readQuotaAccountMeta(record),
1156
+ });
1157
+ }
1158
+ return quotas;
1159
+ }
1160
+ /** Normalize the live aggregate and per-dimension usage response. */
1161
+ export function parseMagpieDashboardUsage(value) {
1162
+ const root = asRecord(value);
1163
+ if (root === null)
1164
+ return null;
1165
+ const hasMetrics = ['calls', 'errors', 'input', 'output', 'cache_read', 'cache_write', 'cost'].some((key) => key in root);
1166
+ if (!hasMetrics && !Array.isArray(root.series) && !Array.isArray(root.sessions))
1167
+ return null;
1168
+ const series = readMagpieSeries(root.series);
1169
+ const agents = readMagpieUsageGroups(root.agents, 'agent');
1170
+ const providers = readMagpieUsageGroups(root.providerKeys ?? root.providers, 'provider');
1171
+ const models = readMagpieUsageGroups(root.models, 'model');
1172
+ const accounts = readMagpieUsageGroups(root.accounts, 'account');
1173
+ const sessions = readMagpieUsageSessions(root.sessions);
1174
+ if (series === null || agents === null || providers === null || models === null || accounts === null || sessions === null)
1175
+ return null;
1176
+ return {
1177
+ aggregate: readMagpieUsageMetrics(root),
1178
+ series,
1179
+ agents,
1180
+ providers,
1181
+ models,
1182
+ accounts,
1183
+ sessions,
1184
+ sessionCount: safeCount(root.sessionCount),
1185
+ };
1186
+ }
1187
+ /** Normalize quota cards while preserving nullable values and bounded history. */
1188
+ export function parseMagpieQuotaCards(value) {
1189
+ const root = asRecord(value);
1190
+ const rows = Array.isArray(value) ? value : Array.isArray(root?.data) ? root.data : null;
1191
+ if (rows === null || rows.length > 512)
1192
+ return null;
1193
+ const quotas = [];
1194
+ for (const row of rows) {
1195
+ const record = asRecord(row);
1196
+ const provider = routeText(record?.provider);
1197
+ const name = routeText(record?.name);
1198
+ if (record === null || provider === null || name === null
1199
+ || !Array.isArray(record.windows) || record.windows.length > 64)
1200
+ return null;
1201
+ const windows = record.windows.flatMap((item) => {
1202
+ const window = asRecord(item);
1203
+ const windowName = routeText(window?.name);
1204
+ if (window === null || windowName === null)
1205
+ return [];
1206
+ return [{
1207
+ name: windowName,
1208
+ usedPercent: quotaUsedPercent(window),
1209
+ remainingPercent: quotaRemainingPercent(window),
1210
+ resetsAt: routeText(window.resetsAt),
1211
+ display: routeText(window.display),
1212
+ amount: safeDecimal(window.amount),
1213
+ limit: safeDecimal(window.limit),
1214
+ unit: routeText(window.unit),
1215
+ holdsWhy: routeText(window.holdsWhy),
1216
+ runsOutAt: routeText(window.runsOutAt),
1217
+ runsOut: safeDecimal(window.runsOut),
1218
+ }];
1219
+ });
1220
+ if (windows.length !== record.windows.length)
1221
+ return null;
1222
+ const dailyRecord = record.daily === undefined ? null : asRecord(record.daily);
1223
+ const dailyPoints = dailyRecord === null ? [] : dailyRecord.days;
1224
+ if (dailyRecord !== null && (!Array.isArray(dailyPoints) || dailyPoints.length > 366))
1225
+ return null;
1226
+ const days = (Array.isArray(dailyPoints) ? dailyPoints : []).flatMap((item) => {
1227
+ const point = asRecord(item);
1228
+ const day = routeText(point?.day);
1229
+ if (point === null || day === null)
1230
+ return [];
1231
+ const used = safeDecimal(point.used);
1232
+ return [{ day, used }];
1233
+ });
1234
+ if (days.length !== (Array.isArray(dailyPoints) ? dailyPoints.length : 0))
1235
+ return null;
1236
+ quotas.push({
1237
+ provider,
1238
+ name,
1239
+ ...optionalQuotaIcon(record.icon),
869
1240
  kind: routeText(record.kind),
870
1241
  user: routeText(record.user),
871
1242
  plan: routeText(record.plan),
@@ -873,22 +1244,369 @@ export function parseMagpieQuotas(value) {
873
1244
  balance: routeText(record.balance),
874
1245
  readAt: routeText(record.readAt),
875
1246
  windows,
1247
+ daily: { since: routeText(dailyRecord?.since), days },
1248
+ ...readQuotaAccountMeta(record),
876
1249
  });
877
1250
  }
878
1251
  return quotas;
879
1252
  }
1253
+ export function parseMagpieQuotaHistory(value) {
1254
+ const root = asRecord(value);
1255
+ const rows = Array.isArray(value) ? value : Array.isArray(root?.data) ? root.data : null;
1256
+ if (rows === null || rows.length > 512)
1257
+ return null;
1258
+ const history = [];
1259
+ for (const row of rows) {
1260
+ const record = asRecord(row);
1261
+ const provider = routeText(record?.provider);
1262
+ if (record === null || provider === null || !Array.isArray(record.lines) || record.lines.length > 64)
1263
+ return null;
1264
+ const lines = record.lines.flatMap((item) => {
1265
+ const line = asRecord(item);
1266
+ const name = routeText(line?.name);
1267
+ if (line === null || name === null || !Array.isArray(line.points) || line.points.length > 2_000)
1268
+ return [];
1269
+ const points = line.points.flatMap((pointValue) => {
1270
+ const point = asRecord(pointValue);
1271
+ const at = routeText(point?.at);
1272
+ return point === null || at === null ? [] : [{
1273
+ at,
1274
+ left: safeDecimal(point.left),
1275
+ start: routeText(point.start),
1276
+ resetsAt: routeText(point.resetsAt),
1277
+ }];
1278
+ });
1279
+ if (points.length !== line.points.length)
1280
+ return [];
1281
+ return [{ name, points }];
1282
+ });
1283
+ if (lines.length !== record.lines.length)
1284
+ return null;
1285
+ history.push({ provider, user: routeText(record.user), lines });
1286
+ }
1287
+ return history;
1288
+ }
1289
+ function readMagpieUsageMetrics(record) {
1290
+ return {
1291
+ calls: safeCount(record.calls),
1292
+ errors: safeCount(record.errors),
1293
+ retried: safeCount(record.retried),
1294
+ inputTokens: safeCount(record.input),
1295
+ outputTokens: safeCount(record.output),
1296
+ cacheReadTokens: safeCount(record.cache_read),
1297
+ cacheWriteTokens: safeCount(record.cache_write),
1298
+ reasoningTokens: safeCount(record.reasoning),
1299
+ cost: safeDecimal(record.cost),
1300
+ unpriced: safeDecimal(record.unpriced),
1301
+ credits: safeDecimal(record.credits),
1302
+ credited: safeDecimal(record.credited),
1303
+ timed: safeCount(record.timed),
1304
+ ttftMs: safeDecimal(record.ttft_ms),
1305
+ decodeMs: safeDecimal(record.decode_ms),
1306
+ decodeTokens: safeCount(record.decode_out),
1307
+ decodeCount: safeCount(record.decode_n),
1308
+ };
1309
+ }
1310
+ function readMagpieSeries(value) {
1311
+ if (value === undefined)
1312
+ return [];
1313
+ if (!Array.isArray(value) || value.length > 2_000)
1314
+ return null;
1315
+ return value.flatMap((item) => {
1316
+ const record = asRecord(item);
1317
+ return record === null ? [] : [{
1318
+ time: routeText(record.time),
1319
+ label: routeText(record.label),
1320
+ ...readMagpieUsageMetrics(record),
1321
+ }];
1322
+ });
1323
+ }
1324
+ function readMagpieUsageGroups(value, dimension) {
1325
+ if (value === undefined)
1326
+ return [];
1327
+ if (!Array.isArray(value) || value.length > 2_000)
1328
+ return null;
1329
+ const idKeys = {
1330
+ agent: ['id', 'agent'],
1331
+ provider: ['provider', 'id'],
1332
+ model: ['model', 'id'],
1333
+ account: ['account', 'id'],
1334
+ };
1335
+ const groups = value.flatMap((item) => {
1336
+ const record = asRecord(item);
1337
+ if (record === null)
1338
+ return [];
1339
+ const id = firstRouteText(record, idKeys[dimension]);
1340
+ if (id === null)
1341
+ return [];
1342
+ return [{
1343
+ id,
1344
+ name: routeText(record.name),
1345
+ provider: routeText(record.provider),
1346
+ model: routeText(record.model),
1347
+ account: routeText(record.account),
1348
+ agent: routeText(record.agent ?? record.id),
1349
+ ...readMagpieUsageMetrics(record),
1350
+ }];
1351
+ });
1352
+ if (dimension !== 'provider')
1353
+ return groups;
1354
+ const byProvider = new Map();
1355
+ for (const group of groups) {
1356
+ const current = byProvider.get(group.id);
1357
+ if (current === undefined) {
1358
+ byProvider.set(group.id, group);
1359
+ continue;
1360
+ }
1361
+ byProvider.set(group.id, {
1362
+ ...current,
1363
+ calls: sumNullable(current.calls, group.calls),
1364
+ errors: sumNullable(current.errors, group.errors),
1365
+ retried: sumNullable(current.retried, group.retried),
1366
+ inputTokens: sumNullable(current.inputTokens, group.inputTokens),
1367
+ outputTokens: sumNullable(current.outputTokens, group.outputTokens),
1368
+ cacheReadTokens: sumNullable(current.cacheReadTokens, group.cacheReadTokens),
1369
+ cacheWriteTokens: sumNullable(current.cacheWriteTokens, group.cacheWriteTokens),
1370
+ reasoningTokens: sumNullable(current.reasoningTokens, group.reasoningTokens),
1371
+ cost: sumNullable(current.cost, group.cost),
1372
+ unpriced: sumNullable(current.unpriced, group.unpriced),
1373
+ credits: sumNullable(current.credits, group.credits),
1374
+ credited: sumNullable(current.credited, group.credited),
1375
+ timed: sumNullable(current.timed, group.timed),
1376
+ ttftMs: null,
1377
+ decodeMs: null,
1378
+ decodeTokens: sumNullable(current.decodeTokens, group.decodeTokens),
1379
+ decodeCount: sumNullable(current.decodeCount, group.decodeCount),
1380
+ });
1381
+ }
1382
+ return [...byProvider.values()];
1383
+ }
1384
+ function readMagpieUsageSessions(value) {
1385
+ if (value === undefined)
1386
+ return [];
1387
+ if (!Array.isArray(value) || value.length > 2_000)
1388
+ return null;
1389
+ return value.flatMap((item) => {
1390
+ const record = asRecord(item);
1391
+ const id = cleanText(record?.id);
1392
+ const agent = cleanText(record?.agent);
1393
+ return record === null || id === null || agent === null ? [] : [{ id, agent, ...readMagpieUsageMetrics(record) }];
1394
+ });
1395
+ }
1396
+ function parseMagpieRequest(record) {
1397
+ const agent = routeText(record.agent);
1398
+ const provider = routeText(record.provider);
1399
+ const requestedModel = routeText(record.req);
1400
+ const servedModel = routeText(record.served);
1401
+ const group = requestedModel?.startsWith('group/') === true ? requestedModel.slice('group/'.length) : null;
1402
+ return {
1403
+ routeId: safeRouteId(record.route_id),
1404
+ time: routeText(record.t),
1405
+ agent,
1406
+ provider,
1407
+ account: routeText(record.providerAccount),
1408
+ requestedModel,
1409
+ servedModel,
1410
+ group,
1411
+ effort: routeText(record.effort),
1412
+ inputTokens: safeCount(record.in),
1413
+ outputTokens: safeCount(record.out),
1414
+ cacheReadTokens: safeCount(record.cache_read),
1415
+ cacheWriteTokens: safeCount(record.cache_write),
1416
+ reasoningTokens: safeCount(record.reasoning),
1417
+ cost: safeDecimal(record.cost),
1418
+ priced: typeof record.priced === 'boolean' ? record.priced : null,
1419
+ status: routeStatusText(record.status),
1420
+ durationMs: safeDecimal(record.ms),
1421
+ ttftMs: safeDecimal(record.ttft_ms),
1422
+ firstTextMs: safeDecimal(record.first_text_ms),
1423
+ firstReasoningMs: safeDecimal(record.first_reasoning_ms),
1424
+ flowMs: safeDecimal(record.flow_ms),
1425
+ sessionId: cleanText(record.session) ?? cleanText(record.native_session),
1426
+ };
1427
+ }
1428
+ function firstRouteText(record, keys) {
1429
+ for (const key of keys) {
1430
+ const value = routeText(record[key]);
1431
+ if (value !== null)
1432
+ return value;
1433
+ }
1434
+ return null;
1435
+ }
1436
+ function sumNullable(left, right) {
1437
+ return left === null && right === null ? null : (left ?? 0) + (right ?? 0);
1438
+ }
1439
+ function isMagpieDashboardPeriod(value) {
1440
+ return value === 'today' || value === 'week' || value === 'month' || value === 'all';
1441
+ }
1442
+ function magpieWebPeriod(period) {
1443
+ switch (period) {
1444
+ case 'today': return 'today';
1445
+ case 'week': return '7d';
1446
+ case 'month': return '30d';
1447
+ case 'all': return 'all';
1448
+ }
1449
+ }
1450
+ function emptyMagpieDashboard(period, capturedAt) {
1451
+ return {
1452
+ period,
1453
+ capturedAt,
1454
+ availabilityIssue: null,
1455
+ usageStatus: 'unavailable',
1456
+ aggregate: null,
1457
+ series: [],
1458
+ agents: [],
1459
+ providers: [],
1460
+ models: [],
1461
+ accounts: [],
1462
+ sessions: [],
1463
+ sessionCount: null,
1464
+ quotaStatus: 'unavailable',
1465
+ quotas: [],
1466
+ quotaHistoryStatus: 'unavailable',
1467
+ quotaHistory: [],
1468
+ };
1469
+ }
1470
+ /**
1471
+ * 窗口使用的百分比。
1472
+ *
1473
+ * Magpie 的 `used` 始终是 0–100 的百分比(见其 `auth.usage` 契约),但 `remaining`
1474
+ * 不一定:同一份数据里既有 `remaining: 96` 这种百分比,也有 `remaining: 86.57`
1475
+ * 配 `amount: 322.25 / limit: 2400` 这种"剩余量"。凡是带 `limit`(或 `amount`)的窗口,
1476
+ * `remaining` 一律按数量解释,不能直接当成 0–100 的百分比,否则 WorkBuddy 的
1477
+ * `322 / 2400` 会被读成"剩余 86.57%",和 `display` 里的数量对不上。
1478
+ */
1479
+ function quotaUsedPercent(record) {
1480
+ return safePercent(record.used);
1481
+ }
1482
+ function quotaRemainingPercent(record) {
1483
+ const counted = safeDecimal(record.limit) !== null || safeDecimal(record.amount) !== null;
1484
+ if (counted) {
1485
+ const used = quotaUsedPercent(record);
1486
+ return used === null ? null : 100 - used;
1487
+ }
1488
+ return safePercent(record.remaining);
1489
+ }
880
1490
  function readQuotaWindow(record, name) {
881
1491
  return {
882
1492
  name,
883
- usedPercent: safePercent(record.used),
884
- remainingPercent: safePercent(record.remaining),
1493
+ usedPercent: quotaUsedPercent(record),
1494
+ remainingPercent: quotaRemainingPercent(record),
885
1495
  resetsAt: routeText(record.resetsAt),
886
1496
  display: routeText(record.display),
887
1497
  amount: safeDecimal(record.amount),
888
1498
  limit: safeDecimal(record.limit),
889
1499
  unit: routeText(record.unit),
890
1500
  holdsWhy: routeText(record.holdsWhy),
1501
+ startsAt: routeText(record.startsAt),
1502
+ runsOut: safeDecimal(record.runsOut),
1503
+ runsOutAt: routeText(record.runsOutAt),
1504
+ };
1505
+ }
1506
+ /**
1507
+ * Read the daily check-in result of providers that have one (WorkBuddy, Trae, MiniMax).
1508
+ *
1509
+ * `outcome` stays an upstream string: the client only localizes the outcomes it knows, so a
1510
+ * new vendor state never gets presented as a successful check-in.
1511
+ */
1512
+ function readQuotaCheckin(value) {
1513
+ const record = asRecord(value);
1514
+ if (record === null)
1515
+ return null;
1516
+ const checkin = {
1517
+ day: routeText(record.day),
1518
+ at: routeText(record.at),
1519
+ outcome: routeText(record.outcome),
1520
+ credit: safeDecimal(record.credit),
1521
+ streak: safeCount(record.streak),
891
1522
  };
1523
+ return Object.values(checkin).every((item) => item === null) ? null : checkin;
1524
+ }
1525
+ function readQuotaDaily(value) {
1526
+ const record = asRecord(value);
1527
+ if (record === null)
1528
+ return undefined;
1529
+ const points = record.days;
1530
+ if (!Array.isArray(points) || points.length > 366)
1531
+ return undefined;
1532
+ const days = points.flatMap((item) => {
1533
+ const point = asRecord(item);
1534
+ const day = routeText(point?.day);
1535
+ return point === null || day === null ? [] : [{ day, used: safeDecimal(point.used) }];
1536
+ });
1537
+ if (days.length !== points.length)
1538
+ return undefined;
1539
+ return { since: routeText(record.since), days };
1540
+ }
1541
+ /**
1542
+ * Read a provider's remaining reset cards without exposing the vendor's raw record.
1543
+ *
1544
+ * `count` 缺失与 0 不同:上游在 0 张时会省略整个对象,因此这里只在对象存在时才返回,
1545
+ * 客户端把缺失显示为"未知"而不是"没有可用重置卡"。
1546
+ */
1547
+ function readQuotaResets(value) {
1548
+ const record = asRecord(value);
1549
+ if (record === null)
1550
+ return null;
1551
+ const eachPoints = record.each;
1552
+ const each = Array.isArray(eachPoints)
1553
+ ? eachPoints.flatMap((item) => {
1554
+ const point = asRecord(item);
1555
+ if (point === null)
1556
+ return [];
1557
+ return [{ window: routeText(point.window), until: routeText(point.until) }];
1558
+ }).slice(0, 64)
1559
+ : [];
1560
+ return {
1561
+ count: safeCount(record.count),
1562
+ until: routeText(record.until),
1563
+ each,
1564
+ byWindow: record.byWindow === true,
1565
+ fiveHour: safeCount(record.fiveHour),
1566
+ weekly: safeCount(record.weekly),
1567
+ };
1568
+ }
1569
+ /** 会话级配额卡只带上 `daily`;上游缺字段时整个属性不出现。 */
1570
+ function sessionQuotaDaily(record) {
1571
+ const daily = readQuotaDaily(record.daily);
1572
+ return daily === undefined ? {} : { daily };
1573
+ }
1574
+ /**
1575
+ * Read the account-level fields shared by both quota parsers.
1576
+ *
1577
+ * `/api/usage/quotas` (session popover) and the dashboard read the same rows; keeping these
1578
+ * fields in one place stops the two views drifting apart. Absent values stay `undefined` so
1579
+ * older Magpie builds keep working.
1580
+ */
1581
+ function readQuotaAccountMeta(record) {
1582
+ const checkin = readQuotaCheckin(record.checkin);
1583
+ const resets = readQuotaResets(record.resets);
1584
+ return {
1585
+ ...(typeof record.active === 'boolean' ? { active: record.active } : {}),
1586
+ ...(checkin === null ? {} : { checkin }),
1587
+ ...(record.checkins === true ? { checkins: true } : {}),
1588
+ error: routeText(record.error),
1589
+ until: routeText(record.until),
1590
+ renew: routeText(record.renew),
1591
+ ...(resets === null ? {} : { resets }),
1592
+ credits: routeText(record.credits),
1593
+ };
1594
+ }
1595
+ function optionalQuotaIcon(value) {
1596
+ const icon = routeText(value);
1597
+ if (icon === null)
1598
+ return {};
1599
+ if (/^file:[A-Za-z0-9][A-Za-z0-9._~-]{0,255}$/u.test(icon))
1600
+ return { icon };
1601
+ return /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u.test(icon) ? { icon } : {};
1602
+ }
1603
+ function magpieIconAssetPaths(icon) {
1604
+ if (/^file:[A-Za-z0-9][A-Za-z0-9._~-]{0,255}$/u.test(icon)) {
1605
+ return [`/api/icons/${encodeURIComponent(icon.slice('file:'.length))}`];
1606
+ }
1607
+ if (!/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/u.test(icon))
1608
+ return [];
1609
+ return [`/icons/${icon}.svg`, `/icons/${icon}.png`];
892
1610
  }
893
1611
  function providerFromRoute(route) {
894
1612
  if (route === null)
@@ -947,6 +1665,12 @@ function cleanText(value) {
947
1665
  const text = value.trim();
948
1666
  return text === '' || text.length > 512 || /[\u0000-\u001f\u007f]/u.test(text) ? null : text;
949
1667
  }
1668
+ function cleanSessionName(value) {
1669
+ if (typeof value !== 'string')
1670
+ return null;
1671
+ const text = value.trim().replace(/[\u0000-\u001f\u007f]/gu, '').slice(0, 240);
1672
+ return text === '' ? null : text;
1673
+ }
950
1674
  /** Pi and Command Code store Magpie selections with a local `magpie/` provider prefix. */
951
1675
  function resolveCatalogModelId(agent, requestedModelId, catalog, modelName) {
952
1676
  if (catalog === null)