agl 41.0.1 → 41.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/changelog.md +24 -0
  2. package/dist_serve/bundle.js +1448 -1448
  3. package/dist_serve/bundle.js.map +2 -2
  4. package/dist_ts/00_commitinfo_data.js +1 -1
  5. package/dist_ts/aglapi/aglapisurface.document.js +7 -3
  6. package/dist_ts/classes.authmodels.d.ts +15 -0
  7. package/dist_ts/classes.authmodels.js +78 -7
  8. package/dist_ts/classes.authstore.js +23 -6
  9. package/dist_ts/classes.cli.js +8 -3
  10. package/dist_ts/classes.controller.d.ts +13 -0
  11. package/dist_ts/classes.controller.js +51 -14
  12. package/dist_ts/classes.controllermanagement.d.ts +53 -6
  13. package/dist_ts/classes.controllermanagement.js +180 -72
  14. package/dist_ts/classes.inferencegateway.d.ts +6 -6
  15. package/dist_ts/classes.inferencegateway.js +28 -15
  16. package/dist_ts/classes.inferenceusage.d.ts +19 -7
  17. package/dist_ts/classes.inferenceusage.js +49 -13
  18. package/dist_ts/classes.inferenceusagetap.d.ts +15 -3
  19. package/dist_ts/classes.inferenceusagetap.js +16 -9
  20. package/dist_ts/classes.processinspection.d.ts +3 -1
  21. package/dist_ts/classes.processinspection.js +4 -2
  22. package/dist_ts/classes.proxycontrol.d.ts +22 -7
  23. package/dist_ts/classes.proxycontrol.js +5 -3
  24. package/dist_ts/classes.proxycontrolclient.d.ts +4 -3
  25. package/dist_ts/classes.proxycontrolclient.js +7 -5
  26. package/dist_ts/classes.proxydaemon.d.ts +6 -4
  27. package/dist_ts/classes.proxydaemon.js +42 -17
  28. package/dist_ts/classes.proxyleaseholder.d.ts +11 -2
  29. package/dist_ts/classes.proxyleaseholder.js +25 -2
  30. package/dist_ts/classes.proxystore.d.ts +3 -2
  31. package/dist_ts/classes.proxystore.js +5 -2
  32. package/dist_ts/classes.upgradecoordinator.d.ts +2 -1
  33. package/dist_ts/classes.upgradecoordinator.js +13 -10
  34. package/dist_ts/classes.upgradetransaction.d.ts +6 -0
  35. package/dist_ts/classes.upgradetransaction.js +38 -13
  36. package/dist_ts/classes.usagefeed.js +3 -2
  37. package/dist_ts/functions.controllermanagementsurface.d.ts +37 -0
  38. package/dist_ts/functions.controllermanagementsurface.js +110 -0
  39. package/dist_ts/functions.inferencecli.d.ts +6 -3
  40. package/dist_ts/functions.inferencecli.js +9 -5
  41. package/dist_ts/functions.inferencecliarguments.d.ts +1 -1
  42. package/dist_ts/functions.inferencecliarguments.js +19 -7
  43. package/dist_ts/functions.inferencelaunch.d.ts +30 -9
  44. package/dist_ts/functions.inferencelaunch.js +49 -23
  45. package/dist_ts/interfaces.auth.d.ts +14 -3
  46. package/dist_ts/interfaces.auth.js +4 -3
  47. package/dist_ts_interfaces/index.d.ts +1 -0
  48. package/dist_ts_interfaces/index.js +2 -1
  49. package/dist_ts_interfaces/interfaces.d.ts +6 -1
  50. package/dist_ts_interfaces/interfaces.js +2 -2
  51. package/dist_ts_interfaces/management.d.ts +33 -0
  52. package/dist_ts_interfaces/management.js +35 -0
  53. package/dist_ts_interfaces/metrics.d.ts +29 -7
  54. package/dist_ts_interfaces/metrics.js +14 -1
  55. package/package.json +6 -5
  56. package/readme.md +123 -28
  57. package/ts/00_commitinfo_data.ts +1 -1
  58. package/ts/aglapi/aglapisurface.document.ts +6 -2
  59. package/ts/classes.authmodels.ts +74 -5
  60. package/ts/classes.authstore.ts +24 -4
  61. package/ts/classes.cli.ts +9 -2
  62. package/ts/classes.controller.ts +98 -59
  63. package/ts/classes.controllermanagement.ts +284 -107
  64. package/ts/classes.inferencegateway.ts +31 -19
  65. package/ts/classes.inferenceusage.ts +58 -19
  66. package/ts/classes.inferenceusagetap.ts +27 -8
  67. package/ts/classes.processinspection.ts +2 -1
  68. package/ts/classes.proxycontrol.ts +25 -9
  69. package/ts/classes.proxycontrolclient.ts +7 -4
  70. package/ts/classes.proxydaemon.ts +43 -17
  71. package/ts/classes.proxyleaseholder.ts +36 -7
  72. package/ts/classes.proxystore.ts +8 -3
  73. package/ts/classes.upgradecoordinator.ts +20 -11
  74. package/ts/classes.upgradetransaction.ts +53 -2
  75. package/ts/classes.usagefeed.ts +2 -1
  76. package/ts/functions.controllermanagementsurface.ts +149 -0
  77. package/ts/functions.inferencecli.ts +15 -7
  78. package/ts/functions.inferencecliarguments.ts +16 -6
  79. package/ts/functions.inferencelaunch.ts +57 -23
  80. package/ts/interfaces.auth.ts +14 -3
  81. package/ts_interfaces/index.ts +1 -0
  82. package/ts_interfaces/interfaces.ts +6 -1
  83. package/ts_interfaces/management.ts +52 -0
  84. package/ts_interfaces/metrics.ts +34 -7
  85. package/ts_web/00_commitinfo_data.ts +1 -1
  86. package/ts_web/classes.controllersocketclient.ts +5 -2
  87. package/ts_web/elements.metricsboard.ts +68 -18
@@ -37,10 +37,41 @@ const claudeNativeLaunch = (optionsArg: IInferenceLaunchOptions & { catalog: { p
37
37
  };
38
38
 
39
39
  /**
40
- * Claude Code on a ChatGPT pool: the proxy's Anthropic Messages endpoint answered by the pool's models, the default model
41
- * for every model alias, the context window the catalog states for it, and the pool's models as the whole `/model`
42
- * picker. Its configuration lives in its own directory, never the host's, so the host's `settings.json` cannot route it
43
- * elsewhere; it sends nothing beyond its model requests: no experimental betas, no nonessential traffic, no attribution.
40
+ * The variables that put Claude Code on a ChatGPT pool: the proxy's Anthropic Messages endpoint and credential, the
41
+ * pool's default model for every model alias, the context window the catalog states for it, and nothing beyond its model
42
+ * requests: no experimental betas, no nonessential traffic, no attribution. `agl proxy run claude --provider openai` and a
43
+ * ChatGPT-pool confined run give Claude Code these alike.
44
+ */
45
+ export const claudeChatGptEnvironment = (inputArg: {
46
+ baseUrl: string;
47
+ token: string;
48
+ models: readonly TInferenceModel[];
49
+ }): Record<string, string> => {
50
+ const model = inputArg.models.find((entryArg) => entryArg.isDefault) ?? inputArg.models[0];
51
+ if (!model) throw new Error('The ChatGPT pool offers no model Claude Code can run on.');
52
+ return {
53
+ ANTHROPIC_BASE_URL: inputArg.baseUrl,
54
+ ANTHROPIC_AUTH_TOKEN: inputArg.token,
55
+ ANTHROPIC_MODEL: model.modelId,
56
+ ANTHROPIC_DEFAULT_OPUS_MODEL: model.modelId,
57
+ ANTHROPIC_DEFAULT_SONNET_MODEL: model.modelId,
58
+ ANTHROPIC_DEFAULT_HAIKU_MODEL: model.modelId,
59
+ ...(model.contextWindow === undefined ? {} : { CLAUDE_CODE_MAX_CONTEXT_TOKENS: String(model.contextWindow) }),
60
+ CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS: '1',
61
+ CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
62
+ CLAUDE_CODE_ATTRIBUTION_HEADER: '0',
63
+ };
64
+ };
65
+
66
+ /** Every variable `claudeChatGptEnvironment` may set, the context window's included. */
67
+ const claudeChatGptVariables: readonly string[] = ['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_MODEL',
68
+ 'ANTHROPIC_DEFAULT_OPUS_MODEL', 'ANTHROPIC_DEFAULT_SONNET_MODEL', 'ANTHROPIC_DEFAULT_HAIKU_MODEL',
69
+ 'CLAUDE_CODE_MAX_CONTEXT_TOKENS', 'CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS', 'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC',
70
+ 'CLAUDE_CODE_ATTRIBUTION_HEADER'];
71
+
72
+ /**
73
+ * Claude Code on a ChatGPT pool (`claudeChatGptEnvironment`), with the pool's models as the whole `/model` picker. Its
74
+ * configuration lives in its own directory, never the host's, so the host's `settings.json` cannot route it elsewhere.
44
75
  */
45
76
  export const claudeChatGptLaunch = (inputArg: {
46
77
  baseUrl: string;
@@ -48,21 +79,10 @@ export const claudeChatGptLaunch = (inputArg: {
48
79
  configDirectory: string;
49
80
  models: readonly TInferenceModel[];
50
81
  }): { environment: Record<string, string>; args: string[] } => {
51
- const model = inputArg.models.find((entryArg) => entryArg.isDefault) ?? inputArg.models[0];
52
- if (!model) throw new Error('The ChatGPT pool offers no model Claude Code can run on.');
53
82
  return {
54
83
  environment: {
55
84
  CLAUDE_CONFIG_DIR: inputArg.configDirectory,
56
- ANTHROPIC_BASE_URL: inputArg.baseUrl,
57
- ANTHROPIC_AUTH_TOKEN: inputArg.token,
58
- ANTHROPIC_MODEL: model.modelId,
59
- ANTHROPIC_DEFAULT_OPUS_MODEL: model.modelId,
60
- ANTHROPIC_DEFAULT_SONNET_MODEL: model.modelId,
61
- ANTHROPIC_DEFAULT_HAIKU_MODEL: model.modelId,
62
- ...(model.contextWindow === undefined ? {} : { CLAUDE_CODE_MAX_CONTEXT_TOKENS: String(model.contextWindow) }),
63
- CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS: '1',
64
- CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1',
65
- CLAUDE_CODE_ATTRIBUTION_HEADER: '0',
85
+ ...claudeChatGptEnvironment(inputArg),
66
86
  },
67
87
  args: ['--settings', JSON.stringify({
68
88
  modelPicker: {
@@ -140,16 +160,23 @@ export const inferenceHarnessLaunch = (optionsArg: IInferenceLaunchOptions): { a
140
160
  return { environment, args: args[0] !== undefined && openCodeCommands.has(args[0]) ? args : [...selection, ...args] };
141
161
  };
142
162
 
163
+ /** A confined run's pool, as its launcher configures Claude Code for it: a ChatGPT-pool run with the pool's models. */
164
+ export type TConfinedLaunchPool = { provider: 'anthropic' } | { provider: 'openai'; models: readonly TInferenceModel[] };
165
+
143
166
  /**
144
167
  * A confined run's `docker run`: the given command, with the confined listener's directory mounted read-only at
145
168
  * `aglProxyConfinedMountPoint` and Claude Code's settings for it added right after `run`, before the launcher's own
146
169
  * options and image. Claude Code inside reaches the proxy through the socket alone (`ANTHROPIC_UNIX_SOCKET`, which
147
170
  * Claude Code sends its API requests through as plain HTTP to `ANTHROPIC_BASE_URL`'s path), on the run's credential
148
- * (`ANTHROPIC_AUTH_TOKEN`), reads the pool's models from the proxy and sends nothing beyond its model requests. The
149
- * container gets each variable by name from the docker command's environment, so the credential never appears on a
150
- * command line; that environment drops every variable that would give Claude Code another login or route, so `-e NAME`
151
- * of one of them passes nothing. The mount is the socket's own directory, which holds nothing else and survives a proxy
152
- * restart, unlike a mount of the socket file. Only `docker run` and `docker container run` are taken.
171
+ * (`ANTHROPIC_AUTH_TOKEN`), and sends nothing beyond its model requests. On the Claude pool it reads the pool's models
172
+ * from the proxy; on the ChatGPT pool it gets the variables `agl proxy run claude --provider openai` gives it
173
+ * (`claudeChatGptEnvironment`: the pool's default model for every alias, its context window), but neither that command's
174
+ * configuration directory, which lies in the AGL home, nor its `/model` picker, an option of the container's command
175
+ * that the launcher does not touch. The container gets each variable by name from the docker command's environment, so
176
+ * the credential never appears on a command line; that environment drops every variable that would give Claude Code
177
+ * another login or route, and on the ChatGPT pool every model variable it sets, so `-e NAME` of one of them passes
178
+ * nothing. The mount is the socket's own directory, which holds nothing else and survives a proxy restart, unlike a
179
+ * mount of the socket file. Only `docker run` and `docker container run` are taken.
153
180
  */
154
181
  export const confinedDockerLaunch = (optionsArg: {
155
182
  args: readonly string[];
@@ -159,6 +186,7 @@ export const confinedDockerLaunch = (optionsArg: {
159
186
  baseUrl: string;
160
187
  token: string;
161
188
  environment: NodeJS.ProcessEnv;
189
+ pool: TConfinedLaunchPool;
162
190
  }): { command: string; args: string[]; environment: NodeJS.ProcessEnv } => {
163
191
  const [command, ...args] = optionsArg.args;
164
192
  const runAt = args[0] === 'run' ? 1 : args[0] === 'container' && args[1] === 'run' ? 2 : 0;
@@ -168,13 +196,19 @@ export const confinedDockerLaunch = (optionsArg: {
168
196
  const directory = path.dirname(optionsArg.socketPath);
169
197
  // `--mount` separates its fields with commas and reads quotes; a directory naming either cannot be passed safely.
170
198
  if (/[,"'\n]/u.test(directory)) throw new Error(`The confined listener's directory ${directory} cannot be mounted: it holds a comma, quote or line break.`);
171
- const variables: Record<string, string> = {
199
+ const socket = `${aglProxyConfinedMountPoint}/${path.basename(optionsArg.socketPath)}`;
200
+ const variables: Record<string, string> = optionsArg.pool.provider === 'anthropic' ? {
172
201
  ...claudeProxyEnvironment(optionsArg.baseUrl),
173
- ANTHROPIC_UNIX_SOCKET: `${aglProxyConfinedMountPoint}/${path.basename(optionsArg.socketPath)}`,
202
+ ANTHROPIC_UNIX_SOCKET: socket,
174
203
  ANTHROPIC_AUTH_TOKEN: optionsArg.token,
204
+ } : {
205
+ ...claudeChatGptEnvironment({ baseUrl: `${optionsArg.baseUrl}/anthropic`, token: optionsArg.token,
206
+ models: optionsArg.pool.models }),
207
+ ANTHROPIC_UNIX_SOCKET: socket,
175
208
  };
176
209
  const environment = { ...optionsArg.environment };
177
210
  for (const name of claudeProxyReservedVariables) delete environment[name];
211
+ if (optionsArg.pool.provider === 'openai') for (const name of claudeChatGptVariables) delete environment[name];
178
212
  return {
179
213
  command,
180
214
  args: [...args.slice(0, runAt),
@@ -7,9 +7,10 @@ export type TControllerAuthLifecycleState = 'setupRequired' | 'ready';
7
7
  export type TWebAuthnCeremonyKind = 'setup' | 'authentication';
8
8
  export type TWebAuthnCeremonyState = 'pending' | 'consumed';
9
9
  /**
10
- * Single source of the audit event vocabulary: the persisted-document
10
+ * Single source of the audit types this release writes: the persisted-document
11
11
  * assertion consumes this list at runtime, so a type added here is
12
- * automatically storable — the two can no longer drift apart.
12
+ * automatically storable with its type-specific checks — the two can no longer
13
+ * drift apart. Other well-formed types are a later release's and still read.
13
14
  */
14
15
  export const controllerAuditEventTypes = [
15
16
  'controller.started',
@@ -249,11 +250,16 @@ export type TControllerAuditSessionId =
249
250
  | IControllerRuntimeId
250
251
  | plugins.browserRuntime.IBrowserClaudeSessionId;
251
252
 
253
+ /**
254
+ * A stored audit event. The audit log is shared with later releases, so a stored `type` is any audit type, also one
255
+ * this release does not know (a later release's, read back after a rollback). This release writes only its own types
256
+ * (`IRecordAuditEventInput`).
257
+ */
252
258
  export interface IControllerAuditEvent {
253
259
  id: string;
254
260
  controllerId: string;
255
261
  timestamp: Date;
256
- type: TControllerAuditEventType;
262
+ type: string;
257
263
  outcome: TControllerAuditOutcome;
258
264
  operationId?: string;
259
265
  peerId?: string;
@@ -263,6 +269,11 @@ export interface IControllerAuditEvent {
263
269
  /** The sender label an agent message declared (`session.message`). */
264
270
  senderLabel?: string;
265
271
  localCli?: IConfigurationLocalCliActor;
272
+ /**
273
+ * What a later release's type records beyond the envelope (bounded plain JSON). This release never writes or
274
+ * interprets it; it is declared so a rolled-back controller still reads and prunes such events.
275
+ */
276
+ details?: Record<string, unknown>;
266
277
  }
267
278
 
268
279
  export interface IControllerTempPasswordDocument {
@@ -9,6 +9,7 @@ export * from './functions.browserpaintcopy.js';
9
9
  export * from './devtools.js';
10
10
  export * from './requests.js';
11
11
  export * from './mcprequests.js';
12
+ export * from './management.js';
12
13
  export * from './codex.js';
13
14
  export * from './mcpworkspacerequests.js';
14
15
  export * from './mcpjobrequests.js';
@@ -5,7 +5,7 @@ import type {
5
5
  TControllerBrowserPaintAvailability,
6
6
  TControllerBrowserPaintTermination,
7
7
  } from './browserpaint.js';
8
- export const controllerProtocolVersion = 46 as const;
8
+ export const controllerProtocolVersion = 47 as const;
9
9
  export const controllerUpgradeManagementVersion = 2 as const;
10
10
  export const controllerPackageName = 'agl' as const;
11
11
  /** The systemd user unit `agl service install` writes; a controller it runs reports it in its status. */
@@ -325,6 +325,11 @@ export interface IControllerStatus {
325
325
  packageVersion: string;
326
326
  protocolVersion: typeof controllerProtocolVersion;
327
327
  upgradeManagementVersion: number;
328
+ /**
329
+ * The protocol of the controller's loopback management endpoint (`controllerManagementProtocolVersion`); absent
330
+ * from a controller older than the endpoint, which is reached over its socket.
331
+ */
332
+ managementProtocolVersion?: number;
328
333
  controllerPid: number;
329
334
  processGroupId: number;
330
335
  processFingerprint: string;
@@ -0,0 +1,52 @@
1
+ import type { IReq_ControllerStatus } from './requests.status.js';
2
+ import type {
3
+ IReq_ControllerUpgradeFinalize,
4
+ IReq_ControllerUpgradeLaunch,
5
+ IReq_ControllerUpgradePrepareBegin,
6
+ } from './requests.upgrade.js';
7
+
8
+ /**
9
+ * The controller's loopback management endpoint, on the controller's own port: one TypedRequest envelope
10
+ * (`method`, `request`, `response`, `correlation`, `requestInstanceId`) per HTTP POST of `application/json`, with
11
+ * `Host: 127.0.0.1:<port>`. The local CLI and the upgrade worker read the status and drive an upgrade through it,
12
+ * so an upgrade worker of one release can finalize the controller of the next whatever transport the browser
13
+ * speaks. Every later release keeps this path, these methods, their request and response shapes and the envelope
14
+ * (`test/test.controllermanagementwire.node.ts` pins the bytes an AGL 41 client sends and what it accepts).
15
+ */
16
+ export const controllerManagementTypedRequestPath = '/management/typedrequest' as const;
17
+
18
+ /**
19
+ * The protocol of this loopback management endpoint: its path, methods, envelope and their request and response
20
+ * shapes. 1 is the endpoint as its first release ships it; only an incompatible change to that contract raises it.
21
+ * A release declares it twice: as `controllerManagementProtocolVersion` in its `package.json`, which a client reads
22
+ * before the release's controller runs, and as `managementProtocolVersion` in that controller's status. A client
23
+ * reaches a controller over this endpoint only when it declares 1 or more, and over the controller socket otherwise
24
+ * (an older release has no endpoint).
25
+ *
26
+ * It is not `controllerUpgradeManagementVersion` (2), the upgrade contract: every upgrade worker requires exactly
27
+ * that number in its target's `package.json` and status, and upgrade transactions record it, so it cannot move
28
+ * without breaking the online upgrade of every earlier release. This number says only how the management methods
29
+ * are reached.
30
+ */
31
+ export const controllerManagementProtocolVersion = 1 as const;
32
+
33
+ /** The methods the endpoint answers; every other method is refused before it is routed. */
34
+ export const controllerManagementMethods = [
35
+ 'controller.status',
36
+ 'controller.upgrade.launch',
37
+ 'controller.upgrade.prepare.begin',
38
+ 'controller.upgrade.finalize',
39
+ ] as const;
40
+
41
+ export type TControllerManagementMethod = typeof controllerManagementMethods[number];
42
+
43
+ export type TControllerManagementTypedRequest =
44
+ | IReq_ControllerStatus
45
+ | IReq_ControllerUpgradeLaunch
46
+ | IReq_ControllerUpgradePrepareBegin
47
+ | IReq_ControllerUpgradeFinalize;
48
+
49
+ /** A management request carries a token, a version and a mode at most. */
50
+ export const controllerManagementRequestMaximumBodyBytes = 16 * 1024;
51
+ /** The status document with every harness profile and upgrade blocker it may report. */
52
+ export const controllerManagementResponseMaximumBytes = 1024 * 1024;
@@ -10,8 +10,27 @@ export type TControllerUsageWindow = '1h' | '24h' | '7d';
10
10
  export const controllerUsageWindows: readonly TControllerUsageWindow[] = ['1h', '24h', '7d'];
11
11
  export type TControllerUsageProvider = 'anthropic' | 'openai';
12
12
 
13
+ /**
14
+ * The tokens of the answers one bucket counts. Input counts only the tokens read neither from nor written to the
15
+ * provider's prompt cache. `cacheReadTokens` and `cacheWriteTokens` add up the cache reads and writes the answers
16
+ * stated; `cacheReadUnknownCalls` and `cacheWriteUnknownCalls` (each at most `calls`) count the answers that did not
17
+ * state theirs, which read or wrote an unknown number, not none. With either above 0 the stated sum is not the whole
18
+ * count, and `controllerUsageTotalIsLowerBound` of the bucket's provider says whether its total is then one. A bucket
19
+ * stored before these were counted reads with all its answers unknown.
20
+ */
21
+ export interface IControllerUsageCounts {
22
+ /** The answers counted. */
23
+ calls: number;
24
+ inputTokens: number;
25
+ outputTokens: number;
26
+ cacheReadTokens: number;
27
+ cacheWriteTokens: number;
28
+ cacheReadUnknownCalls: number;
29
+ cacheWriteUnknownCalls: number;
30
+ }
31
+
13
32
  /** One span of one model's tokens on one harness, account and session; never a prompt, a credential or an answer. */
14
- export interface IControllerUsageBucket {
33
+ export interface IControllerUsageBucket extends IControllerUsageCounts {
15
34
  /** The bucket's digest: SHA-256 of its span, start and dimensions. */
16
35
  id: string;
17
36
  span: TControllerUsageSpan;
@@ -25,14 +44,22 @@ export interface IControllerUsageBucket {
25
44
  accountId: string;
26
45
  /** The session id the client sent; `null` when it sent none. */
27
46
  session: string | null;
28
- /** The answers counted. */
29
- calls: number;
30
- inputTokens: number;
31
- outputTokens: number;
32
- cacheReadTokens: number;
33
- cacheWriteTokens: number;
34
47
  }
35
48
 
49
+ /**
50
+ * Whether a provider's answers state their input without the tokens read from or written to the cache, so that a bucket
51
+ * of that provider with unknown cache counts (`cacheReadUnknownCalls` or `cacheWriteUnknownCalls` above 0) has total
52
+ * tokens (input, output, cache reads and cache writes) that are a lower bound of what its answers used. An Anthropic
53
+ * account's answers leave the cache out of their input, so a cache count such an answer did not state is counted
54
+ * nowhere. An OpenAI account's answers state every input token in their input count, the Messages answers the proxy
55
+ * translates from them included, so their unknown cache counts lie inside the input and their total is exact. Every
56
+ * provider must be named here: a new one fails the type check instead of reading as exact.
57
+ */
58
+ export const controllerUsageTotalIsLowerBound: Readonly<Record<TControllerUsageProvider, boolean>> = {
59
+ anthropic: true,
60
+ openai: false,
61
+ };
62
+
36
63
  const minuteMs = 60_000;
37
64
  const hourMs = 60 * minuteMs;
38
65
 
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: 'agl',
6
- version: '41.0.1',
6
+ version: '41.2.0',
7
7
  description: 'Agent Gateway Layer for OpenCode, FlexHarness, and Codex sessions'
8
8
  }
@@ -606,7 +606,8 @@ export const isControllerSessionFeedList = (
606
606
 
607
607
  const usageBucketKeys = contractKeys<interfaces.IControllerUsageBucket>({
608
608
  id: true, span: true, start: true, provider: true, model: true, harness: true, accountId: true, session: true,
609
- calls: true, inputTokens: true, outputTokens: true, cacheReadTokens: true, cacheWriteTokens: true,
609
+ calls: true, inputTokens: true, outputTokens: true, cacheReadTokens: true, cacheWriteTokens: true, cacheReadUnknownCalls: true,
610
+ cacheWriteUnknownCalls: true,
610
611
  });
611
612
  const usageHex64 = /^[a-f0-9]{64}$/;
612
613
  const usageCount = (valueArg: unknown): boolean => Number.isSafeInteger(valueArg) && (valueArg as number) >= 0;
@@ -629,7 +630,9 @@ export const isControllerUsageBucket = (valueArg: unknown): valueArg is interfac
629
630
  && (candidate.session === null || usageText(candidate.session, 512))
630
631
  && usageCount(candidate.calls) && (candidate.calls as number) >= 1
631
632
  && usageCount(candidate.inputTokens) && usageCount(candidate.outputTokens)
632
- && usageCount(candidate.cacheReadTokens) && usageCount(candidate.cacheWriteTokens);
633
+ && usageCount(candidate.cacheReadTokens) && usageCount(candidate.cacheWriteTokens)
634
+ && usageCount(candidate.cacheReadUnknownCalls) && (candidate.cacheReadUnknownCalls as number) <= (candidate.calls as number)
635
+ && usageCount(candidate.cacheWriteUnknownCalls) && (candidate.cacheWriteUnknownCalls as number) <= (candidate.calls as number);
633
636
  };
634
637
 
635
638
  export const isControllerUsageDelta = (valueArg: unknown): valueArg is interfaces.TControllerUsageDelta => {
@@ -13,23 +13,29 @@ export interface IMetricsBoardSender {
13
13
  ): Promise<TRequest['response']>;
14
14
  }
15
15
 
16
- type TUsageCounts = Pick<interfaces.IControllerUsageBucket,
17
- 'calls' | 'inputTokens' | 'outputTokens' | 'cacheReadTokens' | 'cacheWriteTokens'>;
16
+ type TUsageCounts = interfaces.IControllerUsageCounts;
17
+
18
+ /**
19
+ * A table row's counts: its buckets' counts, their total tokens and whether that total is a lower bound
20
+ * (some bucket of it has unknown cache counts and a provider `controllerUsageTotalIsLowerBound` marks).
21
+ */
22
+ interface IMetricsRowCounts extends TUsageCounts {
23
+ totalTokens: number;
24
+ totalLowerBound: boolean;
25
+ }
18
26
 
19
27
  /** A model's usage over the window: one row of the models table. */
20
- export interface IMetricsModelRow extends TUsageCounts {
28
+ export interface IMetricsModelRow extends IMetricsRowCounts {
21
29
  key: string;
22
30
  provider: interfaces.TControllerUsageProvider;
23
31
  model: string;
24
- totalTokens: number;
25
32
  }
26
33
 
27
34
  /** One session on one harness of the opened model: one row of the drill-down table. */
28
- export interface IMetricsSessionRow extends TUsageCounts {
35
+ export interface IMetricsSessionRow extends IMetricsRowCounts {
29
36
  key: string;
30
37
  harness: string;
31
38
  session: string;
32
- totalTokens: number;
33
39
  }
34
40
 
35
41
  const windowOptions: { key: interfaces.TControllerUsageWindow; label: string; description: string }[] = [
@@ -42,12 +48,15 @@ const modelKey = (bucketArg: Pick<interfaces.IControllerUsageBucket, 'provider'
42
48
  `${bucketArg.provider}/${bucketArg.model}`
43
49
  );
44
50
 
45
- /** Every token a call counted: input, output, cache read and cache write. */
51
+ /** Every token a call counted: input, output and the cache reads and writes it stated. */
46
52
  const totalTokens = (countsArg: TUsageCounts): number => (
47
53
  countsArg.inputTokens + countsArg.outputTokens + countsArg.cacheReadTokens + countsArg.cacheWriteTokens
48
54
  );
49
55
 
50
- const emptyCounts = (): TUsageCounts => ({ calls: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 });
56
+ const emptyCounts = (): TUsageCounts => ({ calls: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0,
57
+ cacheReadUnknownCalls: 0, cacheWriteUnknownCalls: 0 });
58
+
59
+ const emptyRowCounts = (): IMetricsRowCounts => ({ ...emptyCounts(), totalTokens: 0, totalLowerBound: false });
51
60
 
52
61
  const addCounts = (targetArg: TUsageCounts, bucketArg: TUsageCounts): void => {
53
62
  targetArg.calls += bucketArg.calls;
@@ -55,6 +64,31 @@ const addCounts = (targetArg: TUsageCounts, bucketArg: TUsageCounts): void => {
55
64
  targetArg.outputTokens += bucketArg.outputTokens;
56
65
  targetArg.cacheReadTokens += bucketArg.cacheReadTokens;
57
66
  targetArg.cacheWriteTokens += bucketArg.cacheWriteTokens;
67
+ targetArg.cacheReadUnknownCalls += bucketArg.cacheReadUnknownCalls;
68
+ targetArg.cacheWriteUnknownCalls += bucketArg.cacheWriteUnknownCalls;
69
+ };
70
+
71
+ const addBucket = (rowArg: IMetricsRowCounts, bucketArg: interfaces.IControllerUsageBucket): void => {
72
+ addCounts(rowArg, bucketArg);
73
+ rowArg.totalTokens += totalTokens(bucketArg);
74
+ rowArg.totalLowerBound ||= interfaces.controllerUsageTotalIsLowerBound[bucketArg.provider]
75
+ && (bucketArg.cacheReadUnknownCalls > 0 || bucketArg.cacheWriteUnknownCalls > 0);
76
+ };
77
+
78
+ /** The cache counts the board states: reads and writes, each with the calls that did not state theirs. */
79
+ const cacheCounts = {
80
+ cacheReadTokens: 'cacheReadUnknownCalls',
81
+ cacheWriteTokens: 'cacheWriteUnknownCalls',
82
+ } as const satisfies Record<string, keyof TUsageCounts>;
83
+
84
+ /**
85
+ * A cache count as the board states it: what the calls stated; "≥" before it when some calls did not state theirs, as
86
+ * those read or wrote an unknown number, not none; and "Unknown" when no call stated any.
87
+ */
88
+ const cacheText = (countsArg: TUsageCounts, keyArg: keyof typeof cacheCounts, formatArg: Intl.NumberFormat): string => {
89
+ const stated = countsArg[keyArg], unknownCalls = countsArg[cacheCounts[keyArg]];
90
+ return unknownCalls === 0 ? formatArg.format(stated)
91
+ : stated === 0 && unknownCalls === countsArg.calls ? 'Unknown' : `≥ ${formatArg.format(stated)}`;
58
92
  };
59
93
 
60
94
  const compactNumber = new Intl.NumberFormat(undefined, { notation: 'compact', maximumFractionDigits: 1 });
@@ -68,7 +102,8 @@ const pointTimeFormatters: Record<interfaces.TControllerUsageSpan, (epochMsArg:
68
102
  hour: (epochMsArg) => momentFormat.formatRange(epochMsArg, epochMsArg + interfaces.controllerUsageSpanMs.hour),
69
103
  };
70
104
 
71
- const countColumns = <TRow extends TUsageCounts & { totalTokens: number }>(): plugins.deesCatalog.Column<TRow>[] => (
105
+ /** The count columns; a cache count as `cacheText` states it, and the total with "≥" when it is a lower bound. */
106
+ const countColumns = <TRow extends IMetricsRowCounts>(): plugins.deesCatalog.Column<TRow>[] => (
72
107
  ([
73
108
  ['calls', 'Calls'],
74
109
  ['inputTokens', 'Input'],
@@ -80,7 +115,9 @@ const countColumns = <TRow extends TUsageCounts & { totalTokens: number }>(): pl
80
115
  key: keyArg,
81
116
  header: headerArg,
82
117
  align: 'end' as const,
83
- renderer: (valueArg: number) => fullNumber.format(valueArg),
118
+ renderer: (valueArg: number, rowArg: TRow) => (keyArg === 'cacheReadTokens' || keyArg === 'cacheWriteTokens'
119
+ ? cacheText(rowArg, keyArg, fullNumber)
120
+ : keyArg === 'totalTokens' && rowArg.totalLowerBound ? `≥ ${fullNumber.format(valueArg)}` : fullNumber.format(valueArg)),
84
121
  }))
85
122
  );
86
123
 
@@ -375,11 +412,10 @@ export class AglMetricsBoard extends plugins.deesElement.DeesElement {
375
412
  const key = modelKey(bucket);
376
413
  let row = rows.get(key);
377
414
  if (!row) {
378
- row = { key, provider: bucket.provider, model: bucket.model, ...emptyCounts(), totalTokens: 0 };
415
+ row = { key, provider: bucket.provider, model: bucket.model, ...emptyRowCounts() };
379
416
  rows.set(key, row);
380
417
  }
381
- addCounts(row, bucket);
382
- row.totalTokens += totalTokens(bucket);
418
+ addBucket(row, bucket);
383
419
  }
384
420
  return [...rows.values()];
385
421
  }
@@ -393,11 +429,10 @@ export class AglMetricsBoard extends plugins.deesElement.DeesElement {
393
429
  const key = JSON.stringify([bucket.harness, bucket.session]);
394
430
  let row = rows.get(key);
395
431
  if (!row) {
396
- row = { key, harness, session, ...emptyCounts(), totalTokens: 0 };
432
+ row = { key, harness, session, ...emptyRowCounts() };
397
433
  rows.set(key, row);
398
434
  }
399
- addCounts(row, bucket);
400
- row.totalTokens += totalTokens(bucket);
435
+ addBucket(row, bucket);
401
436
  }
402
437
  return [...rows.values()];
403
438
  }
@@ -409,12 +444,27 @@ export class AglMetricsBoard extends plugins.deesElement.DeesElement {
409
444
  return [
410
445
  { id: 'input', title: 'Input', value: value(counts.inputTokens), unit: 'tokens', type: 'number', icon: 'lucide:ArrowDownToLine' },
411
446
  { id: 'output', title: 'Output', value: value(counts.outputTokens), unit: 'tokens', type: 'number', icon: 'lucide:ArrowUpFromLine' },
412
- { id: 'cacheRead', title: 'Cache read', value: value(counts.cacheReadTokens), unit: 'tokens', type: 'number', icon: 'lucide:DatabaseZap' },
413
- { id: 'cacheWrite', title: 'Cache write', value: value(counts.cacheWriteTokens), unit: 'tokens', type: 'number', icon: 'lucide:Database' },
447
+ this.cacheTile(counts, knownArg, 'cacheReadTokens'),
448
+ this.cacheTile(counts, knownArg, 'cacheWriteTokens'),
414
449
  { id: 'calls', title: 'Calls', value: value(counts.calls), type: 'number', icon: 'lucide:Activity' },
415
450
  ];
416
451
  }
417
452
 
453
+ /**
454
+ * A cache read or write tile: "Unknown" without a unit when no call stated its count, "≥" the stated count when some
455
+ * did not, and then how many calls left it unknown.
456
+ */
457
+ private cacheTile(countsArg: TUsageCounts, knownArg: boolean, keyArg: keyof typeof cacheCounts): plugins.deesCatalog.IStatsTile {
458
+ const text = knownArg ? cacheText(countsArg, keyArg, compactNumber) : '–';
459
+ const unknownCalls = countsArg[cacheCounts[keyArg]];
460
+ const tile = keyArg === 'cacheReadTokens' ? { id: 'cacheRead', title: 'Cache read', icon: 'lucide:DatabaseZap' }
461
+ : { id: 'cacheWrite', title: 'Cache write', icon: 'lucide:Database' };
462
+ return { ...tile, value: text, ...(text === 'Unknown' ? {} : { unit: 'tokens' }), type: 'number',
463
+ ...(knownArg && unknownCalls > 0
464
+ ? { description: `Unknown for ${fullNumber.format(unknownCalls)} of ${fullNumber.format(countsArg.calls)} calls` }
465
+ : {}) };
466
+ }
467
+
418
468
  /**
419
469
  * One series per model, the largest first, each drawn from zero: its tokens per minute in every span of the window,
420
470
  * the empty ones as 0. A minute that is still open is left out, as its count is partial; the totals and tables keep