codex-multi-auth 2.9.2 → 2.11.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 (81) hide show
  1. package/.codex-plugin/plugin.json +1 -1
  2. package/README.md +2 -1
  3. package/config/README.md +3 -1
  4. package/config/codex-legacy.json +300 -0
  5. package/config/codex-modern.json +96 -0
  6. package/config/schema/config.schema.json +23 -0
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +62 -2
  9. package/dist/lib/accounts.d.ts +25 -1
  10. package/dist/lib/accounts.d.ts.map +1 -1
  11. package/dist/lib/accounts.js +26 -6
  12. package/dist/lib/capability-policy.d.ts.map +1 -1
  13. package/dist/lib/capability-policy.js +16 -1
  14. package/dist/lib/codex-manager/backend-settings-helpers.d.ts.map +1 -1
  15. package/dist/lib/codex-manager/backend-settings-helpers.js +10 -1
  16. package/dist/lib/codex-manager/backend-settings-schema.d.ts +18 -0
  17. package/dist/lib/codex-manager/backend-settings-schema.d.ts.map +1 -1
  18. package/dist/lib/codex-manager/backend-settings-schema.js +19 -0
  19. package/dist/lib/codex-manager/experimental-settings-prompt.d.ts +1 -0
  20. package/dist/lib/codex-manager/experimental-settings-prompt.d.ts.map +1 -1
  21. package/dist/lib/codex-manager/experimental-settings-prompt.js +12 -0
  22. package/dist/lib/codex-manager/experimental-settings-schema.d.ts +2 -0
  23. package/dist/lib/codex-manager/experimental-settings-schema.d.ts.map +1 -1
  24. package/dist/lib/codex-manager/experimental-settings-schema.js +2 -0
  25. package/dist/lib/config.d.ts +29 -0
  26. package/dist/lib/config.d.ts.map +1 -1
  27. package/dist/lib/config.js +91 -1
  28. package/dist/lib/context-budget/model-context-windows.d.ts +25 -0
  29. package/dist/lib/context-budget/model-context-windows.d.ts.map +1 -0
  30. package/dist/lib/context-budget/model-context-windows.js +130 -0
  31. package/dist/lib/context-budget-guard.d.ts +100 -0
  32. package/dist/lib/context-budget-guard.d.ts.map +1 -0
  33. package/dist/lib/context-budget-guard.js +183 -0
  34. package/dist/lib/context-budget-response.d.ts +25 -0
  35. package/dist/lib/context-budget-response.d.ts.map +1 -0
  36. package/dist/lib/context-budget-response.js +57 -0
  37. package/dist/lib/context-overflow.d.ts.map +1 -1
  38. package/dist/lib/context-overflow.js +5 -60
  39. package/dist/lib/model-capability-matrix.d.ts.map +1 -1
  40. package/dist/lib/model-capability-matrix.js +9 -1
  41. package/dist/lib/request/error-classification.d.ts.map +1 -1
  42. package/dist/lib/request/error-classification.js +43 -2
  43. package/dist/lib/request/helpers/model-map.d.ts.map +1 -1
  44. package/dist/lib/request/helpers/model-map.js +181 -0
  45. package/dist/lib/request/rate-limit-decision.d.ts +4 -5
  46. package/dist/lib/request/rate-limit-decision.d.ts.map +1 -1
  47. package/dist/lib/request/rate-limit-decision.js +33 -4
  48. package/dist/lib/request/response-metadata.d.ts.map +1 -1
  49. package/dist/lib/request/response-metadata.js +1 -0
  50. package/dist/lib/request/stream-failover-runtime.d.ts +3 -1
  51. package/dist/lib/request/stream-failover-runtime.d.ts.map +1 -1
  52. package/dist/lib/request/stream-failover-runtime.js +7 -2
  53. package/dist/lib/runtime/context-budget-settings.d.ts +13 -0
  54. package/dist/lib/runtime/context-budget-settings.d.ts.map +1 -0
  55. package/dist/lib/runtime/context-budget-settings.js +8 -0
  56. package/dist/lib/runtime/loader-setup.d.ts +1 -0
  57. package/dist/lib/runtime/loader-setup.d.ts.map +1 -1
  58. package/dist/lib/runtime/loader-setup.js +1 -0
  59. package/dist/lib/runtime/rotation-account-selection.d.ts +23 -0
  60. package/dist/lib/runtime/rotation-account-selection.d.ts.map +1 -1
  61. package/dist/lib/runtime/rotation-account-selection.js +15 -6
  62. package/dist/lib/runtime/rotation-proxy-state.d.ts +2 -0
  63. package/dist/lib/runtime/rotation-proxy-state.d.ts.map +1 -1
  64. package/dist/lib/runtime/rotation-server-types.d.ts +11 -0
  65. package/dist/lib/runtime/rotation-server-types.d.ts.map +1 -1
  66. package/dist/lib/runtime-rotation-proxy.d.ts.map +1 -1
  67. package/dist/lib/runtime-rotation-proxy.js +251 -43
  68. package/dist/lib/schemas.d.ts +4 -0
  69. package/dist/lib/schemas.d.ts.map +1 -1
  70. package/dist/lib/schemas.js +10 -0
  71. package/dist/lib/synthetic-response.d.ts +31 -0
  72. package/dist/lib/synthetic-response.d.ts.map +1 -0
  73. package/dist/lib/synthetic-response.js +79 -0
  74. package/dist/lib/ui/ui-copy.d.ts +2 -1
  75. package/dist/lib/ui/ui-copy.d.ts.map +1 -1
  76. package/dist/lib/ui/ui-copy.js +2 -1
  77. package/dist/lib/usage/pricing.d.ts +1 -1
  78. package/dist/lib/usage/pricing.d.ts.map +1 -1
  79. package/dist/lib/usage/pricing.js +33 -0
  80. package/package.json +1 -1
  81. package/scripts/codex.js +153 -2
@@ -1 +1 @@
1
- {"version":3,"file":"rotation-server-types.d.ts","sourceRoot":"","sources":["../../../lib/runtime/rotation-server-types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AACrD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAEvD,MAAM,WAAW,0BAA0B;IAC1C,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAC3B,SAAS,EAAE,MAAM,0BAA0B,CAAC;IAC5C;;;;;OAKG;IACH,sBAAsB,CAAC,EAAE,MAAM,MAAM,CAAC;CACtC;AAED,MAAM,WAAW,0BAA0B;IAC1C,SAAS,EAAE,MAAM,CAAC;IAClB,aAAa,EAAE,MAAM,CAAC;IACtB,gBAAgB,EAAE,MAAM,CAAC;IACzB,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,cAAc,EAAE,MAAM,CAAC;IACvB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,oBAAoB,EAAE,MAAM,GAAG,IAAI,CAAC;CACpC;AAED,MAAM,WAAW,2BAA2B;IAC3C,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,YAAY,EAAE,MAAM,CAAC;IACrB,cAAc,CAAC,EAAE,cAAc,CAAC;IAChC,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;IACzB,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IACnB,8BAA8B,CAAC,EAAE,MAAM,CAAC;IACxC,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B;;;;;;;OAOG;IACH,kBAAkB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACnC;AAED,MAAM,WAAW,cAAc;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,EAAE,KAAK,GAAG,MAAM,CAAC;IACvB,YAAY,EAAE,MAAM,CAAC;IACrB,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,MAAM,EAAE,WAAW,CAAC;IACpB,MAAM,EAAE,OAAO,CAAC;IAChB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;CAC1B;AAED,MAAM,MAAM,gBAAgB,GACzB,YAAY,GACZ,cAAc,GACd,eAAe,GACf,cAAc,GACd,QAAQ,GACR,aAAa,GACb,YAAY,CAAC;AAChB,MAAM,MAAM,qBAAqB,GAAG,KAAK,GAAG;IAC3C,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;CACb,CAAC;AAEF,MAAM,WAAW,8BAA8B;IAC9C,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,SAAS,EAAE,MAAM,CAAC;CAClB"}
1
+ {"version":3,"file":"rotation-server-types.d.ts","sourceRoot":"","sources":["../../../lib/runtime/rotation-server-types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,gBAAgB,CAAC;AACrD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,qBAAqB,CAAC;AAEvD,MAAM,WAAW,0BAA0B;IAC1C,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAC3B,SAAS,EAAE,MAAM,0BAA0B,CAAC;IAC5C;;;;;OAKG;IACH,sBAAsB,CAAC,EAAE,MAAM,MAAM,CAAC;CACtC;AAED,MAAM,WAAW,0BAA0B;IAC1C,SAAS,EAAE,MAAM,CAAC;IAClB,aAAa,EAAE,MAAM,CAAC;IACtB,gBAAgB,EAAE,MAAM,CAAC;IACzB,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,cAAc,EAAE,MAAM,CAAC;IACvB,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,oBAAoB,EAAE,MAAM,GAAG,IAAI,CAAC;CACpC;AAED,MAAM,WAAW,2BAA2B;IAC3C,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,YAAY,EAAE,MAAM,CAAC;IACrB,cAAc,CAAC,EAAE,cAAc,CAAC;IAChC,SAAS,CAAC,EAAE,OAAO,KAAK,CAAC;IACzB,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IACnB,8BAA8B,CAAC,EAAE,MAAM,CAAC;IACxC,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B;;;;;;;OAOG;IACH,kBAAkB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CACnC;AAED,MAAM,WAAW,cAAc;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,OAAO,CAAC;IACjB,MAAM,EAAE,KAAK,GAAG,MAAM,CAAC;IACvB,YAAY,EAAE,MAAM,CAAC;IACrB,KAAK,EAAE,MAAM,GAAG,IAAI,CAAC;IACrB,MAAM,EAAE,WAAW,CAAC;IACpB,MAAM,EAAE,OAAO,CAAC;IAChB,UAAU,EAAE,MAAM,GAAG,IAAI,CAAC;IAC1B;;;;;;;;;OASG;IACH,gBAAgB,EAAE,MAAM,GAAG,IAAI,CAAC;CAChC;AAED,MAAM,MAAM,gBAAgB,GACzB,YAAY,GACZ,cAAc,GACd,eAAe,GACf,cAAc,GACd,QAAQ,GACR,aAAa,GACb,YAAY,CAAC;AAChB,MAAM,MAAM,qBAAqB,GAAG,KAAK,GAAG;IAC3C,UAAU,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;CACb,CAAC;AAEF,MAAM,WAAW,8BAA8B;IAC9C,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,GAAG,IAAI,CAAC;IACzB,SAAS,EAAE,MAAM,CAAC;CAClB"}
@@ -1 +1 @@
1
- {"version":3,"file":"runtime-rotation-proxy.d.ts","sourceRoot":"","sources":["../../lib/runtime-rotation-proxy.ts"],"names":[],"mappings":"AAGA,OAAO,EAIN,KAAK,cAAc,EACnB,MAAM,eAAe,CAAC;AA8BvB,OAAO,EAAkB,KAAK,WAAW,EAAE,MAAM,oBAAoB,CAAC;AA6CtE,OAAO,KAAK,EAKX,2BAA2B,EAC3B,0BAA0B,EAE1B,MAAM,oCAAoC,CAAC;AAe5C,YAAY,EACX,2BAA2B,EAC3B,0BAA0B,EAC1B,0BAA0B,GAC1B,MAAM,oCAAoC,CAAC;AAC5C,OAAO,EACN,+BAA+B,EAC/B,0BAA0B,GAC1B,MAAM,kCAAkC,CAAC;AAC1C,YAAY,EAAE,0BAA0B,EAAE,MAAM,kCAAkC,CAAC;AACnF,OAAO,EAAE,aAAa,EAAE,MAAM,yCAAyC,CAAC;AACxE,OAAO,EACN,+BAA+B,EAC/B,8BAA8B,EAC9B,uBAAuB,EACvB,uBAAuB,GACvB,MAAM,oCAAoC,CAAC;AAC5C,YAAY,EAAE,WAAW,EAAE,MAAM,oCAAoC,CAAC;AA8DtE,kFAAkF;AAClF,wBAAgB,qBAAqB,CACpC,OAAO,EAAE,IAAI,CAAC,cAAc,EAAE,WAAW,GAAG,OAAO,GAAG,cAAc,CAAC,GAAG;IACvE,gFAAgF;IAChF,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;CAClB,EACD,MAAM,EAAE,WAAW,EACnB,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,GACnB,MAAM,CAmBR;AA8bD;;;;;;;;GAQG;AACH,wBAAgB,2BAA2B,CAC1C,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,GAAG,SAAS,GACvC,MAAM,GAAG,IAAI,CAUf;AAED,wBAAsB,yBAAyB,CAC9C,OAAO,EAAE,2BAA2B,GAClC,OAAO,CAAC,0BAA0B,CAAC,CA4KrC"}
1
+ {"version":3,"file":"runtime-rotation-proxy.d.ts","sourceRoot":"","sources":["../../lib/runtime-rotation-proxy.ts"],"names":[],"mappings":"AAGA,OAAO,EAIN,KAAK,cAAc,EACnB,MAAM,eAAe,CAAC;AAkCvB,OAAO,EAAkB,KAAK,WAAW,EAAE,MAAM,oBAAoB,CAAC;AAkDtE,OAAO,KAAK,EAKX,2BAA2B,EAC3B,0BAA0B,EAE1B,MAAM,oCAAoC,CAAC;AAe5C,YAAY,EACX,2BAA2B,EAC3B,0BAA0B,EAC1B,0BAA0B,GAC1B,MAAM,oCAAoC,CAAC;AAC5C,OAAO,EACN,+BAA+B,EAC/B,0BAA0B,GAC1B,MAAM,kCAAkC,CAAC;AAC1C,YAAY,EAAE,0BAA0B,EAAE,MAAM,kCAAkC,CAAC;AACnF,OAAO,EAAE,aAAa,EAAE,MAAM,yCAAyC,CAAC;AACxE,OAAO,EACN,+BAA+B,EAC/B,8BAA8B,EAC9B,uBAAuB,EACvB,uBAAuB,GACvB,MAAM,oCAAoC,CAAC;AAC5C,YAAY,EAAE,WAAW,EAAE,MAAM,oCAAoC,CAAC;AA8DtE,kFAAkF;AAClF,wBAAgB,qBAAqB,CACpC,OAAO,EAAE,IAAI,CAAC,cAAc,EAAE,WAAW,GAAG,OAAO,GAAG,cAAc,CAAC,GAAG;IACvE,gFAAgF;IAChF,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;CAClB,EACD,MAAM,EAAE,WAAW,EACnB,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,GACnB,MAAM,CAmBR;AA8gBD;;;;;;;;GAQG;AACH,wBAAgB,2BAA2B,CAC1C,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,GAAG,SAAS,GACvC,MAAM,GAAG,IAAI,CAUf;AAED,wBAAsB,yBAAyB,CAC9C,OAAO,EAAE,2BAA2B,GAClC,OAAO,CAAC,0BAA0B,CAAC,CAmLrC"}
@@ -2,7 +2,7 @@ import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
2
2
  import { createServer } from "node:http";
3
3
  import { AccountManager, AUTH_INVALIDATION_MARKER, extractAccountId, } from "./accounts.js";
4
4
  import { withRoutingMutex } from "./routing-mutex.js";
5
- import { getFetchTimeoutMs, getNetworkErrorCooldownMs, getRetryAllAccountsMaxRetries, getServerErrorCooldownMs, getSessionAffinity, getSessionAffinityMaxEntries, getSessionAffinityTtlMs, getStreamStallTimeoutMs, getMinRotationIntervalMs, getTokenInvalidationCooldownMs, getTokenRefreshSkewMs, getPidOffsetEnabled, getPreemptiveQuotaEnabled, getPreemptiveQuotaMaxDeferralMs, getPreemptiveQuotaRemainingPercent5h, getPreemptiveQuotaRemainingPercent7d, getRoutingMutexMode, getSchedulingStrategy, loadPluginConfig, } from "./config.js";
5
+ import { getFetchTimeoutMs, getNetworkErrorCooldownMs, getRetryAllAccountsMaxRetries, getServerErrorCooldownMs, getSessionAffinity, getSessionAffinityMaxEntries, getSessionAffinityTtlMs, getStreamStallTimeoutMs, getMinRotationIntervalMs, getTokenInvalidationCooldownMs, getTokenRefreshSkewMs, getPidOffsetEnabled, getPreemptiveQuotaEnabled, getPreemptiveQuotaMaxDeferralMs, getPreemptiveQuotaRemainingPercent5h, getPreemptiveQuotaRemainingPercent7d, getContextBudgetGuardEnabled, getContextBudgetGuardSoftPercent, getContextBudgetGuardHardPercent, getContextBudgetGuardModelWindowOverrides, getRoutingMutexMode, getSchedulingStrategy, loadPluginConfig, } from "./config.js";
6
6
  import { CODEX_BASE_URL, HTTP_STATUS, OPENAI_HEADERS, OPENAI_HEADER_VALUES, URL_PATHS, } from "./constants.js";
7
7
  import { getModelFamily } from "./prompts/codex.js";
8
8
  import { CURRENT_CODEX_MODEL } from "./request/helpers/model-map.js";
@@ -11,6 +11,8 @@ import { createRuntimeUsageRecorder, evaluateRuntimePolicy, loadRuntimePolicySta
11
11
  import { createUsageStreamScanner } from "./usage/usage-extraction.js";
12
12
  import { isWorkspaceDisabledError } from "./request/fetch-helpers.js";
13
13
  import { PreemptiveQuotaScheduler, readQuotaSchedulerSnapshot, } from "./preemptive-quota-scheduler.js";
14
+ import { ContextBudgetGuard } from "./context-budget-guard.js";
15
+ import { buildContextBudgetHeaders, createContextBudgetPauseResponse, } from "./context-budget-response.js";
14
16
  import { createLogger, maskString, runWithCorrelationId } from "./logger.js";
15
17
  import { CodexValidationError } from "./errors.js";
16
18
  import { normalizeEmailKey } from "./storage/identity.js";
@@ -22,7 +24,7 @@ import { createRotationProxyState, recoverStaleRuntimeState, } from "./runtime/r
22
24
  import { readStorageMetaFromDisk } from "./runtime/rotation-storage-meta.js";
23
25
  import { applyMonotonicAuthCooldown, DEFAULT_AUTH_FAILURE_COOLDOWN_MS, ensureFreshAccessToken, } from "./runtime/rotation-token-refresh.js";
24
26
  import { SessionAffinityStore } from "./session-affinity.js";
25
- import { isRecord } from "./utils.js";
27
+ import { isRecord, sleep } from "./utils.js";
26
28
  export { buildPinnedUnavailableErrorBody, buildTokenInvalidationBody, } from "./request/rate-limit-decision.js";
27
29
  export { chooseAccount } from "./runtime/rotation-account-selection.js";
28
30
  export { maybeInvalidateAffinityFromDisk, readPinnedAccountIndexFromDisk, readStorageMetaFromDisk, resetPinCacheForTesting, } from "./runtime/rotation-storage-meta.js";
@@ -98,6 +100,39 @@ export function buildQuotaScheduleKey(account, family, model) {
98
100
  return `account:${accountIdentity}:${model ?? family}`;
99
101
  }
100
102
  const DEFAULT_MAX_RUNTIME_ACCOUNT_ATTEMPTS = 4;
103
+ // This is a hard safety ceiling over every pinned selection pass, including
104
+ // branches that do not consume the transient-attempt budget. It deliberately
105
+ // overrides larger retry settings for pinned requests.
106
+ const MAX_PINNED_SELECTION_ITERATIONS = 16;
107
+ /**
108
+ * Ceiling on same-account re-sends for one pinned request.
109
+ *
110
+ * The pool's transient budget is derived from `retryAllAccountsMaxRetries`,
111
+ * whose documented meaning is "wait and retry when EVERY account is
112
+ * rate-limited". For an unpinned pool that budget spends across different
113
+ * accounts; for a pin every unit of it is another copy of the same
114
+ * (non-idempotent) request to the same upstream, so raising that pool knob
115
+ * must not silently multiply pinned re-sends. A user who LOWERS the knob
116
+ * still gets fewer attempts.
117
+ */
118
+ const MAX_PINNED_TRANSIENT_ATTEMPTS = 4;
119
+ /**
120
+ * Backoff before each pinned re-attempt: 250ms, 500ms, 1s, capped.
121
+ *
122
+ * A pinned retry re-sends the SAME non-idempotent request to the SAME
123
+ * upstream, and the retry loop has no other delay anywhere in it. The
124
+ * account's cooldown used to supply that spacing by accident, by blocking
125
+ * selection outright — which is also why the retry budget could never be
126
+ * spent. Waiving the cooldown for the retry (see `allowPinnedCooldown`)
127
+ * removes that accidental spacing, so replace it with a real one that is
128
+ * short enough not to dominate the request's latency: at most
129
+ * 250 + 500 + 1000 = 1.75s across the whole request at the default budget.
130
+ */
131
+ const PINNED_RETRY_BACKOFF_STEPS_MS = [250, 500, 1_000];
132
+ function pinnedRetryBackoffMs(attempt) {
133
+ const index = Math.min(Math.max(0, attempt - 1), PINNED_RETRY_BACKOFF_STEPS_MS.length - 1);
134
+ return PINNED_RETRY_BACKOFF_STEPS_MS[index] ?? 0;
135
+ }
101
136
  const MAX_REQUEST_BODY_BYTES = 64 * 1024 * 1024;
102
137
  const MAX_THREAD_GOAL_FALLBACKS = 512;
103
138
  const ALLOWED_RESPONSES_PATHS = new Set([
@@ -317,6 +352,41 @@ function getThreadGoalFallback(fallbacks, key) {
317
352
  fallbacks.set(key, goal);
318
353
  return goal;
319
354
  }
355
+ /**
356
+ * Session identity that is stable for the life of a conversation.
357
+ *
358
+ * Every source here keeps the same value across turns, so state keyed on it
359
+ * accumulates. `resolveSessionKey` adds `previous_response_id` on top for
360
+ * callers that only need "requests that belong together right now".
361
+ */
362
+ function resolveStableSessionKey(headers, parsedBody) {
363
+ const headerKey = headers.get(OPENAI_HEADERS.SESSION_ID) ??
364
+ headers.get(OPENAI_HEADERS.CONVERSATION_ID) ??
365
+ null;
366
+ if (headerKey && headerKey.trim().length > 0)
367
+ return headerKey.trim();
368
+ if (!parsedBody)
369
+ return null;
370
+ if (typeof parsedBody.prompt_cache_key === "string") {
371
+ const key = parsedBody.prompt_cache_key.trim();
372
+ if (key.length > 0)
373
+ return key;
374
+ }
375
+ const metadata = parsedBody.metadata;
376
+ if (isRecord(metadata)) {
377
+ return (readStringRecordValue(metadata, "session_id") ??
378
+ readStringRecordValue(metadata, "conversation_id") ??
379
+ readStringRecordValue(metadata, "thread_id"));
380
+ }
381
+ return null;
382
+ }
383
+ /**
384
+ * Session identity for affinity/pinning: the stable sources above, plus
385
+ * `previous_response_id` as a last resort. Precedence is unchanged from
386
+ * before `resolveStableSessionKey` was split out — `previous_response_id`
387
+ * still outranks the `metadata` fallbacks — so which account a request pins
388
+ * to does not shift.
389
+ */
320
390
  function resolveSessionKey(headers, parsedBody) {
321
391
  const headerKey = headers.get(OPENAI_HEADERS.SESSION_ID) ??
322
392
  headers.get(OPENAI_HEADERS.CONVERSATION_ID) ??
@@ -362,6 +432,7 @@ function buildResponsesRequestContext(req, body) {
362
432
  family: getModelFamily(model ?? CURRENT_CODEX_MODEL),
363
433
  stream: parsedBody?.stream === true,
364
434
  sessionKey: resolveSessionKey(headers, parsedBody),
435
+ stableSessionKey: resolveStableSessionKey(headers, parsedBody),
365
436
  };
366
437
  }
367
438
  function buildModelsRequestContext(req) {
@@ -374,6 +445,7 @@ function buildModelsRequestContext(req) {
374
445
  family: "codex",
375
446
  stream: false,
376
447
  sessionKey: null,
448
+ stableSessionKey: null,
377
449
  };
378
450
  }
379
451
  function buildThreadGoalRequestContext(req, body, pathname) {
@@ -396,6 +468,7 @@ function buildThreadGoalRequestContext(req, body, pathname) {
396
468
  family: "codex",
397
469
  stream: false,
398
470
  sessionKey,
471
+ stableSessionKey: bodyThreadKey ?? queryThreadKey ?? resolveStableSessionKey(headers, parsedBody),
399
472
  };
400
473
  }
401
474
  function buildUpstreamUrl(req, upstreamBaseUrl, upstreamPath) {
@@ -543,6 +616,12 @@ export async function startRuntimeRotationProxy(options) {
543
616
  getPreemptiveQuotaRemainingPercent7d(pluginConfig),
544
617
  maxDeferralMs: getPreemptiveQuotaMaxDeferralMs(pluginConfig),
545
618
  });
619
+ const contextBudgetGuard = new ContextBudgetGuard({
620
+ enabled: getContextBudgetGuardEnabled(pluginConfig),
621
+ softPercent: getContextBudgetGuardSoftPercent(pluginConfig),
622
+ hardPercent: getContextBudgetGuardHardPercent(pluginConfig),
623
+ modelWindowOverrides: getContextBudgetGuardModelWindowOverrides(pluginConfig),
624
+ });
546
625
  const sessionAffinityStore = getSessionAffinity(pluginConfig)
547
626
  ? new SessionAffinityStore({
548
627
  ttlMs: getSessionAffinityTtlMs(pluginConfig),
@@ -573,6 +652,7 @@ export async function startRuntimeRotationProxy(options) {
573
652
  maxRequestBodyBytes,
574
653
  quotaRemainingPercentThreshold,
575
654
  preemptiveQuotaScheduler,
655
+ contextBudgetGuard,
576
656
  sessionAffinityStore,
577
657
  lastObservedAffinityGeneration,
578
658
  forcedAccountIndex,
@@ -731,6 +811,40 @@ async function handleRequestInner(state, req, res, traceId) {
731
811
  });
732
812
  return;
733
813
  }
814
+ // Context budget guard: pause BEFORE spending an upstream round-trip on a
815
+ // request that the tracked session is already over the hard threshold
816
+ // for, rather than reacting to the eventual context_length_exceeded 400
817
+ // (which this rotation-proxy path does not otherwise handle at all — see
818
+ // lib/context-overflow.ts, wired only into the plugin-loader fetch path).
819
+ // Runs independent of account selection: which account serves the
820
+ // request has no bearing on how full its context already is. Hoisted
821
+ // to function scope (not just this `if`) so the soft-threshold branch
822
+ // below can attach its non-blocking header to the eventual forwarded
823
+ // response.
824
+ let budgetAdvisory = {
825
+ level: "ok",
826
+ };
827
+ if (isResponsesRequest) {
828
+ budgetAdvisory = state.contextBudgetGuard.getAdvisory(context.stableSessionKey ?? "", requestStartedAt, context.model);
829
+ if (budgetAdvisory.level === "hard") {
830
+ // One-shot: the pause returns before the request is forwarded, so
831
+ // `update()` below never runs for it and the recorded usage cannot
832
+ // fall on its own. Without dropping the snapshot the first crossing
833
+ // would wedge the session permanently — including the `/compact`
834
+ // turn this pause tells the user to run, which carries the same
835
+ // session key.
836
+ state.contextBudgetGuard.noteHardPauseEmitted(context.stableSessionKey ?? "");
837
+ await usageRecorder.record({
838
+ outcome: "blocked",
839
+ statusCode: HTTP_STATUS.OK,
840
+ errorCode: "context_budget_guard_paused",
841
+ });
842
+ const pauseResponse = createContextBudgetPauseResponse(budgetAdvisory);
843
+ res.writeHead(pauseResponse.status, Object.fromEntries(pauseResponse.headers.entries()));
844
+ res.end(await pauseResponse.text());
845
+ return;
846
+ }
847
+ }
734
848
  const upstreamUrl = buildUpstreamUrl(req, state.upstreamBaseUrl, context.upstreamPath);
735
849
  const attemptedIndexes = new Set();
736
850
  let exhaustionReason = "no-account";
@@ -757,12 +871,51 @@ async function handleRequestInner(state, req, res, traceId) {
757
871
  // applies unchanged because it all keys off `pinnedIndex` / `isPinned`.
758
872
  const pinnedIndex = state.forcedAccountIndex ?? storageMeta.pinnedAccountIndex;
759
873
  const isPinned = typeof pinnedIndex === "number";
874
+ // The token bucket spreads load across a selectable pool. A pin has no
875
+ // alternative account, so exhausting that local heuristic can only reject a
876
+ // request that the pinned account could serve. Keep circuit-breaker admission
877
+ // inside consumeToken, but do not debit the pool-scoring bucket for a pin.
878
+ const bypassPoolTokenBucket = isPinned;
879
+ const refundConsumedPoolToken = (account) => {
880
+ if (!bypassPoolTokenBucket) {
881
+ accountManager.refundToken(account, context.family, context.model);
882
+ }
883
+ };
884
+ // `rotations` counts moves to a DIFFERENT account. A pinned request has
885
+ // nowhere to move: every retry below re-attempts the same one. Counting
886
+ // those reported several account rotations on a single-account pool whose
887
+ // pin forbids rotation, in `rotation status` and the persisted counters.
888
+ const noteRotation = () => {
889
+ if (!isPinned)
890
+ state.status.rotations += 1;
891
+ };
892
+ // Pool attempts are normally capped by account count because an unpinned
893
+ // account is selected at most once per request. A pin can only retry the
894
+ // same account, so use the configured transient-attempt limit instead of
895
+ // the account-count limit -- capped by MAX_PINNED_TRANSIENT_ATTEMPTS so a
896
+ // pool knob about rate-limited rotation cannot become a same-account
897
+ // re-send multiplier. Every pinned pass is still subject to the hard
898
+ // 16-selection ceiling above.
899
+ if (isPinned) {
900
+ transientAttemptLimit = Math.max(1, Math.min(state.maxRuntimeAccountAttempts, MAX_PINNED_TRANSIENT_ATTEMPTS));
901
+ }
760
902
  if (storageMeta.affinityGeneration > state.lastObservedAffinityGeneration) {
761
903
  state.sessionAffinityStore?.clearAll();
762
904
  state.lastObservedAffinityGeneration = storageMeta.affinityGeneration;
763
905
  }
764
- while (attemptedIndexes.size < accountCount &&
765
- transientAttempts < transientAttemptLimit) {
906
+ let runtimeSelectionIterations = 0;
907
+ while ((isPinned || attemptedIndexes.size < accountCount) &&
908
+ transientAttempts < transientAttemptLimit &&
909
+ (!isPinned ||
910
+ runtimeSelectionIterations < MAX_PINNED_SELECTION_ITERATIONS)) {
911
+ // Space out same-account re-sends. The loop has no other delay in it,
912
+ // and the pinned retry deliberately waives the account's own cooldown
913
+ // (below), so without this two copies of the same non-idempotent
914
+ // request would hit upstream back to back.
915
+ if (isPinned && transientAttempts > 0) {
916
+ await sleep(pinnedRetryBackoffMs(transientAttempts));
917
+ }
918
+ runtimeSelectionIterations += 1;
766
919
  const rotationStickyBoost = state.minRotationIntervalMs > 0 &&
767
920
  state.lastGlobalAccountIndex !== null &&
768
921
  state.now() - state.lastGlobalSwitchAt < state.minRotationIntervalMs
@@ -797,6 +950,12 @@ async function handleRequestInner(state, req, res, traceId) {
797
950
  stickyBoostByAccount: rotationStickyBoost,
798
951
  pidOffsetEnabled: state.pidOffsetEnabled,
799
952
  schedulingStrategy: state.schedulingStrategy,
953
+ // Only on a RETRY pass. The first pass still honors a cooldown
954
+ // another request left on the pin, so an already-cooling pinned
955
+ // account 503s immediately as before; from the second pass on,
956
+ // this request's own retry budget outranks the cooldown it just
957
+ // created for itself.
958
+ allowPinnedCooldown: isPinned && transientAttempts > 0,
800
959
  });
801
960
  const selected = state.routingMutexMode === "enabled"
802
961
  ? await withRoutingMutex(state.routingMutexMode, async () => {
@@ -856,12 +1015,27 @@ async function handleRequestInner(state, req, res, traceId) {
856
1015
  accountManager.markRateLimitedWithReason(selected, preemptiveDeferral.waitMs, context.family, "quota", context.model);
857
1016
  accountManager.recordRateLimit(selected, context.family, context.model);
858
1017
  accountManager.saveToDiskDebounced();
859
- state.status.rotations += 1;
1018
+ noteRotation();
860
1019
  continue;
861
1020
  }
862
- if (!accountManager.consumeToken(selected, context.family, context.model)) {
863
- accountSkipReasons.set(selected.index, "token-exhausted");
1021
+ // consumeToken also takes the race-safe circuit admission slot, so it
1022
+ // can reject for either gate. Take the reason from the call that
1023
+ // rejected rather than re-deriving it: a second evaluation reports the
1024
+ // first blocker it finds (a live cooldown would mask a drained bucket)
1025
+ // and cannot see a half-open probe slot that was just claimed.
1026
+ const admission = accountManager.consumeTokenWithReason(selected, context.family, context.model, { bypassTokenBucket: bypassPoolTokenBucket });
1027
+ if (!admission.ok) {
1028
+ accountSkipReasons.set(selected.index, admission.reason);
864
1029
  exhaustionReason = "rate-limit";
1030
+ // Neither gate can change inside this loop -- nothing here refills the
1031
+ // bucket or closes a circuit -- and a pin has no other account to move
1032
+ // to, so re-selecting would burn the whole 16-iteration ceiling
1033
+ // re-deriving the identical verdict. (For a pin the bucket is bypassed
1034
+ // entirely, so the reason here is always circuit-open.) Unpinned
1035
+ // selection still continues -- there the next pass picks a DIFFERENT
1036
+ // account.
1037
+ if (isPinned)
1038
+ break;
865
1039
  continue;
866
1040
  }
867
1041
  const refreshed = await ensureFreshAccessToken({
@@ -874,7 +1048,13 @@ async function handleRequestInner(state, req, res, traceId) {
874
1048
  tokenInvalidationCooldownMs: state.tokenInvalidationCooldownMs,
875
1049
  });
876
1050
  if (!refreshed.ok) {
877
- accountManager.refundToken(selected, context.family, context.model);
1051
+ refundConsumedPoolToken(selected);
1052
+ // No accountSkipReasons write here: ensureFreshAccessToken always
1053
+ // applies an auth cooldown before returning !ok, so the next
1054
+ // selection pass overwrites whatever this set with
1055
+ // cooling-down:auth-failure, the invalidated exit returns a 401
1056
+ // without reading the map at all, and the budget-boundary block
1057
+ // below already records "auth-failure" for a pin out of retries.
878
1058
  exhaustionReason = "auth-failure";
879
1059
  if (refreshed.invalidated) {
880
1060
  // Refresh endpoint explicitly revoked the token. Stop cascade:
@@ -898,12 +1078,12 @@ async function handleRequestInner(state, req, res, traceId) {
898
1078
  transientAttempts += 1;
899
1079
  transientExhaustionReason = "auth-failure";
900
1080
  state.status.retries += 1;
901
- state.status.rotations += 1;
1081
+ noteRotation();
902
1082
  continue;
903
1083
  }
904
1084
  const accountId = resolveAccountId(refreshed.account, refreshed.accessToken);
905
1085
  if (!accountId) {
906
- accountManager.refundToken(refreshed.account, context.family, context.model);
1086
+ refundConsumedPoolToken(refreshed.account);
907
1087
  accountManager.recordFailure(refreshed.account, context.family, context.model);
908
1088
  accountManager.markAccountCoolingDown(refreshed.account, DEFAULT_AUTH_FAILURE_COOLDOWN_MS, "auth-failure");
909
1089
  // Persist the cooldown like every other cooldown branch in this loop
@@ -916,7 +1096,7 @@ async function handleRequestInner(state, req, res, traceId) {
916
1096
  transientAttempts += 1;
917
1097
  transientExhaustionReason = "auth-failure";
918
1098
  state.status.retries += 1;
919
- state.status.rotations += 1;
1099
+ noteRotation();
920
1100
  continue;
921
1101
  }
922
1102
  const accountIdentity = accountIdentityFromAccount(refreshed.account, state.now());
@@ -950,7 +1130,7 @@ async function handleRequestInner(state, req, res, traceId) {
950
1130
  code: "codex_runtime_rotation_transport_error",
951
1131
  error: transportError,
952
1132
  });
953
- accountManager.refundToken(refreshed.account, context.family, context.model);
1133
+ refundConsumedPoolToken(refreshed.account);
954
1134
  // A pre-header transport exception is a property of the network path,
955
1135
  // not of this account's credentials or quota, so it must NOT feed the
956
1136
  // account's circuit breaker / health tracker: that is what would
@@ -970,7 +1150,7 @@ async function handleRequestInner(state, req, res, traceId) {
970
1150
  transientAttempts += 1;
971
1151
  transientExhaustionReason = "network-error";
972
1152
  state.status.retries += 1;
973
- state.status.rotations += 1;
1153
+ noteRotation();
974
1154
  continue;
975
1155
  }
976
1156
  const quotaSnapshot = readQuotaSchedulerSnapshot(upstream.headers, upstream.status, state.now());
@@ -992,7 +1172,7 @@ async function handleRequestInner(state, req, res, traceId) {
992
1172
  transientAttempts += 1;
993
1173
  transientExhaustionReason = "rate-limit";
994
1174
  state.status.retries += 1;
995
- state.status.rotations += 1;
1175
+ noteRotation();
996
1176
  continue;
997
1177
  }
998
1178
  if (upstream.status === 402 || upstream.status === HTTP_STATUS.FORBIDDEN) {
@@ -1001,7 +1181,7 @@ async function handleRequestInner(state, req, res, traceId) {
1001
1181
  if (isWorkspaceDisabledError(upstream.status, errorCode, bodyText)) {
1002
1182
  const accountWasEnabled = accountManager.getAccountByIndex(refreshed.account.index)?.enabled !==
1003
1183
  false;
1004
- accountManager.refundToken(refreshed.account, context.family, context.model);
1184
+ refundConsumedPoolToken(refreshed.account);
1005
1185
  if (accountWasEnabled) {
1006
1186
  accountManager.recordFailure(refreshed.account, context.family, context.model);
1007
1187
  accountManager.setAccountEnabled(refreshed.account.index, false);
@@ -1010,7 +1190,7 @@ async function handleRequestInner(state, req, res, traceId) {
1010
1190
  state.sessionAffinityStore?.forgetSession(context.sessionKey);
1011
1191
  exhaustionReason = "deactivated";
1012
1192
  state.status.retries += 1;
1013
- state.status.rotations += 1;
1193
+ noteRotation();
1014
1194
  continue;
1015
1195
  }
1016
1196
  if (isThreadGoalRequest && isThreadGoalFallbackStatus(upstream.status)) {
@@ -1080,7 +1260,7 @@ async function handleRequestInner(state, req, res, traceId) {
1080
1260
  }
1081
1261
  if (upstream.status === HTTP_STATUS.UNAUTHORIZED) {
1082
1262
  const bodyText = await readErrorBody(upstream, state.streamStallTimeoutMs);
1083
- accountManager.refundToken(refreshed.account, context.family, context.model);
1263
+ refundConsumedPoolToken(refreshed.account);
1084
1264
  accountManager.recordFailure(refreshed.account, context.family, context.model);
1085
1265
  if (isTokenInvalidationError(bodyText)) {
1086
1266
  // The upstream explicitly revoked this OAuth token. Applying a long
@@ -1116,12 +1296,12 @@ async function handleRequestInner(state, req, res, traceId) {
1116
1296
  transientAttempts += 1;
1117
1297
  transientExhaustionReason = "auth-failure";
1118
1298
  state.status.retries += 1;
1119
- state.status.rotations += 1;
1299
+ noteRotation();
1120
1300
  continue;
1121
1301
  }
1122
1302
  if (upstream.status >= 500) {
1123
1303
  await readErrorBody(upstream, state.streamStallTimeoutMs);
1124
- accountManager.refundToken(refreshed.account, context.family, context.model);
1304
+ refundConsumedPoolToken(refreshed.account);
1125
1305
  accountManager.recordFailure(refreshed.account, context.family, context.model);
1126
1306
  accountManager.markAccountCoolingDown(refreshed.account, state.serverErrorCooldownMs, "server-error");
1127
1307
  accountManager.saveToDiskDebounced();
@@ -1129,7 +1309,7 @@ async function handleRequestInner(state, req, res, traceId) {
1129
1309
  transientAttempts += 1;
1130
1310
  transientExhaustionReason = "server-error";
1131
1311
  state.status.retries += 1;
1132
- state.status.rotations += 1;
1312
+ noteRotation();
1133
1313
  continue;
1134
1314
  }
1135
1315
  if (isThreadGoalRequest && upstream.status >= 400) {
@@ -1196,10 +1376,31 @@ async function handleRequestInner(state, req, res, traceId) {
1196
1376
  accountManager.markAccountCoolingDown(refreshed.account, state.networkErrorCooldownMs, "network-error");
1197
1377
  state.sessionAffinityStore?.forgetSession(context.sessionKey);
1198
1378
  accountManager.saveToDiskDebounced();
1199
- }, state.streamStallTimeoutMs, usageScanner.push);
1379
+ }, state.streamStallTimeoutMs, usageScanner.push, budgetAdvisory.level === "soft"
1380
+ ? buildContextBudgetHeaders(budgetAdvisory)
1381
+ : undefined);
1200
1382
  // A stream that broke mid-flight still bills for whatever the upstream
1201
1383
  // reported before the break, so record the counts on both outcomes.
1202
1384
  const usageTokens = usageScanner.result();
1385
+ // Responses is mostly stateless (store=false): each successful turn's
1386
+ // input_tokens reflects the full conversation resent this call, so it
1387
+ // doubles as a live read of the session's current context size. Record
1388
+ // it even on a broken stream, matching the ledger's own choice above —
1389
+ // the tokens upstream billed for were still resent as full context.
1390
+ //
1391
+ // input + output, NOT total: `usageTokens.totalTokens` is the
1392
+ // provider's `total_tokens`, which also counts `reasoning_tokens`.
1393
+ // Reasoning is not carried into the next turn's input, so counting
1394
+ // it here inflates the measurement by this turn's thinking budget
1395
+ // against a deliberately tight threshold. `outputTokens` is already
1396
+ // reasoning-stripped (lib/usage/usage-extraction.ts).
1397
+ if (usageTokens && context.stableSessionKey && context.model) {
1398
+ state.contextBudgetGuard.update(context.stableSessionKey, {
1399
+ model: context.model,
1400
+ contextTokens: usageTokens.inputTokens + usageTokens.outputTokens,
1401
+ updatedAt: state.now(),
1402
+ });
1403
+ }
1203
1404
  await usageRecorder.record({
1204
1405
  outcome: forwarded ? "success" : "failure",
1205
1406
  statusCode: upstream.status,
@@ -1209,8 +1410,23 @@ async function handleRequestInner(state, req, res, traceId) {
1209
1410
  });
1210
1411
  return;
1211
1412
  }
1413
+ if (isPinned &&
1414
+ typeof pinnedIndex === "number" &&
1415
+ transientAttempts >= transientAttemptLimit) {
1416
+ const pinnedAccount = accountManager.getAccountByIndex(pinnedIndex);
1417
+ const liveReason = pinnedAccount
1418
+ ? accountManager.getManagedAccountRuntimeSkipReason(pinnedAccount, context.family, context.model)
1419
+ : "missing";
1420
+ const finalAttemptReason = transientExhaustionReason === "rate-limit"
1421
+ ? "rate-limited"
1422
+ : transientExhaustionReason;
1423
+ const finalPinnedReason = liveReason ?? finalAttemptReason;
1424
+ if (finalPinnedReason !== null) {
1425
+ accountSkipReasons.set(pinnedIndex, finalPinnedReason);
1426
+ }
1427
+ }
1212
1428
  if (transientAttempts >= transientAttemptLimit &&
1213
- attemptedIndexes.size < accountCount) {
1429
+ (isPinned || attemptedIndexes.size < accountCount)) {
1214
1430
  exhaustionReason = "budget";
1215
1431
  }
1216
1432
  else if (exhaustionReason === "deactivated" &&
@@ -1237,17 +1453,13 @@ async function handleRequestInner(state, req, res, traceId) {
1237
1453
  // so advertising a record's expiry would invite a retry into another
1238
1454
  // 503.
1239
1455
  //
1240
- // The recorded reason alone is not enough to detect one. It is the
1241
- // SELECTION verdict, and this request can make the pin permanently
1242
- // unselectable after selection already ran: a workspace-disabled
1243
- // 402/403 calls setAccountEnabled(index, false) above and then
1244
- // continues, so the next pass records "already-attempted" and the
1245
- // disable never surfaces. With a breaker tripped by the same failure
1246
- // the 503 would then advertise the circuit's ~30s reset for an
1247
- // account no timer will ever re-admit. Re-read the pin's CURRENT
1248
- // runtime state so a permanent blocker cannot hide behind the
1249
- // verdict; the recorded reason still covers the selection-only
1250
- // verdicts ("missing", "policy-blocked") that state cannot express.
1456
+ // The recorded reason alone is not enough to detect every concurrent
1457
+ // state change: it is the most recent selection/attempt verdict, while
1458
+ // this request or another one can make the pin permanently unselectable
1459
+ // after that verdict was recorded. Re-read the pin's CURRENT runtime
1460
+ // state so a permanent blocker cannot hide behind an earlier transient
1461
+ // failure; the recorded reason still covers selection-only verdicts
1462
+ // ("missing", "policy-blocked") that state cannot express.
1251
1463
  const pinnedCurrentSkipReason = pinnedAccount === null
1252
1464
  ? null
1253
1465
  : accountManager.getManagedAccountRuntimeSkipReason(pinnedAccount, context.family, context.model);
@@ -1255,13 +1467,10 @@ async function handleRequestInner(state, req, res, traceId) {
1255
1467
  PINNED_PERMANENT_SKIP_REASONS.has(pinnedSkipReason)) ||
1256
1468
  (pinnedCurrentSkipReason !== null &&
1257
1469
  PINNED_PERMANENT_SKIP_REASONS.has(pinnedCurrentSkipReason));
1258
- // Otherwise recovery comes from the pinned account's persisted state, not the
1259
- // recorded skip reason: a direct 429/cooldown on the pinned account
1260
- // reaches this 503 as "already-attempted" (the retry loop's selection
1261
- // verdict) while the record it just wrote is what actually bounds
1262
- // recovery — and with several overlapping records the account stays
1263
- // skipped until the LAST one expires, so the latest bound is the one
1264
- // worth advertising.
1470
+ // Otherwise recovery comes from the pinned account's persisted state,
1471
+ // not merely the recorded skip token. With several overlapping records
1472
+ // the account stays skipped until the LAST one expires, so the latest
1473
+ // bound is the one worth advertising.
1265
1474
  //
1266
1475
  // One clock for the whole body. The recovery bound and the
1267
1476
  // rate-limit bound are compared against each other below, so
@@ -1296,9 +1505,8 @@ async function handleRequestInner(state, req, res, traceId) {
1296
1505
  pinSource: typeof state.forcedAccountIndex === "number" ? "forced" : "manual",
1297
1506
  resetAtMs: pinnedResetAtMs,
1298
1507
  recoveryBound,
1299
- // The recorded verdict above is the retry loop's
1300
- // "already-attempted" on the dominant path; the re-read runtime
1301
- // state is what the operator-facing sentence should describe.
1508
+ // Re-read runtime state for the operator-facing sentence so a
1509
+ // concurrent or later blocker can refine the recorded verdict.
1302
1510
  currentSkipReason: pinnedCurrentSkipReason,
1303
1511
  now: evaluatedAtMs,
1304
1512
  });
@@ -71,6 +71,10 @@ export declare const PluginConfigSchema: z.ZodObject<{
71
71
  preemptiveQuotaRemainingPercent5h: z.ZodOptional<z.ZodNumber>;
72
72
  preemptiveQuotaRemainingPercent7d: z.ZodOptional<z.ZodNumber>;
73
73
  preemptiveQuotaMaxDeferralMs: z.ZodOptional<z.ZodNumber>;
74
+ contextBudgetGuardEnabled: z.ZodOptional<z.ZodBoolean>;
75
+ contextBudgetGuardSoftPercent: z.ZodOptional<z.ZodNumber>;
76
+ contextBudgetGuardHardPercent: z.ZodOptional<z.ZodNumber>;
77
+ contextBudgetGuardModelWindowOverrides: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodNumber>>;
74
78
  routingMutex: z.ZodOptional<z.ZodEnum<{
75
79
  enabled: "enabled";
76
80
  legacy: "legacy";
@@ -1 +1 @@
1
- {"version":3,"file":"schemas.d.ts","sourceRoot":"","sources":["../../lib/schemas.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"schemas.d.ts","sourceRoot":"","sources":["../../lib/schemas.ts"],"names":[],"mappings":"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"}
@@ -76,6 +76,16 @@ export const PluginConfigSchema = z.object({
76
76
  preemptiveQuotaRemainingPercent5h: z.number().min(0).max(100).optional(),
77
77
  preemptiveQuotaRemainingPercent7d: z.number().min(0).max(100).optional(),
78
78
  preemptiveQuotaMaxDeferralMs: z.number().min(1_000).optional(),
79
+ contextBudgetGuardEnabled: z.boolean().optional(),
80
+ contextBudgetGuardSoftPercent: z.number().min(0).max(100).optional(),
81
+ // Floor of 10, not 0: a hard threshold at 0 is satisfied by every recorded
82
+ // snapshot, so it would pause every session from its first turn. A value
83
+ // below the floor is dropped by the per-field sanitizer and the default (69)
84
+ // applies.
85
+ contextBudgetGuardHardPercent: z.number().min(10).max(100).optional(),
86
+ contextBudgetGuardModelWindowOverrides: z
87
+ .record(z.string(), z.number().positive())
88
+ .optional(),
79
89
  routingMutex: z.enum(["enabled", "legacy"]).optional(),
80
90
  schedulingStrategy: z.enum(["hybrid", "sequential"]).optional(),
81
91
  });
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Shared builder for locally-answered Responses API replies.
3
+ *
4
+ * Both the reactive context-overflow handler (`lib/context-overflow.ts`) and
5
+ * the proactive context budget guard (`lib/context-budget-response.ts`) need
6
+ * to hand the caller a plugin-authored notice instead of forwarding to
7
+ * upstream, in the exact SSE dialect the Codex CLI / Responses client
8
+ * expects. Extracted here so both answer with one audited SSE shape rather
9
+ * than two independently-maintained copies of it.
10
+ */
11
+ export interface SyntheticSseResponseOptions {
12
+ /** Model id to echo back in the synthetic response, or "unknown". */
13
+ model: string;
14
+ /** Plain-text notice shown to the user as the assistant's message. */
15
+ message: string;
16
+ /** Distinguishes id prefixes across call sites, e.g. "overflow", "context_budget_pause". */
17
+ idPrefix: string;
18
+ /** Value for the `X-Codex-Plugin-Error-Type` response header. */
19
+ errorType: string;
20
+ }
21
+ /**
22
+ * Build a synthetic OpenAI Responses API SSE stream carrying a single
23
+ * assistant text message, then a terminal `response.completed`.
24
+ *
25
+ * Emits `response.*` events — the dialect the Codex CLI client and this
26
+ * package's own `convertSseToJson` parser speak, not the Anthropic Messages
27
+ * API dialect. Returns 200 OK so the host session never locks on a 4xx for a
28
+ * notice the plugin itself generated.
29
+ */
30
+ export declare function createSyntheticSseResponse(options: SyntheticSseResponseOptions): Response;
31
+ //# sourceMappingURL=synthetic-response.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"synthetic-response.d.ts","sourceRoot":"","sources":["../../lib/synthetic-response.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAMH,MAAM,WAAW,2BAA2B;IAC3C,qEAAqE;IACrE,KAAK,EAAE,MAAM,CAAC;IACd,sEAAsE;IACtE,OAAO,EAAE,MAAM,CAAC;IAChB,4FAA4F;IAC5F,QAAQ,EAAE,MAAM,CAAC;IACjB,iEAAiE;IACjE,SAAS,EAAE,MAAM,CAAC;CAClB;AAED;;;;;;;;GAQG;AACH,wBAAgB,0BAA0B,CACzC,OAAO,EAAE,2BAA2B,GAClC,QAAQ,CA+DV"}