@bitkyc08/opencodex 2.46.0 → 2.47.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 (65) hide show
  1. package/README.md +22 -0
  2. package/SPONSORS.md +105 -0
  3. package/gui/dist/assets/index-B8ZZ0ivI.css +1 -0
  4. package/gui/dist/assets/{index-B72RPblC.js → index-BoXZzNaV.js} +21 -21
  5. package/gui/dist/index.html +2 -2
  6. package/package.json +2 -1
  7. package/src/adapters/cursor/protobuf-request.ts +34 -21
  8. package/src/adapters/cursor/tool-guidance.ts +2 -2
  9. package/src/adapters/cursor/tool-result-normalize.ts +22 -2
  10. package/src/adapters/exec-tool-result-normalize.ts +87 -0
  11. package/src/adapters/kiro.ts +29 -18
  12. package/src/adapters/responses-code-mode.ts +14 -4
  13. package/src/adapters/tool-catalog-nudge.ts +2 -2
  14. package/src/bridge.ts +40 -24
  15. package/src/claude/inbound.ts +21 -6
  16. package/src/claude/outbound.ts +43 -26
  17. package/src/cli/capabilities.ts +27 -0
  18. package/src/cli/integrations.ts +1 -1
  19. package/src/cli/models-runtime-subcommands.ts +2 -0
  20. package/src/cli/models-runtime.ts +108 -1
  21. package/src/cli/observe.ts +18 -3
  22. package/src/cli/usage-report.ts +6 -1
  23. package/src/clients/config-export.ts +5 -3
  24. package/src/codex/auth-api.ts +36 -0
  25. package/src/codex/catalog/provider-fetch.ts +2 -1
  26. package/src/codex/quota-auto-refresh-state.ts +8 -0
  27. package/src/codex/quota-auto-refresh.ts +157 -27
  28. package/src/codex/warmup.ts +5 -0
  29. package/src/config/provider-validation.ts +70 -1
  30. package/src/config.ts +73 -1
  31. package/src/generated/compatibility-version.json +67 -55
  32. package/src/lib/json-byte-size.ts +61 -0
  33. package/src/lib/provider-outbound.ts +11 -10
  34. package/src/oauth/index.ts +42 -8
  35. package/src/oauth/orcarouter.ts +200 -0
  36. package/src/providers/key-store.ts +22 -0
  37. package/src/providers/registry.ts +78 -29
  38. package/src/responses/citation-markers.ts +56 -24
  39. package/src/responses/reasoning-envelope.ts +53 -19
  40. package/src/server/auth-cors.ts +8 -0
  41. package/src/server/chat-completions.ts +7 -0
  42. package/src/server/chat-native.ts +65 -3
  43. package/src/server/claude-messages.ts +30 -10
  44. package/src/server/effort-policy.ts +183 -1
  45. package/src/server/management/agent-settings-routes.ts +50 -9
  46. package/src/server/management/config-routes.ts +2 -0
  47. package/src/server/management/logs-usage-routes.ts +11 -3
  48. package/src/server/management/model-routes.ts +65 -1
  49. package/src/server/management/model-rows.ts +5 -0
  50. package/src/server/management/provider-routes.ts +99 -6
  51. package/src/server/management/route-registry.ts +2 -0
  52. package/src/server/management/usage-aggregate-cache.ts +14 -7
  53. package/src/server/responses/compact.ts +3 -2
  54. package/src/server/responses/core.ts +16 -2
  55. package/src/server/startup-health-cache.ts +31 -9
  56. package/src/types/config.ts +5 -0
  57. package/src/types/provider.ts +4 -0
  58. package/src/usage/cost.ts +27 -31
  59. package/src/usage/summary.ts +52 -9
  60. package/src/usage/time-range.ts +48 -0
  61. package/src/usage/user-cost-overlays.ts +35 -3
  62. package/src/vision/anthropic-describe.ts +46 -2
  63. package/src/web-search/anthropic-executor.ts +49 -3
  64. package/src/web-search/parse.ts +1 -1
  65. package/gui/dist/assets/index-BFgUC17B.css +0 -1
@@ -544,6 +544,8 @@ export interface OcxConfig {
544
544
  * set, the lower one wins for sub-agents. See src/server/effort-policy.ts.
545
545
  */
546
546
  subagentEffortCap?: string;
547
+ /** Global model effort overrides, after provider model/wide pins; none means omission. */
548
+ modelPinnedEfforts?: Record<string, string>;
547
549
  /**
548
550
  * Models hidden from Codex discovery without blocking direct proxy calls. Routed provider ids
549
551
  * are excluded from the catalog + /v1/models entirely. Account-qualified native ids hide only
@@ -723,6 +725,9 @@ export interface OcxConfig {
723
725
  /** Upstream reset timestamps already activated, retained across restarts. */
724
726
  lastFiveHourResetAt?: number;
725
727
  lastWeeklyResetAt?: number;
728
+ /** Observed boundaries retained until activation, even if an idle upstream clock moves. */
729
+ nextFiveHourResetAt?: number;
730
+ nextWeeklyResetAt?: number;
726
731
  }>;
727
732
  /**
728
733
  * Selection order per account id, higher used earlier; absent = 0. Keyed by id
@@ -493,6 +493,10 @@ export interface OcxProviderConfig {
493
493
  modelReasoningEfforts?: Record<string, string[]>;
494
494
  /** Model-specific default Codex reasoning tier; must also be present in the visible tier list. */
495
495
  modelDefaultReasoningEfforts?: Record<string, string>;
496
+ /** Operator-owned effort override; none omits effort and uses the provider default. */
497
+ pinnedReasoningEffort?: string;
498
+ /** Per-model operator override, ahead of provider-wide and global pins; caps still apply. */
499
+ modelPinnedReasoningEfforts?: Record<string, string>;
496
500
  /**
497
501
  * Model-specific Codex reasoning-summary capability. Set false when an OpenAI-compatible
498
502
  * Responses backend rejects Codex summary-delivery fields for that model.
package/src/usage/cost.ts CHANGED
@@ -16,10 +16,10 @@ import {
16
16
  } from "../generated/model-metadata";
17
17
  import type { AttemptTierOutcome, OcxUsage } from "../types";
18
18
  import { canonicalFastTierMarker } from "../providers/fastwire";
19
- import { baseProviderLabel, canonicalUsageProviderLabel } from "../providers/label";
19
+ import { baseProviderLabel } from "../providers/label";
20
20
  import type { PersistedUsageAttempt, UsageStatus } from "./log";
21
21
  import { canonicalAntigravityUsageModel } from "../providers/antigravity-models";
22
- import { activeConfiguredProviders, activeUserCostOverlays, userCostOverlayVersion } from "./user-cost-overlays";
22
+ import { activeAccountPricingProviders, activeConfiguredProviders, activeUserCostOverlays, userCostOverlayVersion } from "./user-cost-overlays";
23
23
  import {
24
24
  EXPECTED_PRICE_OVERLAYS,
25
25
  findExpectedPriceOverlay,
@@ -177,8 +177,8 @@ export function calculateCost(tokens: CostTokens, cost4: Cost4): CostBreakdown {
177
177
  * bundle) nonzero -> overlay verified -> overlay verified-derived -> jawcode
178
178
  * model-level vendor price (cross-provider fallback: a model follows its official
179
179
  * vendor price — WP5 policy, e.g. kiro/claude-opus-4-6 uses the anthropic price)
180
- * -> null. All-zero rows are overlay candidates (zero is "not billable here",
181
- * not "free").
180
+ * -> null. An explicit all-zero user override means free; all-zero catalog
181
+ * rows remain overlay candidates rather than evidence of free pricing.
182
182
  */
183
183
  export function resolveMatchedPrice(
184
184
  provider: string,
@@ -187,21 +187,18 @@ export function resolveMatchedPrice(
187
187
  userOverlays: readonly ExpectedPriceOverlay[] = activeUserCostOverlays(),
188
188
  options: PriceResolutionOptions = {},
189
189
  ): MatchedPrice | null {
190
- // User-configured overlays are keyed by the EXACT configured provider name.
191
- // A provider that literally exists in config.providers keeps its own pricing
192
- // namespace: a real custom provider can legitimately end with a label-shaped
193
- // suffix (e.g. acme-pabcdef) and must not inherit the base provider's user
194
- // overlay. Only NON-configured names (generated account log labels) collapse
195
- // to their label base. chatgpt/openai-multi are the same OpenAI usage surface
196
- // and always canonicalize to openai.
197
- const collapsed = baseProviderLabel(provider);
198
- if (collapsed !== provider && (canonicalUsageProviderLabel(provider) !== provider || !activeConfiguredProviders().has(provider))) {
190
+ // Literal configured providers win over account identities. Only then use
191
+ // config-owned Codex identities, followed by the existing historical suffix
192
+ // grammar. Never infer an account by stripping an arbitrary suffix.
193
+ const namespace = activeConfiguredProviders().has(provider)
194
+ ? provider
195
+ : activeAccountPricingProviders().get(provider) ?? baseProviderLabel(provider);
196
+ if (namespace !== provider) {
197
+ // An exact override (including caller-supplied rows) owns its namespace.
198
+ // Unchanged names use the memoized inner lookup's existing user-first order.
199
199
  const exactUserOverlay = userOverlayMatch(provider, modelId, userOverlays);
200
200
  if (exactUserOverlay) return exactUserOverlay;
201
- // Pool/account log suffixes (e.g. google-antigravity-p442fff) must collapse
202
- // before the compiled/overlay lookup; configured providers keep their own
203
- // namespace above.
204
- provider = collapsed;
201
+ provider = namespace;
205
202
  }
206
203
  // Memoize by (provider, model): usage summaries iterate hundreds of thousands of
207
204
  // rows that share a handful of provider/model keys, so resolving each time would
@@ -247,7 +244,7 @@ function resolveMatchedPriceInner(
247
244
  /**
248
245
  * Exact provider/model price lookup: user-configured `modelCosts` first, then
249
246
  * an exact official correction, the jawcode provider bundle, the expected-price overlay, then the
250
- * model-level vendor fallback. All-zero rows fall through ("not billable").
247
+ * model-level vendor fallback. All-zero catalog rows fall through; user zeros win.
251
248
  */
252
249
  function resolveMatchedPriceExact(
253
250
  provider: string,
@@ -305,14 +302,14 @@ function resolveMatchedPriceExact(
305
302
  };
306
303
  }
307
304
 
308
- /** User-configured overlay match (all-zero rows fall through like any other source). */
305
+ /** User-configured overlay match; explicit zero rates are authoritative too. */
309
306
  function userOverlayMatch(
310
307
  provider: string,
311
308
  modelId: string,
312
309
  userOverlays: readonly ExpectedPriceOverlay[],
313
310
  ): MatchedPrice | null {
314
311
  const overlay = findExpectedPriceOverlay(provider, modelId, userOverlays);
315
- if (!overlay || !validCost4(overlay.cost4) || !hasNonZeroCost(overlay.cost4)) return null;
312
+ if (!overlay || !validCost4(overlay.cost4)) return null;
316
313
  return {
317
314
  provider,
318
315
  modelId,
@@ -466,7 +463,7 @@ function applyContextTier(
466
463
  tier?: ServiceTierInput,
467
464
  ): [Cost4, ContextTierName | undefined, boolean] {
468
465
  if (rawInputTokens === undefined) return [cost4, undefined, false];
469
- const rule = findContextTier(baseProviderLabel(provider), modelId);
466
+ const rule = findContextTier(provider, modelId);
470
467
  if (!rule || !isLongContext(rule, rawInputTokens)) return [cost4, undefined, false];
471
468
  const confirmedFast = isConfirmedFast(tier);
472
469
  if (confirmedFast && rule.confirmedPriorityRelation === "exclusive") {
@@ -494,9 +491,8 @@ function applyPriorityMultiplier(
494
491
  contextTier?: ContextTierName,
495
492
  ): [Cost4, number] {
496
493
  if (canonicalFastTierMarker(tierScalar(serviceTier)) !== "priority") return [cost4, 1];
497
- const base = baseProviderLabel(provider);
498
- if (contextTier && findContextTier(base, modelId)?.confirmedPriorityRelation !== "stack") return [cost4, 1];
499
- const rule = findPriorityPricingRule(base, modelId);
494
+ if (contextTier && findContextTier(provider, modelId)?.confirmedPriorityRelation !== "stack") return [cost4, 1];
495
+ const rule = findPriorityPricingRule(provider, modelId);
500
496
  if (rule?.requiresResponseConfirmation && !isConfirmedFast(serviceTier)) return [cost4, 1];
501
497
  const multiplier = rule?.multiplier ?? 1;
502
498
  if (multiplier === 1) return [cost4, 1];
@@ -524,7 +520,7 @@ function isOpenRouterPriorityLowerBound(
524
520
  provider: string,
525
521
  outcome: AttemptTierOutcome | undefined,
526
522
  ): boolean {
527
- return baseProviderLabel(provider) === "openrouter"
523
+ return provider === "openrouter"
528
524
  && outcome?.canonical === "priority"
529
525
  && outcome.fastOutcome === "applied"
530
526
  && (outcome.confirmation === "confirmed" || outcome.confirmation === "assumed");
@@ -550,13 +546,13 @@ export function estimateAttemptCost(
550
546
  ? serviceTierContextFromOutcome(attempt.tierOutcome)
551
547
  : serviceTier;
552
548
  const [tieredCost4, contextTier, contextPriorityLowerBound] = applyContextTier(
553
- price.cost4, attempt.provider, attempt.model, attempt.usage.inputTokens, attemptServiceTier,
549
+ price.cost4, price.provider, attempt.model, attempt.usage.inputTokens, attemptServiceTier,
554
550
  );
555
551
  const [effectiveCost4, multiplier] = applyPriorityMultiplier(
556
- tieredCost4, attempt.provider, attempt.model, attemptServiceTier, contextTier,
552
+ tieredCost4, price.provider, attempt.model, attemptServiceTier, contextTier,
557
553
  );
558
554
  const priorityLowerBound = contextPriorityLowerBound
559
- || isOpenRouterPriorityLowerBound(attempt.provider, attempt.tierOutcome);
555
+ || isOpenRouterPriorityLowerBound(price.provider, attempt.tierOutcome);
560
556
  return {
561
557
  ordinal: attempt.ordinal,
562
558
  provider: attempt.provider,
@@ -635,13 +631,13 @@ export function estimateRequestCost(
635
631
  const price = resolveMatchedPrice(input.provider, input.model, overlays, userOverlays, input);
636
632
  if (!price) return null;
637
633
  const [tieredCost4, contextTier, contextPriorityLowerBound] = applyContextTier(
638
- price.cost4, input.provider, input.model, input.usage.inputTokens, input.serviceTier,
634
+ price.cost4, price.provider, input.model, input.usage.inputTokens, input.serviceTier,
639
635
  );
640
636
  const [effectiveCost4, multiplier] = applyPriorityMultiplier(
641
- tieredCost4, input.provider, input.model, input.serviceTier, contextTier,
637
+ tieredCost4, price.provider, input.model, input.serviceTier, contextTier,
642
638
  );
643
639
  const priorityLowerBound = contextPriorityLowerBound || isOpenRouterPriorityLowerBound(
644
- input.provider,
640
+ price.provider,
645
641
  typeof input.serviceTier === "object" ? input.serviceTier.tierOutcome : undefined,
646
642
  );
647
643
  return {
@@ -1,6 +1,7 @@
1
1
  import { baseProviderLabel } from "../providers/label";
2
2
  import { canonicalAntigravityUsageModel } from "../providers/antigravity-models";
3
3
  import { usageDisplayTotalTokens } from "./totals";
4
+ import type { UsageTimeWindow } from "./time-range";
4
5
  import { isUnresolvedRequestedModel, usageModelPriceOptions } from "./model-identity";
5
6
  import { isCodexUsageAccountLogLabel, type PersistedUsageEntry, type UsageStatus } from "./log";
6
7
  import { type AttemptCostEstimate, type CostEstimate, estimateAttemptCost, estimateRequestCost, serviceTierContext, type ServiceTierContext } from "./cost";
@@ -145,6 +146,8 @@ export interface UsageSummary {
145
146
  range: UsageRange;
146
147
  surface: UsageSurface;
147
148
  since: number | null;
149
+ customWindow?: true;
150
+ until?: number;
148
151
  generatedAt: number;
149
152
  summary: UsageSummaryTotals;
150
153
  days: UsageDay[];
@@ -297,6 +300,25 @@ function dayCountForAllRange(oldest: number | null, now: number): number {
297
300
  return Math.min(MAX_USAGE_DAY_BUCKETS, Math.max(1, days));
298
301
  }
299
302
 
303
+ function customWindowDates(window: UsageTimeWindow): string[] {
304
+ const start = startOfLocalDay(window.since);
305
+ const date = new Date(startOfLocalDay(window.until));
306
+ const dates: string[] = [];
307
+ while (date.getTime() >= start && dates.length < MAX_USAGE_DAY_BUCKETS) {
308
+ dates.push(localDateKey(date.getTime()));
309
+ const previous = date.getTime();
310
+ date.setDate(date.getDate() - 1);
311
+ date.setHours(0, 0, 0, 0);
312
+ // A skipped civil day can normalize back to this same midnight (Apia, 2011).
313
+ // Move through the preceding instant to find the prior existing local day.
314
+ if (date.getTime() >= previous) {
315
+ date.setTime(previous - 1);
316
+ date.setHours(0, 0, 0, 0);
317
+ }
318
+ }
319
+ return dates.reverse();
320
+ }
321
+
300
322
  function blankTotals(): UsageSummaryTotals {
301
323
  return {
302
324
  requests: 0,
@@ -1015,6 +1037,7 @@ class StreamingUsageSummaryAccumulator implements UsageSummaryAccumulator {
1015
1037
  private readonly requestIds: Map<string, number> | null;
1016
1038
  private readonly filter: NormalizedUsageFilter | null;
1017
1039
  private readonly mode: UsageAccumulatorMode;
1040
+ private readonly window: UsageTimeWindow | undefined;
1018
1041
  private nextRequestId = 0;
1019
1042
  private nextOrdinal = 0;
1020
1043
  private snapshotStart: number | null = null;
@@ -1025,6 +1048,7 @@ class StreamingUsageSummaryAccumulator implements UsageSummaryAccumulator {
1025
1048
  constructor(options?: {
1026
1049
  filter?: { provider?: string | null; model?: string | null; apiKeyId?: string | null };
1027
1050
  mode?: UsageAccumulatorMode;
1051
+ window?: UsageTimeWindow;
1028
1052
  }) {
1029
1053
  const provider = normalizeFilterValue(options?.filter?.provider);
1030
1054
  const model = normalizeFilterValue(options?.filter?.model);
@@ -1033,6 +1057,7 @@ class StreamingUsageSummaryAccumulator implements UsageSummaryAccumulator {
1033
1057
  ? null
1034
1058
  : { provider, model, apiKeyId };
1035
1059
  this.mode = options?.mode ?? "exact";
1060
+ this.window = options?.window ? Object.freeze({ ...options.window }) : undefined;
1036
1061
  this.requestIds = this.mode === "exact" ? new Map() : null;
1037
1062
  }
1038
1063
 
@@ -1048,6 +1073,7 @@ class StreamingUsageSummaryAccumulator implements UsageSummaryAccumulator {
1048
1073
  const cloned = new StreamingUsageSummaryAccumulator({
1049
1074
  ...(this.filter ? { filter: this.filter } : {}),
1050
1075
  mode: this.mode,
1076
+ window: this.window,
1051
1077
  });
1052
1078
  cloned.nextRequestId = this.nextRequestId;
1053
1079
  cloned.nextOrdinal = this.nextOrdinal;
@@ -1276,6 +1302,8 @@ class StreamingUsageSummaryAccumulator implements UsageSummaryAccumulator {
1276
1302
  ? sourceEntry.timestamp
1277
1303
  : Math.max(this.snapshotEnd, sourceEntry.timestamp);
1278
1304
  }
1305
+ if (this.window && (!Number.isFinite(sourceEntry.timestamp)
1306
+ || sourceEntry.timestamp < this.window.since || sourceEntry.timestamp > this.window.until)) return;
1279
1307
  const projected = this.filter ? projectedEntryForFilter(sourceEntry, this.filter) : { entry: sourceEntry, comboOverlap: false };
1280
1308
  if (!projected) return;
1281
1309
  this.comboOverlap ||= projected.comboOverlap;
@@ -1340,7 +1368,9 @@ class StreamingUsageSummaryAccumulator implements UsageSummaryAccumulator {
1340
1368
  now: number,
1341
1369
  surface: UsageSurface = "all",
1342
1370
  ): UsageSummary & { filter?: UsageFilterEcho } {
1343
- const { since, days: fixedDays } = rangeWindow(range, now);
1371
+ const preset = rangeWindow(range, now);
1372
+ const since = this.window?.since ?? preset.since;
1373
+ const fixedDays = preset.days;
1344
1374
  const totals = blankTotals();
1345
1375
  const models = new Map<string, UsageModelAccumulator>();
1346
1376
  const providers = new Map<string, UsageModelAccumulator>();
@@ -1351,7 +1381,7 @@ class StreamingUsageSummaryAccumulator implements UsageSummaryAccumulator {
1351
1381
 
1352
1382
  for (const partition of this.partitions.values()) {
1353
1383
  if (!usageSurfaceMatches(partition.surface, surface)) continue;
1354
- if (since !== null && partition.dayStart < since) continue;
1384
+ if (!this.window && since !== null && partition.dayStart < since) continue;
1355
1385
  mergeTotals(totals, partition.totals);
1356
1386
  mergeModelMaps(models, partition.models);
1357
1387
  if (partition.providers) mergeModelMaps(providers, partition.providers);
@@ -1377,27 +1407,34 @@ class StreamingUsageSummaryAccumulator implements UsageSummaryAccumulator {
1377
1407
  }
1378
1408
  finalizeCoverage(totals);
1379
1409
 
1380
- const dayCount = range === "all" ? dayCountForAllRange(oldestTimestamp, now) : fixedDays;
1381
- const startOfToday = startOfLocalDay(now);
1410
+ const customDates = this.window ? new Set(customWindowDates(this.window)) : null;
1411
+ const dayCount = customDates?.size ?? (range === "all" ? dayCountForAllRange(oldestTimestamp, now) : fixedDays);
1412
+ const startOfToday = startOfLocalDay(this.window?.until ?? now);
1382
1413
  const firstVisibleDay = new Date(startOfToday);
1383
1414
  firstVisibleDay.setDate(firstVisibleDay.getDate() - dayCount + 1);
1384
1415
  const firstVisibleDate = localDateKey(firstVisibleDay.getTime());
1385
1416
  const lastVisibleDate = localDateKey(startOfToday);
1386
- for (let offset = dayCount - 1; offset >= 0; offset--) {
1417
+ const visibleDates = customDates ?? new Set<string>();
1418
+ for (let offset = dayCount - 1; !customDates && offset >= 0; offset--) {
1387
1419
  const date = new Date(startOfToday);
1388
1420
  date.setDate(date.getDate() - offset);
1389
- const key = localDateKey(date.getTime());
1421
+ visibleDates.add(localDateKey(date.getTime()));
1422
+ }
1423
+ for (const key of visibleDates) {
1390
1424
  if (!dayAccumulators.has(key)) {
1391
1425
  dayAccumulators.set(key, { totals: blankTotals(), models: new Map(), modelOverlaps: [] });
1392
1426
  }
1393
1427
  }
1394
- const days = [...dayAccumulators]
1428
+ const visibleDays = customDates
1429
+ ? [...customDates].map(date => [date, dayAccumulators.get(date)!] as const)
1430
+ : [...dayAccumulators]
1395
1431
  // All-history totals, models, providers, and accounts still cover every
1396
1432
  // retained row. Only the chart buckets are bounded so one malformed or
1397
1433
  // ancient timestamp cannot synthesize an enormous JSON response.
1398
1434
  .filter(([date]) => range !== "all"
1399
1435
  || (date >= firstVisibleDate && date <= lastVisibleDate))
1400
- .sort(([a], [b]) => a.localeCompare(b))
1436
+ .sort(([a], [b]) => a.localeCompare(b));
1437
+ const days = visibleDays
1401
1438
  .map(([date, day]): UsageDay => ({
1402
1439
  date,
1403
1440
  requests: day.totals.requests,
@@ -1412,6 +1449,7 @@ class StreamingUsageSummaryAccumulator implements UsageSummaryAccumulator {
1412
1449
  range,
1413
1450
  surface,
1414
1451
  since,
1452
+ ...(this.window ? { customWindow: true as const, until: this.window.until } : {}),
1415
1453
  generatedAt: now,
1416
1454
  summary: totals,
1417
1455
  days,
@@ -1449,6 +1487,7 @@ class StreamingUsageSummaryAccumulator implements UsageSummaryAccumulator {
1449
1487
  export function createUsageSummaryAccumulator(options?: {
1450
1488
  filter?: { provider?: string | null; model?: string | null; apiKeyId?: string | null };
1451
1489
  mode?: UsageAccumulatorMode;
1490
+ window?: UsageTimeWindow;
1452
1491
  }): UsageSummaryAccumulator {
1453
1492
  return new StreamingUsageSummaryAccumulator(options);
1454
1493
  }
@@ -1501,7 +1540,11 @@ export function projectUsageSummary<T extends UsageSummary>(
1501
1540
  const model = normalizeFilterValue(filter.model);
1502
1541
  const apiKeyId = normalizeExactFilterValue(filter.apiKeyId);
1503
1542
  if (provider === null && model === null && apiKeyId === null) return summary;
1504
- const accumulator = createUsageSummaryAccumulator({ filter: { provider, model, apiKeyId } });
1543
+ const accumulator = createUsageSummaryAccumulator({
1544
+ filter: { provider, model, apiKeyId },
1545
+ ...(summary.customWindow && summary.since !== null && summary.until !== undefined
1546
+ ? { window: { since: summary.since, until: summary.until } } : {}),
1547
+ });
1505
1548
  for (const entry of entries ?? []) accumulator.add(entry);
1506
1549
  const projected = accumulator.summarize(summary.range, summary.generatedAt, summary.surface);
1507
1550
  return {
@@ -0,0 +1,48 @@
1
+ /** Inclusive epoch-millisecond bounds, independent of the selected preset. */
2
+ export interface UsageTimeWindow {
3
+ readonly since: number;
4
+ readonly until: number;
5
+ }
6
+
7
+ const MAX_DATE_MS = 8_640_000_000_000_000;
8
+ const ISO_DATETIME = /^(\d{4}|\+\d{6})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,3})?(Z|([+-])(\d{2}):(\d{2}))$/;
9
+
10
+ function parseTimestamp(input: string | number, name: "since" | "until"): number {
11
+ const invalid = (): never => {
12
+ throw new Error(`${name} must be nonnegative integer epoch milliseconds or a valid full ISO datetime with timezone`);
13
+ };
14
+ let timestamp: number;
15
+ if (typeof input === "number") timestamp = input;
16
+ else if (/^\d+$/.test(input)) timestamp = Number(input);
17
+ else {
18
+ const parts = ISO_DATETIME.exec(input);
19
+ if (!parts) return invalid();
20
+ const year = Number(parts[1]);
21
+ const month = Number(parts[2]);
22
+ const day = Number(parts[3]);
23
+ const leapYear = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
24
+ const monthDays = [31, leapYear ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
25
+ // Date.parse normalizes some impossible dates (e.g. February 30).
26
+ // Validate the written calendar fields before applying its timezone offset.
27
+ if (month < 1 || month > 12 || day < 1 || day > monthDays[month - 1]!
28
+ || Number(parts[4]) > 23 || Number(parts[5]) > 59 || Number(parts[6]) > 59
29
+ || (parts[7] !== "Z" && (Number(parts[9]) > 23 || Number(parts[10]) > 59))) {
30
+ return invalid();
31
+ }
32
+ timestamp = Date.parse(input);
33
+ }
34
+ if (!Number.isSafeInteger(timestamp) || timestamp < 0 || timestamp > MAX_DATE_MS) return invalid();
35
+ return timestamp;
36
+ }
37
+
38
+ /** No bounds selects the preset; supplying either bound requires both. */
39
+ export function parseUsageTimeWindow(
40
+ since: string | number | null | undefined,
41
+ until: string | number | null | undefined,
42
+ ): UsageTimeWindow | undefined {
43
+ if (since == null && until == null) return undefined;
44
+ if (since == null || until == null) throw new Error("since and until must be supplied together");
45
+ const window = { since: parseTimestamp(since, "since"), until: parseTimestamp(until, "until") };
46
+ if (window.since > window.until) throw new Error("since must be less than or equal to until");
47
+ return Object.freeze(window);
48
+ }
@@ -13,19 +13,23 @@
13
13
  * must not churn the version (see refreshUserCostOverlays). The configured
14
14
  * provider-name set is part of the change identity: adding or removing a
15
15
  * provider changes which names may collapse to a label base in the resolver,
16
- * so it bumps the version even when no overlay row changed.
16
+ * so it bumps the version even when no overlay row changed. Exact selectable
17
+ * Codex IDs and effective log labels also participate in that identity.
17
18
  *
18
19
  * Display-time estimation only — these rows never affect billing.
19
20
  */
20
21
  import type { OcxConfig, OcxProviderConfig, ProviderCostOverlay } from "../types";
21
22
  import { MAX_COST4_RATE, type ExpectedPriceOverlay } from "./expected-prices";
22
23
  import { redactSecretString } from "../lib/redact";
24
+ import { isSelectableCodexPoolAccount, MAIN_CODEX_ACCOUNT_ID } from "../codex/account-id";
25
+ import { codexAccountLogLabel } from "../codex/account-label";
23
26
 
24
27
  const EMPTY: readonly ExpectedPriceOverlay[] = [];
25
28
 
26
29
  let active: readonly ExpectedPriceOverlay[] = EMPTY;
27
30
  let activeSignature = "";
28
31
  let activeConfigured = new Set<string>();
32
+ let activeAccountProviders = codexAccountProviders([]);
29
33
  let version = 0;
30
34
  let preservedDiskOnlyProviders: Record<string, OcxProviderConfig> | null = null;
31
35
 
@@ -54,6 +58,24 @@ function providerNames(config: OcxConfig): Set<string> {
54
58
  return new Set(Object.keys(config.providers ?? {}));
55
59
  }
56
60
 
61
+ /** Exact config-owned identities only; aliases and generic OAuth stores are not authority. */
62
+ function codexAccountProviders(accounts: OcxConfig["codexAccounts"]): Map<string, string> {
63
+ const identities = new Set(["main", MAIN_CODEX_ACCOUNT_ID]);
64
+ for (const account of accounts ?? []) {
65
+ if (!isSelectableCodexPoolAccount(account)) continue;
66
+ identities.add(account.id);
67
+ identities.add(codexAccountLogLabel(account));
68
+ }
69
+ const mapping = new Map<string, string>();
70
+ for (const identity of identities) {
71
+ mapping.set(identity, "openai");
72
+ for (const provider of ["openai", "chatgpt", "openai-multi"]) {
73
+ mapping.set(`${provider}-${identity}`, "openai");
74
+ }
75
+ }
76
+ return mapping;
77
+ }
78
+
57
79
  /** Register one active live-config owner. Multiple server leases may share one config object. */
58
80
  export function registerPreservedProviderOwner(config: OcxConfig): void {
59
81
  const tagged = config as PreservationTaggedConfig;
@@ -289,12 +311,17 @@ export function refreshUserCostOverlays(config: OcxConfig): void {
289
311
  // removing a provider (even one without an overlay) changes which names are
290
312
  // allowed to collapse to a label base, so the resolver memo and the
291
313
  // /api/usage summary cache must be invalidated on that change as well.
314
+ // Sort effective account identities so account order, aliases and plan
315
+ // metadata do not churn caches; add/remove/label changes still invalidate.
292
316
  const configuredNames = Object.keys(providers ?? {}).sort();
293
- const signature = `${JSON.stringify(configuredNames)}\u0000${JSON.stringify(rows)}`;
317
+ const accountProviders = codexAccountProviders(config.codexAccounts);
318
+ const accountEntries = [...accountProviders].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
319
+ const signature = `${JSON.stringify(configuredNames)}\u0000${JSON.stringify(rows)}\u0000${JSON.stringify(accountEntries)}`;
294
320
  if (signature === activeSignature) return;
295
321
  activeSignature = signature;
296
322
  active = rows;
297
323
  activeConfigured = new Set(configuredNames);
324
+ activeAccountProviders = accountProviders;
298
325
  version++;
299
326
  }
300
327
 
@@ -303,7 +330,7 @@ export function activeUserCostOverlays(): readonly ExpectedPriceOverlay[] {
303
330
  return active;
304
331
  }
305
332
 
306
- /** Monotonic version bumped on every refresh; used by the estimator memo key. */
333
+ /** Monotonic version bumped on pricing-identity changes; used by the estimator memo key. */
307
334
  export function userCostOverlayVersion(): number {
308
335
  return version;
309
336
  }
@@ -312,3 +339,8 @@ export function userCostOverlayVersion(): number {
312
339
  export function activeConfiguredProviders(): ReadonlySet<string> {
313
340
  return activeConfigured;
314
341
  }
342
+
343
+ /** Account pricing identities built at refresh, without reading credential stores. */
344
+ export function activeAccountPricingProviders(): ReadonlyMap<string, string> {
345
+ return activeAccountProviders;
346
+ }
@@ -10,6 +10,8 @@ import type { DescribeOutcome, VisionSettings } from "./describe";
10
10
  const ANTHROPIC_VISION_MAX_TOKENS = 1024;
11
11
  const ALLOWED_IMAGE_MIME = new Set(["image/png", "image/jpeg", "image/jpg", "image/webp", "image/gif"]);
12
12
  const MAX_IMAGE_BYTES = 20 * 1024 * 1024;
13
+ /** Bound the sidecar SSE stream and its untrusted error body; the description is clamped downstream. */
14
+ const MAX_SIDECAR_RESPONSE_BYTES = 64 * 1024;
13
15
  const DESCRIBE_INSTRUCTION =
14
16
  "You are a vision describer for a text-only model that cannot see the image. Describe the image " +
15
17
  "thoroughly and factually so that model can fully reason about it: transcribe any visible text " +
@@ -43,6 +45,34 @@ function buildImageBlock(imageUrl: string): { block?: AnthropicImageBlock; error
43
45
  return { error: "unsupported image URL scheme (expected data: or https:)" };
44
46
  }
45
47
 
48
+ /** Read at most `MAX_SIDECAR_RESPONSE_BYTES` of an untrusted upstream body, then stop reading. */
49
+ async function readBoundedText(res: Response): Promise<string> {
50
+ if (!res.body) return "";
51
+ const reader = res.body.getReader();
52
+ const decoder = new TextDecoder();
53
+ let out = "";
54
+ let seen = 0;
55
+ try {
56
+ for (;;) {
57
+ const { done, value } = await reader.read();
58
+ if (done) break;
59
+ const remaining = MAX_SIDECAR_RESPONSE_BYTES - seen;
60
+ const accepted = value.byteLength <= remaining ? value : value.subarray(0, remaining);
61
+ seen += accepted.byteLength;
62
+ out += decoder.decode(accepted, { stream: true });
63
+ if (seen >= MAX_SIDECAR_RESPONSE_BYTES) {
64
+ try { void reader.cancel("vision sidecar error body byte limit reached").catch(() => undefined); }
65
+ catch { /* best-effort body teardown */ }
66
+ break;
67
+ }
68
+ }
69
+ out += decoder.decode();
70
+ } catch {
71
+ /* a failed error-body read must not mask the HTTP status we are about to report */
72
+ }
73
+ return out;
74
+ }
75
+
46
76
  /** Fold Anthropic Messages text deltas into one description. Malformed frames are ignored. */
47
77
  export async function parseAnthropicVisionSSE(res: Response): Promise<DescribeOutcome> {
48
78
  if (!res.body) return { text: "", error: "anthropic vision sidecar returned no response body" };
@@ -52,6 +82,7 @@ export async function parseAnthropicVisionSSE(res: Response): Promise<DescribeOu
52
82
  const decoder = new TextDecoder();
53
83
  const reader = res.body.getReader();
54
84
  let buffer = "";
85
+ let responseBytes = 0;
55
86
 
56
87
  const processFrame = (rawFrame: string): void => {
57
88
  let dataLine = "";
@@ -76,12 +107,24 @@ export async function parseAnthropicVisionSSE(res: Response): Promise<DescribeOu
76
107
  for (;;) {
77
108
  const { done, value } = await reader.read();
78
109
  if (done) break;
79
- buffer = (buffer + decoder.decode(value, { stream: true })).replace(/\r\n/g, "\n");
110
+ // Frames only fold on a `\n\n` separator, so an upstream that never emits one would grow
111
+ // `buffer` for the whole response. Accept a bounded prefix instead.
112
+ const remaining = MAX_SIDECAR_RESPONSE_BYTES - responseBytes;
113
+ const accepted = value.byteLength <= remaining ? value : value.subarray(0, remaining);
114
+ responseBytes += accepted.byteLength;
115
+ buffer = (buffer + decoder.decode(accepted, { stream: true })).replace(/\r\n/g, "\n");
80
116
  let separator: number;
81
117
  while ((separator = buffer.indexOf("\n\n")) !== -1) {
82
118
  processFrame(buffer.slice(0, separator));
83
119
  buffer = buffer.slice(separator + 2);
84
120
  }
121
+ if (responseBytes >= MAX_SIDECAR_RESPONSE_BYTES) {
122
+ // Keep the frames folded above, drop the unterminated tail, and do not wait on teardown.
123
+ try { void reader.cancel("vision sidecar response byte limit reached").catch(() => undefined); }
124
+ catch { /* best-effort body teardown */ }
125
+ buffer = "";
126
+ break;
127
+ }
85
128
  }
86
129
  buffer = (buffer + decoder.decode()).replace(/\r\n/g, "\n");
87
130
  if (buffer.trim()) processFrame(buffer);
@@ -164,7 +207,8 @@ export async function describeImageAnthropic(
164
207
  { abortSignal: linkedSignal.signal, label: "vision-sidecar-anthropic" },
165
208
  );
166
209
  if (!res.ok) {
167
- const responseText = await res.text().catch(() => "");
210
+ // The body is untrusted and only feeds one auth-failure message, so read a bounded prefix.
211
+ const responseText = await readBoundedText(res);
168
212
  console.warn(`[vision] anthropic sidecar HTTP ${res.status} (${Date.now() - startedAt}ms)`);
169
213
  if (res.status === 401) {
170
214
  return { text: "", error: `anthropic vision sidecar auth failed: ${publicOAuthAuthenticationErrorMessage(new Error(responseText))}` };