remote-codex 0.11.30 → 0.11.32

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 (37) hide show
  1. package/README.md +12 -4
  2. package/apps/relay-server/dist/index.js +2956 -164
  3. package/apps/supervisor-api/dist/index.js +521 -64
  4. package/apps/supervisor-web/dist/apple-touch-icon.png +0 -0
  5. package/apps/supervisor-web/dist/assets/index-CGHHTNkM.js +21 -0
  6. package/apps/supervisor-web/dist/assets/index-CdjTdnJt.css +1 -0
  7. package/apps/supervisor-web/dist/assets/thread-ui-B9eC2H4u.js +3677 -0
  8. package/apps/supervisor-web/dist/favicon-16x16.png +0 -0
  9. package/apps/supervisor-web/dist/favicon-32x32.png +0 -0
  10. package/apps/supervisor-web/dist/favicon-48x48.png +0 -0
  11. package/apps/supervisor-web/dist/icon-192.png +0 -0
  12. package/apps/supervisor-web/dist/icon-512.png +0 -0
  13. package/apps/supervisor-web/dist/index.html +10 -4
  14. package/apps/supervisor-web/dist/remote-codex-icon.png +0 -0
  15. package/apps/supervisor-web/dist/site.webmanifest +19 -0
  16. package/bin/remote-codex.mjs +6 -3
  17. package/config/codex-model-pricing.json +44 -0
  18. package/package.json +1 -1
  19. package/packages/agent-runtime/src/model-pricing.ts +66 -6
  20. package/packages/agent-runtime/src/types.ts +13 -0
  21. package/packages/claude/src/runtimeAdapter.test.ts +64 -0
  22. package/packages/claude/src/runtimeAdapter.ts +64 -0
  23. package/packages/codex/src/appServerManager.ts +16 -0
  24. package/packages/codex/src/modelPricing.test.ts +84 -0
  25. package/packages/codex/src/runtimeAdapter.test.ts +44 -0
  26. package/packages/codex/src/runtimeAdapter.ts +66 -0
  27. package/packages/codex/src/types.ts +3 -1
  28. package/packages/db/migrations/0029_thread_turn_delivery.sql +19 -0
  29. package/packages/db/src/repositories.ts +96 -1
  30. package/packages/db/src/schema.ts +20 -0
  31. package/packages/opencode/src/runtimeAdapter.ts +15 -0
  32. package/packages/shared/src/index.ts +203 -7
  33. package/scripts/run-web-service.mjs +2 -2
  34. package/scripts/service-manager.mjs +2 -2
  35. package/apps/supervisor-web/dist/assets/index-BnpZn_3_.js +0 -6
  36. package/apps/supervisor-web/dist/assets/index-CJFMmjP5.css +0 -1
  37. package/apps/supervisor-web/dist/assets/thread-ui-C0VPL4Uk.js +0 -3677
@@ -3,17 +3,23 @@
3
3
  <head>
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
- <title>Remote Codex Supervisor</title>
7
- <script type="module" crossorigin src="/assets/index-BnpZn_3_.js"></script>
6
+ <meta name="theme-color" content="#101722" />
7
+ <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
8
+ <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
9
+ <link rel="icon" type="image/png" sizes="48x48" href="/favicon-48x48.png" />
10
+ <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
11
+ <link rel="manifest" href="/site.webmanifest" />
12
+ <title>Remote Codex</title>
13
+ <script type="module" crossorigin src="/assets/index-CGHHTNkM.js"></script>
8
14
  <link rel="modulepreload" crossorigin href="/assets/react-vendor-Dfg_6BLf.js">
9
15
  <link rel="modulepreload" crossorigin href="/assets/ui-vendor-CuR8GHb0.js">
10
16
  <link rel="modulepreload" crossorigin href="/assets/graph-vendor-DVQUpZ8C.js">
11
17
  <link rel="modulepreload" crossorigin href="/assets/terminal-vendor-C5bTa-Ka.js">
12
18
  <link rel="modulepreload" crossorigin href="/assets/markdown-vendor-RZk8L7-L.js">
13
- <link rel="modulepreload" crossorigin href="/assets/thread-ui-C0VPL4Uk.js">
19
+ <link rel="modulepreload" crossorigin href="/assets/thread-ui-B9eC2H4u.js">
14
20
  <link rel="stylesheet" crossorigin href="/assets/graph-vendor-C5ap-Sga.css">
15
21
  <link rel="stylesheet" crossorigin href="/assets/terminal-vendor-Beg8tuEN.css">
16
- <link rel="stylesheet" crossorigin href="/assets/index-CJFMmjP5.css">
22
+ <link rel="stylesheet" crossorigin href="/assets/index-CdjTdnJt.css">
17
23
  </head>
18
24
  <body class="bg-stone-950">
19
25
  <div id="root"></div>
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "Remote Codex",
3
+ "short_name": "Remote Codex",
4
+ "icons": [
5
+ {
6
+ "src": "/icon-192.png",
7
+ "sizes": "192x192",
8
+ "type": "image/png"
9
+ },
10
+ {
11
+ "src": "/icon-512.png",
12
+ "sizes": "512x512",
13
+ "type": "image/png"
14
+ }
15
+ ],
16
+ "theme_color": "#101722",
17
+ "background_color": "#101722",
18
+ "display": "standalone"
19
+ }
@@ -851,9 +851,9 @@ What it starts:
851
851
  API http://SERVICE_API_HOST:SERVICE_API_PORT
852
852
 
853
853
  Environment:
854
- SERVICE_HOST Web listen host. Default 127.0.0.1.
854
+ SERVICE_HOST Web listen host. Default 0.0.0.0.
855
855
  SERVICE_PORT Web listen port. Default ${defaultServicePort}.
856
- SERVICE_API_HOST API listen host. Default 127.0.0.1.
856
+ SERVICE_API_HOST API listen host. Default 0.0.0.0.
857
857
  SERVICE_API_PORT API listen port. Default ${defaultApiPort}.
858
858
  REMOTE_CODEX_SERVICE_DIR Service state/log directory.
859
859
  LOG_LEVEL API log level. Default warn for service mode.
@@ -871,7 +871,10 @@ Supervisor configuration forwarded to the API:
871
871
  OPENCODE_COMMAND OpenCode executable. Default opencode.
872
872
 
873
873
  Example:
874
- SERVICE_HOST=127.0.0.1 SERVICE_PORT=4173 remote-codex start
874
+ remote-codex start
875
+
876
+ Restrict the service to this machine:
877
+ SERVICE_HOST=127.0.0.1 SERVICE_API_HOST=127.0.0.1 remote-codex start
875
878
 
876
879
  Expose over Tailscale after start:
877
880
  tailscale serve --bg http://127.0.0.1:${defaultServicePort}
@@ -9,6 +9,50 @@
9
9
  }
10
10
  },
11
11
  "models": {
12
+ "gpt-5.6": {
13
+ "inputUsdPerMillion": 5,
14
+ "cachedInputUsdPerMillion": 0.5,
15
+ "cacheWriteInputUsdPerMillion": 6.25,
16
+ "outputUsdPerMillion": 30,
17
+ "supportsFastMode": true,
18
+ "contextWindowTokens": 353400,
19
+ "longContextThresholdTokens": 272000,
20
+ "longContextInputMultiplier": 2,
21
+ "longContextOutputMultiplier": 1.5
22
+ },
23
+ "gpt-5.6-sol": {
24
+ "inputUsdPerMillion": 5,
25
+ "cachedInputUsdPerMillion": 0.5,
26
+ "cacheWriteInputUsdPerMillion": 6.25,
27
+ "outputUsdPerMillion": 30,
28
+ "supportsFastMode": true,
29
+ "contextWindowTokens": 353400,
30
+ "longContextThresholdTokens": 272000,
31
+ "longContextInputMultiplier": 2,
32
+ "longContextOutputMultiplier": 1.5
33
+ },
34
+ "gpt-5.6-terra": {
35
+ "inputUsdPerMillion": 2.5,
36
+ "cachedInputUsdPerMillion": 0.25,
37
+ "cacheWriteInputUsdPerMillion": 3.125,
38
+ "outputUsdPerMillion": 15,
39
+ "supportsFastMode": true,
40
+ "contextWindowTokens": 353400,
41
+ "longContextThresholdTokens": 272000,
42
+ "longContextInputMultiplier": 2,
43
+ "longContextOutputMultiplier": 1.5
44
+ },
45
+ "gpt-5.6-luna": {
46
+ "inputUsdPerMillion": 1,
47
+ "cachedInputUsdPerMillion": 0.1,
48
+ "cacheWriteInputUsdPerMillion": 1.25,
49
+ "outputUsdPerMillion": 6,
50
+ "supportsFastMode": true,
51
+ "contextWindowTokens": 353400,
52
+ "longContextThresholdTokens": 272000,
53
+ "longContextInputMultiplier": 2,
54
+ "longContextOutputMultiplier": 1.5
55
+ },
12
56
  "gpt-5.5": {
13
57
  "inputUsdPerMillion": 5,
14
58
  "cachedInputUsdPerMillion": 0.5,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "remote-codex",
3
- "version": "0.11.30",
3
+ "version": "0.11.32",
4
4
  "description": "Local web supervisor for Codex workspaces and threads.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -11,10 +11,14 @@ export type PricingTierKey = 'standard' | 'fast';
11
11
  interface ModelPricingEntry {
12
12
  inputUsdPerMillion: number;
13
13
  cachedInputUsdPerMillion: number;
14
+ cacheWriteInputUsdPerMillion?: number;
14
15
  outputUsdPerMillion: number;
15
16
  supportsFastMode: boolean;
16
17
  fastMultiplier?: number;
17
18
  contextWindowTokens?: number;
19
+ longContextThresholdTokens?: number;
20
+ longContextInputMultiplier?: number;
21
+ longContextOutputMultiplier?: number;
18
22
  }
19
23
 
20
24
  interface PricingTierConfig {
@@ -119,10 +123,14 @@ function parsePricingConfig(raw: unknown): PricingConfig {
119
123
  const entry = value as {
120
124
  inputUsdPerMillion?: unknown;
121
125
  cachedInputUsdPerMillion?: unknown;
126
+ cacheWriteInputUsdPerMillion?: unknown;
122
127
  outputUsdPerMillion?: unknown;
123
128
  supportsFastMode?: unknown;
124
129
  fastMultiplier?: unknown;
125
130
  contextWindowTokens?: unknown;
131
+ longContextThresholdTokens?: unknown;
132
+ longContextInputMultiplier?: unknown;
133
+ longContextOutputMultiplier?: unknown;
126
134
  };
127
135
 
128
136
  if (
@@ -133,6 +141,12 @@ function parsePricingConfig(raw: unknown): PricingConfig {
133
141
  ) {
134
142
  throw new Error(`Pricing config model "${modelKey}" has invalid fields.`);
135
143
  }
144
+ if (
145
+ entry.cacheWriteInputUsdPerMillion !== undefined &&
146
+ !isPositiveNumber(entry.cacheWriteInputUsdPerMillion)
147
+ ) {
148
+ throw new Error(`Pricing config model "${modelKey}" cacheWriteInputUsdPerMillion must be a non-negative number.`);
149
+ }
136
150
  if (
137
151
  entry.fastMultiplier !== undefined &&
138
152
  !isPositiveNumber(entry.fastMultiplier)
@@ -145,10 +159,22 @@ function parsePricingConfig(raw: unknown): PricingConfig {
145
159
  ) {
146
160
  throw new Error(`Pricing config model "${modelKey}" contextWindowTokens must be a non-negative number.`);
147
161
  }
162
+ for (const field of [
163
+ 'longContextThresholdTokens',
164
+ 'longContextInputMultiplier',
165
+ 'longContextOutputMultiplier',
166
+ ] as const) {
167
+ if (entry[field] !== undefined && !isPositiveNumber(entry[field])) {
168
+ throw new Error(`Pricing config model "${modelKey}" ${field} must be a non-negative number.`);
169
+ }
170
+ }
148
171
 
149
172
  models[modelKey] = {
150
173
  inputUsdPerMillion: entry.inputUsdPerMillion as number,
151
174
  cachedInputUsdPerMillion: entry.cachedInputUsdPerMillion as number,
175
+ ...(entry.cacheWriteInputUsdPerMillion !== undefined
176
+ ? { cacheWriteInputUsdPerMillion: entry.cacheWriteInputUsdPerMillion as number }
177
+ : {}),
152
178
  outputUsdPerMillion: entry.outputUsdPerMillion as number,
153
179
  supportsFastMode: entry.supportsFastMode,
154
180
  ...(entry.fastMultiplier !== undefined
@@ -157,6 +183,15 @@ function parsePricingConfig(raw: unknown): PricingConfig {
157
183
  ...(entry.contextWindowTokens !== undefined
158
184
  ? { contextWindowTokens: entry.contextWindowTokens as number }
159
185
  : {}),
186
+ ...(entry.longContextThresholdTokens !== undefined
187
+ ? { longContextThresholdTokens: entry.longContextThresholdTokens as number }
188
+ : {}),
189
+ ...(entry.longContextInputMultiplier !== undefined
190
+ ? { longContextInputMultiplier: entry.longContextInputMultiplier as number }
191
+ : {}),
192
+ ...(entry.longContextOutputMultiplier !== undefined
193
+ ? { longContextOutputMultiplier: entry.longContextOutputMultiplier as number }
194
+ : {}),
160
195
  };
161
196
  }
162
197
 
@@ -293,24 +328,48 @@ export function estimateTurnPrice(
293
328
  }
294
329
 
295
330
  const nonCachedInputTokens = Math.max(
296
- usage.total.inputTokens - usage.total.cachedInputTokens,
331
+ usage.total.inputTokens -
332
+ usage.total.cachedInputTokens -
333
+ (usage.total.cacheWriteInputTokens ?? 0),
297
334
  0,
298
335
  );
299
336
  const cachedInputTokens = Math.max(usage.total.cachedInputTokens, 0);
337
+ const cacheWriteInputTokens = Math.max(
338
+ usage.total.cacheWriteInputTokens ?? 0,
339
+ 0,
340
+ );
300
341
  const outputTokens = Math.max(usage.total.outputTokens, 0);
301
- const multiplier =
342
+ const tierMultiplier =
302
343
  tierKey === 'fast' && modelPricing.fastMultiplier !== undefined
303
344
  ? modelPricing.fastMultiplier
304
345
  : tier.multiplier;
346
+ const usesLongContextPricing =
347
+ typeof modelPricing.longContextThresholdTokens === 'number' &&
348
+ usage.total.inputTokens > modelPricing.longContextThresholdTokens;
349
+ const inputMultiplier =
350
+ tierMultiplier *
351
+ (usesLongContextPricing
352
+ ? modelPricing.longContextInputMultiplier ?? 1
353
+ : 1);
354
+ const outputMultiplier =
355
+ tierMultiplier *
356
+ (usesLongContextPricing
357
+ ? modelPricing.longContextOutputMultiplier ?? 1
358
+ : 1);
305
359
 
306
360
  const inputUsd =
307
- (nonCachedInputTokens * modelPricing.inputUsdPerMillion * multiplier) /
361
+ (nonCachedInputTokens * modelPricing.inputUsdPerMillion * inputMultiplier) /
308
362
  TOKEN_PRICE_DENOMINATOR;
309
363
  const cachedInputUsd =
310
- (cachedInputTokens * modelPricing.cachedInputUsdPerMillion * multiplier) /
364
+ (cachedInputTokens * modelPricing.cachedInputUsdPerMillion * inputMultiplier) /
365
+ TOKEN_PRICE_DENOMINATOR;
366
+ const cacheWriteInputUsd =
367
+ (cacheWriteInputTokens *
368
+ (modelPricing.cacheWriteInputUsdPerMillion ?? modelPricing.inputUsdPerMillion) *
369
+ inputMultiplier) /
311
370
  TOKEN_PRICE_DENOMINATOR;
312
371
  const outputUsd =
313
- (outputTokens * modelPricing.outputUsdPerMillion * multiplier) /
372
+ (outputTokens * modelPricing.outputUsdPerMillion * outputMultiplier) /
314
373
  TOKEN_PRICE_DENOMINATOR;
315
374
 
316
375
  return {
@@ -319,7 +378,8 @@ export function estimateTurnPrice(
319
378
  currency: pricingConfig.currency,
320
379
  inputUsd,
321
380
  cachedInputUsd,
381
+ cacheWriteInputUsd,
322
382
  outputUsd,
323
- totalUsd: inputUsd + cachedInputUsd + outputUsd,
383
+ totalUsd: inputUsd + cachedInputUsd + cacheWriteInputUsd + outputUsd,
324
384
  };
325
385
  }
@@ -582,4 +582,17 @@ export interface AgentRuntime extends EventEmitter {
582
582
  getGoal?(providerSessionId: string): Promise<AgentGoal | null>;
583
583
  setGoal?(input: SetAgentGoalInput): Promise<AgentGoal>;
584
584
  clearGoal?(providerSessionId: string): Promise<boolean>;
585
+ getSubscriptionUsage?(): Promise<{
586
+ provider: 'codex' | 'claude';
587
+ authKind: 'subscription' | 'apiKey' | 'unknown';
588
+ observedAt: string;
589
+ stale: boolean;
590
+ windows: Array<{
591
+ id: string;
592
+ durationMinutes: number | null;
593
+ label: string;
594
+ usedPercent: number;
595
+ resetsAt: string | null;
596
+ }>;
597
+ } | null>;
585
598
  }
@@ -208,6 +208,70 @@ function makeAdapter(
208
208
  }
209
209
 
210
210
  describe('ClaudeRuntimeAdapter', () => {
211
+ it('captures Claude subscription rate-limit windows from SDK events', async () => {
212
+ const adapter = makeAdapter(() => [
213
+ systemInit(),
214
+ {
215
+ type: 'rate_limit_event',
216
+ rate_limit_info: {
217
+ status: 'allowed',
218
+ rateLimitType: 'five_hour',
219
+ utilization: 0.23,
220
+ resetsAt: 1_800_000_000,
221
+ },
222
+ uuid: '00000000-0000-4000-8000-000000000003',
223
+ session_id: 'claude-session-1',
224
+ },
225
+ {
226
+ type: 'rate_limit_event',
227
+ rate_limit_info: {
228
+ status: 'allowed_warning',
229
+ rateLimitType: 'seven_day',
230
+ utilization: 0.84,
231
+ resetsAt: 1_800_086_400,
232
+ },
233
+ uuid: '00000000-0000-4000-8000-000000000004',
234
+ session_id: 'claude-session-1',
235
+ },
236
+ result(),
237
+ ]);
238
+
239
+ await adapter.start();
240
+ await expect(adapter.getSubscriptionUsage()).resolves.toMatchObject({
241
+ provider: 'claude',
242
+ authKind: 'unknown',
243
+ windows: [],
244
+ });
245
+ await adapter.startSession({
246
+ cwd: '/tmp/workspace',
247
+ model: 'sonnet',
248
+ approvalMode: 'guarded',
249
+ sandboxMode: 'workspace-write',
250
+ });
251
+
252
+ await expect(adapter.getSubscriptionUsage()).resolves.toMatchObject({
253
+ provider: 'claude',
254
+ authKind: 'subscription',
255
+ stale: false,
256
+ windows: [
257
+ {
258
+ id: 'five_hour',
259
+ durationMinutes: 300,
260
+ label: '5h',
261
+ usedPercent: 23,
262
+ resetsAt: new Date(1_800_000_000 * 1000).toISOString(),
263
+ },
264
+ {
265
+ id: 'seven_day',
266
+ durationMinutes: 10_080,
267
+ label: '7d',
268
+ usedPercent: 84,
269
+ resetsAt: new Date(1_800_086_400 * 1000).toISOString(),
270
+ },
271
+ ],
272
+ });
273
+ });
274
+
211
275
  it('passes the configured Claude executable to the SDK', async () => {
212
276
  const sdkOptions: Record<string, unknown>[] = [];
213
277
  const adapter = new ClaudeRuntimeAdapter({
@@ -119,6 +119,19 @@ interface SDKMessage {
119
119
  modelUsage?: unknown;
120
120
  errors?: string[];
121
121
  stop_reason?: string;
122
+ rate_limit_info?: SDKRateLimitInfo;
123
+ }
124
+ interface SDKRateLimitInfo {
125
+ status: 'allowed' | 'allowed_warning' | 'rejected';
126
+ resetsAt?: number;
127
+ rateLimitType?:
128
+ | 'five_hour'
129
+ | 'seven_day'
130
+ | 'seven_day_opus'
131
+ | 'seven_day_sonnet'
132
+ | 'seven_day_overage_included'
133
+ | 'overage';
134
+ utilization?: number;
122
135
  }
123
136
  interface SDKUserMessage {
124
137
  type: 'user';
@@ -364,6 +377,7 @@ interface ClaudeTokenUsageBreakdown {
364
377
  totalTokens: number;
365
378
  inputTokens: number;
366
379
  cachedInputTokens: number;
380
+ cacheWriteInputTokens: number;
367
381
  outputTokens: number;
368
382
  reasoningOutputTokens: number;
369
383
  }
@@ -792,6 +806,8 @@ function addClaudeUsage(
792
806
  totalTokens: left.totalTokens + right.totalTokens,
793
807
  inputTokens: left.inputTokens + right.inputTokens,
794
808
  cachedInputTokens: left.cachedInputTokens + right.cachedInputTokens,
809
+ cacheWriteInputTokens:
810
+ left.cacheWriteInputTokens + right.cacheWriteInputTokens,
795
811
  outputTokens: left.outputTokens + right.outputTokens,
796
812
  reasoningOutputTokens: left.reasoningOutputTokens + right.reasoningOutputTokens,
797
813
  };
@@ -821,6 +837,7 @@ function normalizeClaudeUsage(value: unknown): ClaudeTokenUsageBreakdown | null
821
837
  totalTokens,
822
838
  inputTokens,
823
839
  cachedInputTokens: cacheReadInputTokens,
840
+ cacheWriteInputTokens: cacheCreationInputTokens,
824
841
  outputTokens,
825
842
  reasoningOutputTokens: 0,
826
843
  };
@@ -1218,6 +1235,11 @@ export class ClaudeRuntimeAdapter extends EventEmitter implements AgentRuntime {
1218
1235
  private readonly sessionModels = new Map<string, string | null>();
1219
1236
  private readonly sessionApprovalModes = new Map<string, StartAgentSessionInput['approvalMode']>();
1220
1237
  private readonly liveUserPrompts = new Map<string, Map<string, string>>();
1238
+ private readonly subscriptionUsageWindows = new Map<
1239
+ 'five_hour' | 'seven_day',
1240
+ { usedPercent: number; resetsAt: string | null }
1241
+ >();
1242
+ private subscriptionUsageObservedAt: string | null = null;
1221
1243
  private readonly clientApp: string;
1222
1244
  private sdkLoadError: string | null = null;
1223
1245
 
@@ -1238,6 +1260,46 @@ export class ClaudeRuntimeAdapter extends EventEmitter implements AgentRuntime {
1238
1260
  return { ...this.status };
1239
1261
  }
1240
1262
 
1263
+ async getSubscriptionUsage() {
1264
+ const observedAt = this.subscriptionUsageObservedAt ?? new Date().toISOString();
1265
+ const windows = [...this.subscriptionUsageWindows.entries()].map(([id, window]) => ({
1266
+ id,
1267
+ durationMinutes: id === 'five_hour' ? 300 : 10_080,
1268
+ label: id === 'five_hour' ? '5h' : '7d',
1269
+ usedPercent: window.usedPercent,
1270
+ resetsAt: window.resetsAt,
1271
+ }));
1272
+ return {
1273
+ provider: 'claude' as const,
1274
+ authKind: windows.length > 0 ? 'subscription' as const : 'unknown' as const,
1275
+ observedAt,
1276
+ stale: false,
1277
+ windows,
1278
+ };
1279
+ }
1280
+
1281
+ private captureRateLimit(message: SDKMessage) {
1282
+ if (message.type !== 'rate_limit_event') {
1283
+ return;
1284
+ }
1285
+ const info = message.rate_limit_info;
1286
+ if (
1287
+ !info
1288
+ || (info.rateLimitType !== 'five_hour' && info.rateLimitType !== 'seven_day')
1289
+ || typeof info.utilization !== 'number'
1290
+ || !Number.isFinite(info.utilization)
1291
+ ) {
1292
+ return;
1293
+ }
1294
+ this.subscriptionUsageWindows.set(info.rateLimitType, {
1295
+ usedPercent: Math.max(0, Math.min(100, info.utilization * 100)),
1296
+ resetsAt: typeof info.resetsAt === 'number'
1297
+ ? new Date(info.resetsAt * 1000).toISOString()
1298
+ : null,
1299
+ });
1300
+ this.subscriptionUsageObservedAt = new Date().toISOString();
1301
+ }
1302
+
1241
1303
  private updateToolboxItemsFromSystemInit(message: SDKMessage) {
1242
1304
  this.managementSchema.toolboxItems = buildClaudeToolboxItems(
1243
1305
  normalizeClaudeSlashCommands(message.slash_commands),
@@ -1404,6 +1466,7 @@ export class ClaudeRuntimeAdapter extends EventEmitter implements AgentRuntime {
1404
1466
  try {
1405
1467
  for await (const message of query) {
1406
1468
  rawMessages.push(message);
1469
+ this.captureRateLimit(message);
1407
1470
  if (message.type === 'system' && message.subtype === 'init') {
1408
1471
  this.updateToolboxItemsFromSystemInit(message);
1409
1472
  const sessionId = message.session_id;
@@ -1695,6 +1758,7 @@ export class ClaudeRuntimeAdapter extends EventEmitter implements AgentRuntime {
1695
1758
  try {
1696
1759
  for await (const message of state.query) {
1697
1760
  rawMessages.push(message);
1761
+ this.captureRateLimit(message);
1698
1762
  this.consumeMessage(state, message);
1699
1763
  const status = queryResultStatus(message);
1700
1764
  if (status) {
@@ -320,6 +320,22 @@ export class CodexAppServerManager extends EventEmitter {
320
320
  return response.data.map(mapModel);
321
321
  }
322
322
 
323
+ async readAccount() {
324
+ await this.ensureReady();
325
+ return this.client!.request<{
326
+ account: { type: string } | null;
327
+ requiresOpenaiAuth: boolean;
328
+ }>('account/read', { refreshToken: false });
329
+ }
330
+
331
+ async readAccountRateLimits() {
332
+ await this.ensureReady();
333
+ return this.client!.request<{
334
+ rateLimits: unknown;
335
+ rateLimitsByLimitId?: Record<string, unknown> | null;
336
+ }>('account/rateLimits/read', null);
337
+ }
338
+
323
339
  async listThreads(): Promise<CodexThreadRecord[]> {
324
340
  await this.ensureReady();
325
341
  const response = await this.client!.request<{ data: any[] }>('thread/list', {
@@ -52,6 +52,90 @@ describe('modelPricing', () => {
52
52
  expect(estimate?.totalUsd).toBeCloseTo(0.05025, 10);
53
53
  });
54
54
 
55
+ it('prices GPT-5.6 aliases and cache writes with the published rates', () => {
56
+ const usage = {
57
+ ...sampleUsage,
58
+ total: {
59
+ ...sampleUsage.total,
60
+ cacheWriteInputTokens: 200,
61
+ },
62
+ last: {
63
+ ...sampleUsage.last,
64
+ cacheWriteInputTokens: 200,
65
+ },
66
+ modelContextWindow: 353_400,
67
+ };
68
+
69
+ expect(contextWindowForModel('gpt-5.6')).toBe(353_400);
70
+ expect(contextWindowForModel('openai/gpt-5.6-sol')).toBe(353_400);
71
+ expect(supportsFastMode('gpt-5.6-terra')).toBe(true);
72
+ expect(buildTurnPricingSnapshot('gpt-5.6-luna', false)).toEqual({
73
+ pricingModelKey: 'gpt-5.6-luna',
74
+ pricingTierKey: 'standard',
75
+ });
76
+
77
+ const solEstimate = estimateTurnPrice(usage, {
78
+ pricingModelKey: 'gpt-5.6-sol',
79
+ pricingTierKey: 'standard',
80
+ });
81
+ expect(solEstimate).toMatchObject({
82
+ pricingModelKey: 'gpt-5.6-sol',
83
+ inputUsd: 0.004,
84
+ cachedInputUsd: 0.00025,
85
+ cacheWriteInputUsd: 0.00125,
86
+ outputUsd: 0.045,
87
+ });
88
+ expect(solEstimate?.totalUsd).toBeCloseTo(0.0505, 10);
89
+
90
+ const terraEstimate = estimateTurnPrice(usage, {
91
+ pricingModelKey: 'gpt-5.6-terra',
92
+ pricingTierKey: 'standard',
93
+ });
94
+ expect(terraEstimate?.cacheWriteInputUsd).toBeCloseTo(0.000625, 10);
95
+
96
+ const lunaEstimate = estimateTurnPrice(usage, {
97
+ pricingModelKey: 'gpt-5.6-luna',
98
+ pricingTierKey: 'standard',
99
+ });
100
+ expect(lunaEstimate?.cacheWriteInputUsd).toBeCloseTo(0.00025, 10);
101
+ });
102
+
103
+ it('applies GPT-5.6 long-context rates above 272K input tokens', () => {
104
+ const estimate = estimateTurnPrice(
105
+ {
106
+ total: {
107
+ totalTokens: 301_000,
108
+ inputTokens: 300_000,
109
+ cachedInputTokens: 0,
110
+ cacheWriteInputTokens: 0,
111
+ outputTokens: 1_000,
112
+ reasoningOutputTokens: 0,
113
+ },
114
+ last: {
115
+ totalTokens: 301_000,
116
+ inputTokens: 300_000,
117
+ cachedInputTokens: 0,
118
+ cacheWriteInputTokens: 0,
119
+ outputTokens: 1_000,
120
+ reasoningOutputTokens: 0,
121
+ },
122
+ modelContextWindow: 353_400,
123
+ },
124
+ {
125
+ pricingModelKey: 'gpt-5.6-sol',
126
+ pricingTierKey: 'standard',
127
+ },
128
+ );
129
+
130
+ expect(estimate).toMatchObject({
131
+ inputUsd: 3,
132
+ cachedInputUsd: 0,
133
+ cacheWriteInputUsd: 0,
134
+ outputUsd: 0.045,
135
+ });
136
+ expect(estimate?.totalUsd).toBeCloseTo(3.045, 10);
137
+ });
138
+
55
139
  it('uses the gpt-5.5-specific fast multiplier and marks it fast-capable', () => {
56
140
  expect(supportsFastMode('gpt-5.5')).toBe(true);
57
141
  expect(contextWindowForModel('gpt-5.5')).toBe(272000);
@@ -27,6 +27,22 @@ class FakeCodexManager extends EventEmitter {
27
27
  items: [],
28
28
  };
29
29
  }
30
+
31
+ async readAccount() {
32
+ return { account: { type: 'chatgpt' }, requiresOpenaiAuth: true };
33
+ }
34
+
35
+ async readAccountRateLimits() {
36
+ return {
37
+ rateLimits: {},
38
+ rateLimitsByLimitId: {
39
+ codex: {
40
+ primary: { usedPercent: 40, windowDurationMins: 300, resetsAt: 1_800_000_000 },
41
+ secondary: { usedPercent: 75, windowDurationMins: 10_080, resetsAt: 1_800_604_800 },
42
+ },
43
+ },
44
+ };
45
+ }
30
46
  }
31
47
 
32
48
  describe('CodexRuntimeAdapter', () => {
@@ -73,4 +89,32 @@ describe('CodexRuntimeAdapter', () => {
73
89
  { type: 'text', text: '.', text_elements: [] },
74
90
  ]);
75
91
  });
92
+
93
+ it('maps ChatGPT rate-limit windows without assuming fixed durations', async () => {
94
+ const adapter = new CodexRuntimeAdapter(new FakeCodexManager() as never);
95
+
96
+ await expect(adapter.getSubscriptionUsage()).resolves.toMatchObject({
97
+ provider: 'codex',
98
+ authKind: 'subscription',
99
+ stale: false,
100
+ windows: [
101
+ { id: 'primary', label: '5h', durationMinutes: 300, usedPercent: 40 },
102
+ { id: 'secondary', label: '7d', durationMinutes: 10_080, usedPercent: 75 },
103
+ ],
104
+ });
105
+ });
106
+
107
+ it('hides subscription windows for API-key authentication', async () => {
108
+ const manager = new FakeCodexManager();
109
+ manager.readAccount = async () => ({
110
+ account: { type: 'apiKey' },
111
+ requiresOpenaiAuth: true,
112
+ });
113
+ const adapter = new CodexRuntimeAdapter(manager as never);
114
+
115
+ await expect(adapter.getSubscriptionUsage()).resolves.toMatchObject({
116
+ authKind: 'apiKey',
117
+ windows: [],
118
+ });
119
+ });
76
120
  });