codex-multi-auth 2.9.1 → 2.10.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 (66) hide show
  1. package/.codex-plugin/plugin.json +1 -1
  2. package/README.md +2 -1
  3. package/config/schema/config.schema.json +23 -0
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +62 -2
  6. package/dist/lib/accounts.d.ts +25 -1
  7. package/dist/lib/accounts.d.ts.map +1 -1
  8. package/dist/lib/accounts.js +26 -6
  9. package/dist/lib/codex-manager/backend-settings-helpers.d.ts.map +1 -1
  10. package/dist/lib/codex-manager/backend-settings-helpers.js +10 -1
  11. package/dist/lib/codex-manager/backend-settings-schema.d.ts +18 -0
  12. package/dist/lib/codex-manager/backend-settings-schema.d.ts.map +1 -1
  13. package/dist/lib/codex-manager/backend-settings-schema.js +19 -0
  14. package/dist/lib/codex-manager/experimental-settings-prompt.d.ts +1 -0
  15. package/dist/lib/codex-manager/experimental-settings-prompt.d.ts.map +1 -1
  16. package/dist/lib/codex-manager/experimental-settings-prompt.js +12 -0
  17. package/dist/lib/codex-manager/experimental-settings-schema.d.ts +2 -0
  18. package/dist/lib/codex-manager/experimental-settings-schema.d.ts.map +1 -1
  19. package/dist/lib/codex-manager/experimental-settings-schema.js +2 -0
  20. package/dist/lib/config.d.ts +29 -0
  21. package/dist/lib/config.d.ts.map +1 -1
  22. package/dist/lib/config.js +88 -0
  23. package/dist/lib/context-budget/model-context-windows.d.ts +25 -0
  24. package/dist/lib/context-budget/model-context-windows.d.ts.map +1 -0
  25. package/dist/lib/context-budget/model-context-windows.js +118 -0
  26. package/dist/lib/context-budget-guard.d.ts +100 -0
  27. package/dist/lib/context-budget-guard.d.ts.map +1 -0
  28. package/dist/lib/context-budget-guard.js +183 -0
  29. package/dist/lib/context-budget-response.d.ts +25 -0
  30. package/dist/lib/context-budget-response.d.ts.map +1 -0
  31. package/dist/lib/context-budget-response.js +57 -0
  32. package/dist/lib/context-overflow.d.ts.map +1 -1
  33. package/dist/lib/context-overflow.js +5 -60
  34. package/dist/lib/request/rate-limit-decision.d.ts +4 -5
  35. package/dist/lib/request/rate-limit-decision.d.ts.map +1 -1
  36. package/dist/lib/request/rate-limit-decision.js +33 -4
  37. package/dist/lib/request/response-metadata.d.ts.map +1 -1
  38. package/dist/lib/request/response-metadata.js +1 -0
  39. package/dist/lib/request/stream-failover-runtime.d.ts +3 -1
  40. package/dist/lib/request/stream-failover-runtime.d.ts.map +1 -1
  41. package/dist/lib/request/stream-failover-runtime.js +7 -2
  42. package/dist/lib/runtime/context-budget-settings.d.ts +13 -0
  43. package/dist/lib/runtime/context-budget-settings.d.ts.map +1 -0
  44. package/dist/lib/runtime/context-budget-settings.js +8 -0
  45. package/dist/lib/runtime/loader-setup.d.ts +1 -0
  46. package/dist/lib/runtime/loader-setup.d.ts.map +1 -1
  47. package/dist/lib/runtime/loader-setup.js +1 -0
  48. package/dist/lib/runtime/rotation-account-selection.d.ts +23 -0
  49. package/dist/lib/runtime/rotation-account-selection.d.ts.map +1 -1
  50. package/dist/lib/runtime/rotation-account-selection.js +15 -6
  51. package/dist/lib/runtime/rotation-proxy-state.d.ts +2 -0
  52. package/dist/lib/runtime/rotation-proxy-state.d.ts.map +1 -1
  53. package/dist/lib/runtime/rotation-server-types.d.ts +11 -0
  54. package/dist/lib/runtime/rotation-server-types.d.ts.map +1 -1
  55. package/dist/lib/runtime-rotation-proxy.d.ts.map +1 -1
  56. package/dist/lib/runtime-rotation-proxy.js +251 -43
  57. package/dist/lib/schemas.d.ts +4 -0
  58. package/dist/lib/schemas.d.ts.map +1 -1
  59. package/dist/lib/schemas.js +10 -0
  60. package/dist/lib/synthetic-response.d.ts +31 -0
  61. package/dist/lib/synthetic-response.d.ts.map +1 -0
  62. package/dist/lib/synthetic-response.js +79 -0
  63. package/dist/lib/ui/ui-copy.d.ts +2 -1
  64. package/dist/lib/ui/ui-copy.d.ts.map +1 -1
  65. package/dist/lib/ui/ui-copy.js +2 -1
  66. package/package.json +2 -2
@@ -171,6 +171,10 @@ export const DEFAULT_PLUGIN_CONFIG = {
171
171
  preemptiveQuotaRemainingPercent5h: 5,
172
172
  preemptiveQuotaRemainingPercent7d: 5,
173
173
  preemptiveQuotaMaxDeferralMs: 2 * 60 * 60_000,
174
+ contextBudgetGuardEnabled: false,
175
+ contextBudgetGuardSoftPercent: 65,
176
+ contextBudgetGuardHardPercent: 69,
177
+ contextBudgetGuardModelWindowOverrides: {},
174
178
  routingMutex: "legacy",
175
179
  schedulingStrategy: "hybrid",
176
180
  };
@@ -834,6 +838,14 @@ function resolveNumberSetting(envName, configValue, defaultValue, options) {
834
838
  logConfigWarnOnce(`Ignoring invalid numeric env ${envName}. Expected a finite number.`);
835
839
  }
836
840
  const candidate = envValue ?? configValue ?? defaultValue;
841
+ // Math.max/Math.min propagate NaN, so a non-finite candidate would be
842
+ // returned as-is and every threshold comparison against it reads false --
843
+ // a setting that silently disables whatever it gates. parseNumberEnv already
844
+ // rejects non-finite env values and zod rejects NaN on the config field, so
845
+ // this is a boundary guard for the ~20 settings that share this helper
846
+ // rather than a reachable path through loadPluginConfig.
847
+ if (!Number.isFinite(candidate))
848
+ return defaultValue;
837
849
  const min = options?.min ?? Number.NEGATIVE_INFINITY;
838
850
  const max = options?.max ?? Number.POSITIVE_INFINITY;
839
851
  return Math.max(min, Math.min(max, candidate));
@@ -1295,6 +1307,62 @@ export function getPreemptiveQuotaRemainingPercent7d(pluginConfig) {
1295
1307
  export function getPreemptiveQuotaMaxDeferralMs(pluginConfig) {
1296
1308
  return resolveNumberSetting("CODEX_AUTH_PREEMPTIVE_QUOTA_MAX_DEFERRAL_MS", pluginConfig.preemptiveQuotaMaxDeferralMs, 2 * 60 * 60_000, { min: 1_000 });
1297
1309
  }
1310
+ /**
1311
+ * Whether the (experimental, opt-in) context budget guard is enabled.
1312
+ *
1313
+ * Ships disabled: pausing a session before it hits the context window is a
1314
+ * staged behavior change, not a default-on safety net like preemptive quota
1315
+ * deferral, so an explicit opt-in is required.
1316
+ */
1317
+ export function getContextBudgetGuardEnabled(pluginConfig) {
1318
+ return resolveBooleanSetting("CODEX_AUTH_CONTEXT_BUDGET_GUARD_ENABLED", pluginConfig.contextBudgetGuardEnabled, false);
1319
+ }
1320
+ /** Percent of the effective context window at which the guard's non-blocking advisory fires. */
1321
+ export function getContextBudgetGuardSoftPercent(pluginConfig) {
1322
+ return resolveNumberSetting("CODEX_AUTH_CONTEXT_BUDGET_SOFT_PCT", pluginConfig.contextBudgetGuardSoftPercent, 65, { min: 0, max: 100 });
1323
+ }
1324
+ /**
1325
+ * Percent of the effective context window at which the guard pauses the next
1326
+ * request.
1327
+ *
1328
+ * Floored at 10, not 0. `resolveNumberSetting` clamps out-of-range values
1329
+ * instead of rejecting them, so a configured (or env-supplied) `0` would be a
1330
+ * legal threshold that every recorded snapshot clears — pausing every session
1331
+ * from its first turn on. The guard applies the same floor in `configure()`
1332
+ * so a directly constructed instance cannot be set below it either.
1333
+ */
1334
+ export function getContextBudgetGuardHardPercent(pluginConfig) {
1335
+ return resolveNumberSetting("CODEX_AUTH_CONTEXT_BUDGET_HARD_PCT", pluginConfig.contextBudgetGuardHardPercent, 69, { min: 10, max: 100 });
1336
+ }
1337
+ /**
1338
+ * User-supplied context-window sizes, keyed by normalized model id, that
1339
+ * always take priority over this package's own best-effort estimates (see
1340
+ * `lib/context-budget/model-context-windows.ts`). Settings-only: there is no
1341
+ * environment override, the same as `unsupportedCodexFallbackChain`, since a
1342
+ * per-model token count is not a reasonable single env var.
1343
+ */
1344
+ export function getContextBudgetGuardModelWindowOverrides(pluginConfig) {
1345
+ const overrides = pluginConfig.contextBudgetGuardModelWindowOverrides;
1346
+ if (!overrides || typeof overrides !== "object") {
1347
+ return {};
1348
+ }
1349
+ const normalized = {};
1350
+ for (const [rawModel, rawValue] of Object.entries(overrides)) {
1351
+ const model = rawModel.trim().toLowerCase();
1352
+ if (!model)
1353
+ continue;
1354
+ if (typeof rawValue !== "number" || !Number.isFinite(rawValue))
1355
+ continue;
1356
+ // Floor first: the schema accepts any positive number, so a sub-1 value
1357
+ // passes a `rawValue > 0` check and then floors to 0, which is stored as
1358
+ // a window of zero tokens and silently ignored downstream.
1359
+ const tokens = Math.floor(rawValue);
1360
+ if (tokens <= 0)
1361
+ continue;
1362
+ normalized[model] = tokens;
1363
+ }
1364
+ return normalized;
1365
+ }
1298
1366
  const ROUTING_MUTEX_MODES = new Set(["enabled", "legacy"]);
1299
1367
  /**
1300
1368
  * Resolve the routing-mutex mode (PR-N / R4).
@@ -1649,6 +1717,26 @@ const CONFIG_EXPLAIN_ENTRIES = [
1649
1717
  envNames: ["CODEX_AUTH_PREEMPTIVE_QUOTA_MAX_DEFERRAL_MS"],
1650
1718
  getValue: getPreemptiveQuotaMaxDeferralMs,
1651
1719
  },
1720
+ {
1721
+ key: "contextBudgetGuardEnabled",
1722
+ envNames: ["CODEX_AUTH_CONTEXT_BUDGET_GUARD_ENABLED"],
1723
+ getValue: getContextBudgetGuardEnabled,
1724
+ },
1725
+ {
1726
+ key: "contextBudgetGuardSoftPercent",
1727
+ envNames: ["CODEX_AUTH_CONTEXT_BUDGET_SOFT_PCT"],
1728
+ getValue: getContextBudgetGuardSoftPercent,
1729
+ },
1730
+ {
1731
+ key: "contextBudgetGuardHardPercent",
1732
+ envNames: ["CODEX_AUTH_CONTEXT_BUDGET_HARD_PCT"],
1733
+ getValue: getContextBudgetGuardHardPercent,
1734
+ },
1735
+ {
1736
+ key: "contextBudgetGuardModelWindowOverrides",
1737
+ envNames: [],
1738
+ getValue: getContextBudgetGuardModelWindowOverrides,
1739
+ },
1652
1740
  // config-01/config-07: these three live settings were missing from the explain
1653
1741
  // report, so `config explain` silently under-reported the effective config. A
1654
1742
  // parity test (test/config-explain.test.ts) now guards this class of drift.
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Routable models this guard consciously does not estimate a window for.
3
+ * `getEffectiveContextWindow` returns `null` for these absent an override,
4
+ * and the guard treats `null` as "cannot evaluate; skip" — never a guessed
5
+ * percentage. The 5.6 family ships with materially different per-tier
6
+ * behavior (see the v2.5.0 release notes) closely enough to launch that no
7
+ * estimate here has been reviewed for it yet.
8
+ */
9
+ export declare const UNESTIMATED_ROUTABLE_MODELS: readonly ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"];
10
+ export interface EffectiveContextWindow {
11
+ tokens: number;
12
+ source: "override" | "estimate";
13
+ }
14
+ /**
15
+ * Resolve the context-window size to evaluate a session's budget against.
16
+ *
17
+ * Precedence: an explicit override always wins, even for a model this file
18
+ * has never heard of — the whole point of the override setting is to let a
19
+ * user correct or extend past this file's guesses. Failing that, fall back
20
+ * to the estimate table. Returns `null` when neither source has a value, so
21
+ * the guard can no-op rather than evaluate against a fabricated number.
22
+ */
23
+ export declare function getEffectiveContextWindow(model: string | null | undefined, overrides: Record<string, number> | undefined): EffectiveContextWindow | null;
24
+ export declare function listEstimatedModelContextWindows(): Record<string, number>;
25
+ //# sourceMappingURL=model-context-windows.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"model-context-windows.d.ts","sourceRoot":"","sources":["../../../lib/context-budget/model-context-windows.ts"],"names":[],"mappings":"AAyCA;;;;;;;GAOG;AACH,eAAO,MAAM,2BAA2B,2DAI9B,CAAC;AAgCX,MAAM,WAAW,sBAAsB;IACtC,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,UAAU,GAAG,UAAU,CAAC;CAChC;AAED;;;;;;;;GAQG;AACH,wBAAgB,yBAAyB,CACxC,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EAChC,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,SAAS,GAC3C,sBAAsB,GAAG,IAAI,CA0B/B;AAED,wBAAgB,gCAAgC,IAAI,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAEzE"}
@@ -0,0 +1,118 @@
1
+ import { getNormalizedModel } from "../request/helpers/model-map.js";
2
+ /**
3
+ * Context-window size estimates for the Context Budget Guard.
4
+ *
5
+ * Unlike `lib/usage/pricing.ts`, the numbers below are NOT published,
6
+ * contractual facts. Per `docs/releases/v2.5.0.md` ("Where the model facts
7
+ * came from"), the context window OpenAI's published API docs advertise is
8
+ * for the API surface, not the ChatGPT Codex backend this wrapper actually
9
+ * talks to — and the docs pages don't even agree with each other. Treating a
10
+ * hardcoded number here as ground truth would repeat that exact mistake for
11
+ * a safety feature, which is worse than not shipping the feature at all.
12
+ *
13
+ * So this table is a fallback starting point ONLY. `getEffectiveContextWindow`
14
+ * always prefers a caller-supplied override (from
15
+ * `contextBudgetGuardModelWindowOverrides`, the setting a user fills in once
16
+ * they know their real ceiling) over anything listed here.
17
+ */
18
+ /**
19
+ * Best-effort estimates, in tokens. Deliberately conservative: an estimate
20
+ * that is too LOW makes the guard fire early (annoying, safe); an estimate
21
+ * that is too HIGH makes the guard fire late or never (silent, unsafe). When
22
+ * in doubt about a model's real ceiling, leave it out of this table and add
23
+ * it to UNESTIMATED_ROUTABLE_MODELS instead.
24
+ */
25
+ const ESTIMATED_MODEL_CONTEXT_WINDOWS = {
26
+ "gpt-5.1": 260_000,
27
+ "gpt-5.2": 260_000,
28
+ "gpt-5.2-pro": 260_000,
29
+ "gpt-5.3-codex": 260_000,
30
+ "gpt-5.4": 260_000,
31
+ "gpt-5.4-mini": 260_000,
32
+ "gpt-5.4-nano": 260_000,
33
+ "gpt-5.4-pro": 260_000,
34
+ "gpt-5.5": 260_000,
35
+ "gpt-5.5-pro": 260_000,
36
+ "gpt-5-mini": 260_000,
37
+ "gpt-5-nano": 260_000,
38
+ };
39
+ /**
40
+ * Routable models this guard consciously does not estimate a window for.
41
+ * `getEffectiveContextWindow` returns `null` for these absent an override,
42
+ * and the guard treats `null` as "cannot evaluate; skip" — never a guessed
43
+ * percentage. The 5.6 family ships with materially different per-tier
44
+ * behavior (see the v2.5.0 release notes) closely enough to launch that no
45
+ * estimate here has been reviewed for it yet.
46
+ */
47
+ export const UNESTIMATED_ROUTABLE_MODELS = [
48
+ "gpt-5.6-sol",
49
+ "gpt-5.6-terra",
50
+ "gpt-5.6-luna",
51
+ ];
52
+ function normalizeModelName(model) {
53
+ const trimmed = model?.trim().toLowerCase();
54
+ return trimmed && trimmed.length > 0 ? trimmed : null;
55
+ }
56
+ /**
57
+ * Model ids to try, in order, against the override map and the estimate table.
58
+ *
59
+ * The guard is fed the model string exactly as the client sent it: the
60
+ * rotation proxy is a pass-through and `buildResponsesRequestContext` copies
61
+ * `body.model` verbatim. That raw string is very often NOT a key of this
62
+ * file's table — Codex CLI's own default is `gpt-5-codex`, and every
63
+ * reasoning-suffixed alias (`gpt-5.3-codex-high`, …) is an alias-map entry
64
+ * rather than a catalog model. Looking up only the raw string made the guard
65
+ * silently return "no window, skip" for the most common model in the product.
66
+ *
67
+ * So: try the raw (lowercased) string first, so a user override keyed exactly
68
+ * the way they type the model still wins, then the catalog's canonical id for
69
+ * it. `getNormalizedModel` is deliberately the exact/alias-only resolver —
70
+ * `resolveNormalizedModel` falls back to `DEFAULT_MODEL` for anything it does
71
+ * not recognize, which would hand an unknown model gpt-5.5's window and
72
+ * evaluate a real session against a fabricated number.
73
+ */
74
+ function windowLookupCandidates(model) {
75
+ const raw = normalizeModelName(model);
76
+ if (!raw)
77
+ return [];
78
+ const canonical = normalizeModelName(getNormalizedModel(raw));
79
+ return canonical && canonical !== raw ? [raw, canonical] : [raw];
80
+ }
81
+ /**
82
+ * Resolve the context-window size to evaluate a session's budget against.
83
+ *
84
+ * Precedence: an explicit override always wins, even for a model this file
85
+ * has never heard of — the whole point of the override setting is to let a
86
+ * user correct or extend past this file's guesses. Failing that, fall back
87
+ * to the estimate table. Returns `null` when neither source has a value, so
88
+ * the guard can no-op rather than evaluate against a fabricated number.
89
+ */
90
+ export function getEffectiveContextWindow(model, overrides) {
91
+ const candidates = windowLookupCandidates(model);
92
+ if (candidates.length === 0)
93
+ return null;
94
+ for (const candidate of candidates) {
95
+ const override = overrides?.[candidate];
96
+ if (typeof override !== "number" || !Number.isFinite(override))
97
+ continue;
98
+ // Floor BEFORE the positivity check, not after. Checking `override > 0`
99
+ // on the raw value admits anything in (0, 1), which then floors to 0 and
100
+ // returns a zero-token window -- breaking this function's contract that a
101
+ // window it cannot resolve comes back as null, and handing any caller
102
+ // that trusts it a division by zero.
103
+ const tokens = Math.floor(override);
104
+ if (tokens > 0) {
105
+ return { tokens, source: "override" };
106
+ }
107
+ }
108
+ for (const candidate of candidates) {
109
+ const estimate = ESTIMATED_MODEL_CONTEXT_WINDOWS[candidate];
110
+ if (typeof estimate === "number") {
111
+ return { tokens: estimate, source: "estimate" };
112
+ }
113
+ }
114
+ return null;
115
+ }
116
+ export function listEstimatedModelContextWindows() {
117
+ return { ...ESTIMATED_MODEL_CONTEXT_WINDOWS };
118
+ }
@@ -0,0 +1,100 @@
1
+ export interface ContextBudgetSnapshot {
2
+ model: string;
3
+ /**
4
+ * Tokens the NEXT turn on this session is expected to resend as context:
5
+ * this turn's `input_tokens` plus the part of its output that becomes
6
+ * conversation history. Deliberately NOT `total_tokens`, which also counts
7
+ * `reasoning_tokens` that the Responses API drops rather than resends —
8
+ * charging those against the window inflates every measurement by this
9
+ * turn's thinking budget and fires the guard early on long-reasoning turns.
10
+ */
11
+ contextTokens: number;
12
+ updatedAt: number;
13
+ }
14
+ export interface ContextBudgetOptions {
15
+ enabled?: boolean;
16
+ softPercent?: number;
17
+ hardPercent?: number;
18
+ modelWindowOverrides?: Record<string, number>;
19
+ }
20
+ interface ContextBudgetPressure {
21
+ percent: number;
22
+ contextTokens: number;
23
+ windowTokens: number;
24
+ windowSource: "override" | "estimate";
25
+ model: string;
26
+ }
27
+ export type ContextBudgetAdvisory = {
28
+ level: "ok";
29
+ } | ({
30
+ level: "soft";
31
+ } & ContextBudgetPressure) | ({
32
+ level: "hard";
33
+ } & ContextBudgetPressure);
34
+ /**
35
+ * Tracks how full each session's context window is and decides, before the
36
+ * NEXT request on that session goes out, whether to nudge (soft) or pause
37
+ * (hard) the caller.
38
+ *
39
+ * Deliberately shaped like `PreemptiveQuotaScheduler`
40
+ * (`lib/preemptive-quota-scheduler.ts`): a per-key `Map` snapshot, a
41
+ * `configure()`/decision-getter split, and a lazy `prune()` on the same
42
+ * cadence. Same reason in both places — the decision needs to survive
43
+ * across the request that observed the usage and the next request that
44
+ * asks "are we still OK," and a scheduled sweep keeps memory bounded
45
+ * without a dedicated timer.
46
+ */
47
+ export declare class ContextBudgetGuard {
48
+ private readonly snapshots;
49
+ private enabled;
50
+ private softPercent;
51
+ private hardPercent;
52
+ private modelWindowOverrides;
53
+ private lastPruneAt;
54
+ constructor(options?: ContextBudgetOptions);
55
+ configure(options?: ContextBudgetOptions): void;
56
+ private maybePrune;
57
+ /**
58
+ * Record the latest known context size for a session, from that turn's usage.
59
+ *
60
+ * No-ops while disabled. The call sites sit on the hot path of every
61
+ * forwarded Responses turn, so a default-off install must not pay for a
62
+ * per-session map it will never read.
63
+ */
64
+ update(key: string, snapshot: ContextBudgetSnapshot): void;
65
+ /**
66
+ * Evaluate before forwarding the NEXT request on this session key.
67
+ *
68
+ * `requestModel` is the model that request will use, and it is what the
69
+ * window is resolved from -- NOT the model of the last recorded turn. The
70
+ * two differ whenever a session switches models, and then the snapshot's
71
+ * model is the wrong denominator: a session carrying 205k tokens is 79% of
72
+ * a 260k window and a fifth of a 1M one. Using the stale model also let
73
+ * the guard pause a request for a model it deliberately refuses to
74
+ * estimate, and print that stale model's name in the notice. Falls back to
75
+ * the snapshot's model only when the request declares none.
76
+ */
77
+ getAdvisory(key: string, now?: number, requestModel?: string | null): ContextBudgetAdvisory;
78
+ /** Drop sessions untouched for a while so the map does not grow unbounded. */
79
+ prune(now?: number): number;
80
+ /** Forget a single session's tracked usage, e.g. once it has visibly compacted. */
81
+ forget(key: string): void;
82
+ /**
83
+ * Call immediately after emitting a hard pause for `key`.
84
+ *
85
+ * The pause is synthetic: it short-circuits before the request is forwarded,
86
+ * so `update()` never runs for it and the recorded usage can never fall on
87
+ * its own. Left in place, the first crossing of the hard threshold would
88
+ * block that session forever — including the `/compact` turn the pause
89
+ * message tells the user to run, which travels on the same session key.
90
+ *
91
+ * Dropping the snapshot makes the pause one-shot per measurement: the next
92
+ * request is forwarded and re-measures. A session that really did compact
93
+ * comes back under the threshold and stays quiet; one that did not gets
94
+ * paused again on the turn after, so the warning still repeats while the
95
+ * session is over budget, without ever becoming a dead end.
96
+ */
97
+ noteHardPauseEmitted(key: string): void;
98
+ }
99
+ export {};
100
+ //# sourceMappingURL=context-budget-guard.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"context-budget-guard.d.ts","sourceRoot":"","sources":["../../lib/context-budget-guard.ts"],"names":[],"mappings":"AAEA,MAAM,WAAW,qBAAqB;IACrC,KAAK,EAAE,MAAM,CAAC;IACd;;;;;;;OAOG;IACH,aAAa,EAAE,MAAM,CAAC;IACtB,SAAS,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,oBAAoB;IACpC,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,oBAAoB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;CAC9C;AAED,UAAU,qBAAqB;IAC9B,OAAO,EAAE,MAAM,CAAC;IAChB,aAAa,EAAE,MAAM,CAAC;IACtB,YAAY,EAAE,MAAM,CAAC;IACrB,YAAY,EAAE,UAAU,GAAG,UAAU,CAAC;IACtC,KAAK,EAAE,MAAM,CAAC;CACd;AAED,MAAM,MAAM,qBAAqB,GAC9B;IAAE,KAAK,EAAE,IAAI,CAAA;CAAE,GACf,CAAC;IAAE,KAAK,EAAE,MAAM,CAAA;CAAE,GAAG,qBAAqB,CAAC,GAC3C,CAAC;IAAE,KAAK,EAAE,MAAM,CAAA;CAAE,GAAG,qBAAqB,CAAC,CAAC;AAuB/C;;;;;;;;;;;;GAYG;AACH,qBAAa,kBAAkB;IAC9B,OAAO,CAAC,QAAQ,CAAC,SAAS,CAA4C;IACtE,OAAO,CAAC,OAAO,CAAS;IACxB,OAAO,CAAC,WAAW,CAAwB;IAC3C,OAAO,CAAC,WAAW,CAAwB;IAC3C,OAAO,CAAC,oBAAoB,CAAqC;IACjE,OAAO,CAAC,WAAW,CAAK;gBAEZ,OAAO,GAAE,oBAAyB;IAI9C,SAAS,CAAC,OAAO,GAAE,oBAAyB,GAAG,IAAI;IAgCnD,OAAO,CAAC,UAAU;IAMlB;;;;;;OAMG;IACH,MAAM,CAAC,GAAG,EAAE,MAAM,EAAE,QAAQ,EAAE,qBAAqB,GAAG,IAAI;IAmB1D;;;;;;;;;;;OAWG;IACH,WAAW,CACV,GAAG,EAAE,MAAM,EACX,GAAG,SAAa,EAChB,YAAY,CAAC,EAAE,MAAM,GAAG,IAAI,GAC1B,qBAAqB;IAuCxB,8EAA8E;IAC9E,KAAK,CAAC,GAAG,SAAa,GAAG,MAAM;IAW/B,mFAAmF;IACnF,MAAM,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI;IAIzB;;;;;;;;;;;;;;OAcG;IACH,oBAAoB,CAAC,GAAG,EAAE,MAAM,GAAG,IAAI;CAGvC"}
@@ -0,0 +1,183 @@
1
+ import { getEffectiveContextWindow } from "./context-budget/model-context-windows.js";
2
+ const DEFAULT_SOFT_PERCENT = 65;
3
+ const DEFAULT_HARD_PERCENT = 69;
4
+ /**
5
+ * Lowest hard threshold that can describe a real budget.
6
+ *
7
+ * `getContextBudgetGuardHardPercent` clamps rather than rejects (that is what
8
+ * every other numeric setting does), so without a floor a configured `0` is a
9
+ * legal value that makes `percent >= hardPercent` true for every snapshot —
10
+ * every session pauses from its first recorded turn onward. Anything under
11
+ * 10% of a window is that same failure in slower motion, so clamp up to a
12
+ * value that at least can't fire on turn one of a normal session.
13
+ */
14
+ const MIN_HARD_PERCENT = 10;
15
+ /** Sessions idle longer than this are dropped on the next prune sweep. */
16
+ const SESSION_TTL_MS = 6 * 60 * 60_000;
17
+ const PRUNE_INTERVAL_MS = 60_000;
18
+ function clampInt(value, min, max) {
19
+ return Math.max(min, Math.min(max, Math.floor(value)));
20
+ }
21
+ /**
22
+ * Tracks how full each session's context window is and decides, before the
23
+ * NEXT request on that session goes out, whether to nudge (soft) or pause
24
+ * (hard) the caller.
25
+ *
26
+ * Deliberately shaped like `PreemptiveQuotaScheduler`
27
+ * (`lib/preemptive-quota-scheduler.ts`): a per-key `Map` snapshot, a
28
+ * `configure()`/decision-getter split, and a lazy `prune()` on the same
29
+ * cadence. Same reason in both places — the decision needs to survive
30
+ * across the request that observed the usage and the next request that
31
+ * asks "are we still OK," and a scheduled sweep keeps memory bounded
32
+ * without a dedicated timer.
33
+ */
34
+ export class ContextBudgetGuard {
35
+ snapshots = new Map();
36
+ enabled = false;
37
+ softPercent = DEFAULT_SOFT_PERCENT;
38
+ hardPercent = DEFAULT_HARD_PERCENT;
39
+ modelWindowOverrides;
40
+ lastPruneAt = 0;
41
+ constructor(options = {}) {
42
+ this.configure(options);
43
+ }
44
+ configure(options = {}) {
45
+ if (typeof options.enabled === "boolean") {
46
+ // Turning the guard off must also drop what it already tracked, or a
47
+ // disable/re-enable cycle re-evaluates against usage measured under
48
+ // the old settings and can pause a session on its first request back.
49
+ if (!options.enabled && this.enabled) {
50
+ this.snapshots.clear();
51
+ }
52
+ this.enabled = options.enabled;
53
+ }
54
+ if (typeof options.softPercent === "number" && Number.isFinite(options.softPercent)) {
55
+ this.softPercent = clampInt(options.softPercent, 0, 100);
56
+ }
57
+ if (typeof options.hardPercent === "number" && Number.isFinite(options.hardPercent)) {
58
+ this.hardPercent = clampInt(options.hardPercent, MIN_HARD_PERCENT, 100);
59
+ }
60
+ // A misconfigured soft >= hard must degrade safely (guard fires later
61
+ // than intended) rather than throw or produce a nonsensical "hard but
62
+ // not soft" state. Never silently widen hard to fix it — narrowing soft
63
+ // is the change least likely to surprise whoever set hard deliberately.
64
+ if (this.softPercent >= this.hardPercent) {
65
+ this.softPercent = Math.max(0, this.hardPercent - 1);
66
+ }
67
+ if (options.modelWindowOverrides) {
68
+ this.modelWindowOverrides = options.modelWindowOverrides;
69
+ }
70
+ }
71
+ maybePrune(now) {
72
+ if (now - this.lastPruneAt < PRUNE_INTERVAL_MS)
73
+ return;
74
+ this.prune(now);
75
+ this.lastPruneAt = now;
76
+ }
77
+ /**
78
+ * Record the latest known context size for a session, from that turn's usage.
79
+ *
80
+ * No-ops while disabled. The call sites sit on the hot path of every
81
+ * forwarded Responses turn, so a default-off install must not pay for a
82
+ * per-session map it will never read.
83
+ */
84
+ update(key, snapshot) {
85
+ if (!this.enabled || !key)
86
+ return;
87
+ // Both production call sites source contextTokens from
88
+ // lib/usage/usage-extraction.ts, whose nonNegativeInteger already returns
89
+ // 0 for anything non-finite, so this is a boundary guard rather than a
90
+ // reachable path today. It is here because percent is a division by the
91
+ // window: a non-finite count would put `Infinity%` in the pause notice a
92
+ // user reads, and every threshold comparison against NaN is false, which
93
+ // disables the guard silently.
94
+ if (!Number.isFinite(snapshot.contextTokens) ||
95
+ snapshot.contextTokens < 0) {
96
+ return;
97
+ }
98
+ this.maybePrune(snapshot.updatedAt || Date.now());
99
+ this.snapshots.set(key, snapshot);
100
+ }
101
+ /**
102
+ * Evaluate before forwarding the NEXT request on this session key.
103
+ *
104
+ * `requestModel` is the model that request will use, and it is what the
105
+ * window is resolved from -- NOT the model of the last recorded turn. The
106
+ * two differ whenever a session switches models, and then the snapshot's
107
+ * model is the wrong denominator: a session carrying 205k tokens is 79% of
108
+ * a 260k window and a fifth of a 1M one. Using the stale model also let
109
+ * the guard pause a request for a model it deliberately refuses to
110
+ * estimate, and print that stale model's name in the notice. Falls back to
111
+ * the snapshot's model only when the request declares none.
112
+ */
113
+ getAdvisory(key, now = Date.now(), requestModel) {
114
+ this.maybePrune(now);
115
+ if (!this.enabled || !key)
116
+ return { level: "ok" };
117
+ const snapshot = this.snapshots.get(key);
118
+ if (!snapshot)
119
+ return { level: "ok" };
120
+ const trimmedRequestModel = requestModel?.trim();
121
+ const evaluatedModel = trimmedRequestModel && trimmedRequestModel.length > 0
122
+ ? trimmedRequestModel
123
+ : snapshot.model;
124
+ const window = getEffectiveContextWindow(evaluatedModel, this.modelWindowOverrides);
125
+ if (!window || window.tokens <= 0)
126
+ return { level: "ok" };
127
+ const percent = (snapshot.contextTokens / window.tokens) * 100;
128
+ if (percent >= this.hardPercent) {
129
+ return {
130
+ level: "hard",
131
+ percent,
132
+ contextTokens: snapshot.contextTokens,
133
+ windowTokens: window.tokens,
134
+ windowSource: window.source,
135
+ model: evaluatedModel,
136
+ };
137
+ }
138
+ if (percent >= this.softPercent) {
139
+ return {
140
+ level: "soft",
141
+ percent,
142
+ contextTokens: snapshot.contextTokens,
143
+ windowTokens: window.tokens,
144
+ windowSource: window.source,
145
+ model: evaluatedModel,
146
+ };
147
+ }
148
+ return { level: "ok" };
149
+ }
150
+ /** Drop sessions untouched for a while so the map does not grow unbounded. */
151
+ prune(now = Date.now()) {
152
+ let removed = 0;
153
+ for (const [key, snapshot] of this.snapshots.entries()) {
154
+ if (now - snapshot.updatedAt > SESSION_TTL_MS) {
155
+ this.snapshots.delete(key);
156
+ removed += 1;
157
+ }
158
+ }
159
+ return removed;
160
+ }
161
+ /** Forget a single session's tracked usage, e.g. once it has visibly compacted. */
162
+ forget(key) {
163
+ this.snapshots.delete(key);
164
+ }
165
+ /**
166
+ * Call immediately after emitting a hard pause for `key`.
167
+ *
168
+ * The pause is synthetic: it short-circuits before the request is forwarded,
169
+ * so `update()` never runs for it and the recorded usage can never fall on
170
+ * its own. Left in place, the first crossing of the hard threshold would
171
+ * block that session forever — including the `/compact` turn the pause
172
+ * message tells the user to run, which travels on the same session key.
173
+ *
174
+ * Dropping the snapshot makes the pause one-shot per measurement: the next
175
+ * request is forwarded and re-measures. A session that really did compact
176
+ * comes back under the threshold and stays quiet; one that did not gets
177
+ * paused again on the turn after, so the warning still repeats while the
178
+ * session is over budget, without ever becoming a dead end.
179
+ */
180
+ noteHardPauseEmitted(key) {
181
+ this.snapshots.delete(key);
182
+ }
183
+ }
@@ -0,0 +1,25 @@
1
+ import type { ContextBudgetAdvisory } from "./context-budget-guard.js";
2
+ /** Response header carrying the soft-threshold advisory, non-blocking. */
3
+ export declare const CONTEXT_BUDGET_PERCENT_HEADER = "x-codex-context-budget-percent";
4
+ /**
5
+ * Build the synthetic response returned in place of forwarding to upstream
6
+ * when the context budget guard's hard threshold has been reached.
7
+ *
8
+ * Same technique as `createContextOverflowResponse`: a 200 OK synthetic SSE
9
+ * reply so the host session never locks on an error for a notice the plugin
10
+ * itself generated — except this fires pre-flight, before the request that
11
+ * would overflow is ever sent, instead of reacting to the 400 after the
12
+ * fact.
13
+ */
14
+ export declare function createContextBudgetPauseResponse(advisory: Extract<ContextBudgetAdvisory, {
15
+ level: "hard";
16
+ }>): Response;
17
+ /**
18
+ * Non-blocking header for the soft threshold, added to the real forwarded
19
+ * response next to the existing `x-codex-*-used-percent` quota headers.
20
+ * Consumed by `codex-multi-auth status` / the statusline, not by the model.
21
+ */
22
+ export declare function buildContextBudgetHeaders(advisory: Extract<ContextBudgetAdvisory, {
23
+ level: "soft" | "hard";
24
+ }>): Record<string, string>;
25
+ //# sourceMappingURL=context-budget-response.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"context-budget-response.d.ts","sourceRoot":"","sources":["../../lib/context-budget-response.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,2BAA2B,CAAC;AAGvE,0EAA0E;AAC1E,eAAO,MAAM,6BAA6B,mCAAmC,CAAC;AAgC9E;;;;;;;;;GASG;AACH,wBAAgB,gCAAgC,CAC/C,QAAQ,EAAE,OAAO,CAAC,qBAAqB,EAAE;IAAE,KAAK,EAAE,MAAM,CAAA;CAAE,CAAC,GACzD,QAAQ,CAOV;AAED;;;;GAIG;AACH,wBAAgB,yBAAyB,CACxC,QAAQ,EAAE,OAAO,CAAC,qBAAqB,EAAE;IAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAAA;CAAE,CAAC,GAClE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAIxB"}
@@ -0,0 +1,57 @@
1
+ import { createSyntheticSseResponse } from "./synthetic-response.js";
2
+ /** Response header carrying the soft-threshold advisory, non-blocking. */
3
+ export const CONTEXT_BUDGET_PERCENT_HEADER = "x-codex-context-budget-percent";
4
+ function formatPercent(percent) {
5
+ return percent.toFixed(1);
6
+ }
7
+ /**
8
+ * The message shown to users when the context budget guard pauses a session
9
+ * at the hard threshold, before the request that would have pushed it over
10
+ * the model's window is even sent upstream.
11
+ *
12
+ * Deliberately echoes `lib/context-overflow.ts`'s CONTEXT_OVERFLOW_MESSAGE
13
+ * copy so the two related notices read as one family in a transcript,
14
+ * rather than as two unexplained different warnings.
15
+ */
16
+ function buildPauseMessage(advisory) {
17
+ const windowNote = advisory.windowSource === "override"
18
+ ? "your configured window size"
19
+ : "an estimated window size (unverified — set `contextBudgetGuardModelWindowOverrides` if you know the real limit)";
20
+ return `[Plugin Notice] Context budget guard paused this session at ${formatPercent(advisory.percent)}% of ${windowNote} for \`${advisory.model}\`, before hitting the model's actual limit.
21
+
22
+ Please use one of these commands to reduce context size:
23
+
24
+ • **/compact** - Compress conversation history (recommended)
25
+ • **/clear** - Start fresh with empty context
26
+ • **/undo** - Remove recent messages
27
+
28
+ Then retry your request. This pause clears itself automatically once your context usage drops back below the threshold.`;
29
+ }
30
+ /**
31
+ * Build the synthetic response returned in place of forwarding to upstream
32
+ * when the context budget guard's hard threshold has been reached.
33
+ *
34
+ * Same technique as `createContextOverflowResponse`: a 200 OK synthetic SSE
35
+ * reply so the host session never locks on an error for a notice the plugin
36
+ * itself generated — except this fires pre-flight, before the request that
37
+ * would overflow is ever sent, instead of reacting to the 400 after the
38
+ * fact.
39
+ */
40
+ export function createContextBudgetPauseResponse(advisory) {
41
+ return createSyntheticSseResponse({
42
+ model: advisory.model,
43
+ message: buildPauseMessage(advisory),
44
+ idPrefix: "context_budget_pause",
45
+ errorType: "context_budget_pause",
46
+ });
47
+ }
48
+ /**
49
+ * Non-blocking header for the soft threshold, added to the real forwarded
50
+ * response next to the existing `x-codex-*-used-percent` quota headers.
51
+ * Consumed by `codex-multi-auth status` / the statusline, not by the model.
52
+ */
53
+ export function buildContextBudgetHeaders(advisory) {
54
+ return {
55
+ [CONTEXT_BUDGET_PERCENT_HEADER]: formatPercent(advisory.percent),
56
+ };
57
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"context-overflow.d.ts","sourceRoot":"","sources":["../../lib/context-overflow.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAiBH;;GAEG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAMhF;AAiBD;;;;;;;;;GASG;AACH,wBAAgB,6BAA6B,CAAC,KAAK,GAAE,MAAkB,GAAG,QAAQ,CAsEjF;AAED;;GAEG;AACH,wBAAsB,qBAAqB,CACzC,QAAQ,EAAE,QAAQ,EAClB,KAAK,CAAC,EAAE,MAAM,GACb,OAAO,CAAC;IAAE,OAAO,EAAE,IAAI,CAAC;IAAC,QAAQ,EAAE,QAAQ,CAAA;CAAE,GAAG;IAAE,OAAO,EAAE,KAAK,CAAA;CAAE,CAAC,CAmBrE"}
1
+ {"version":3,"file":"context-overflow.d.ts","sourceRoot":"","sources":["../../lib/context-overflow.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAkBH;;GAEG;AACH,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,GAAG,OAAO,CAMhF;AAiBD;;;;;;;;;GASG;AACH,wBAAgB,6BAA6B,CAAC,KAAK,GAAE,MAAkB,GAAG,QAAQ,CAOjF;AAED;;GAEG;AACH,wBAAsB,qBAAqB,CACzC,QAAQ,EAAE,QAAQ,EAClB,KAAK,CAAC,EAAE,MAAM,GACb,OAAO,CAAC;IAAE,OAAO,EAAE,IAAI,CAAC;IAAC,QAAQ,EAAE,QAAQ,CAAA;CAAE,GAAG;IAAE,OAAO,EAAE,KAAK,CAAA;CAAE,CAAC,CAmBrE"}