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.
- package/.codex-plugin/plugin.json +1 -1
- package/README.md +2 -1
- package/config/README.md +3 -1
- package/config/codex-legacy.json +300 -0
- package/config/codex-modern.json +96 -0
- package/config/schema/config.schema.json +23 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +62 -2
- package/dist/lib/accounts.d.ts +25 -1
- package/dist/lib/accounts.d.ts.map +1 -1
- package/dist/lib/accounts.js +26 -6
- package/dist/lib/capability-policy.d.ts.map +1 -1
- package/dist/lib/capability-policy.js +16 -1
- package/dist/lib/codex-manager/backend-settings-helpers.d.ts.map +1 -1
- package/dist/lib/codex-manager/backend-settings-helpers.js +10 -1
- package/dist/lib/codex-manager/backend-settings-schema.d.ts +18 -0
- package/dist/lib/codex-manager/backend-settings-schema.d.ts.map +1 -1
- package/dist/lib/codex-manager/backend-settings-schema.js +19 -0
- package/dist/lib/codex-manager/experimental-settings-prompt.d.ts +1 -0
- package/dist/lib/codex-manager/experimental-settings-prompt.d.ts.map +1 -1
- package/dist/lib/codex-manager/experimental-settings-prompt.js +12 -0
- package/dist/lib/codex-manager/experimental-settings-schema.d.ts +2 -0
- package/dist/lib/codex-manager/experimental-settings-schema.d.ts.map +1 -1
- package/dist/lib/codex-manager/experimental-settings-schema.js +2 -0
- package/dist/lib/config.d.ts +29 -0
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +91 -1
- package/dist/lib/context-budget/model-context-windows.d.ts +25 -0
- package/dist/lib/context-budget/model-context-windows.d.ts.map +1 -0
- package/dist/lib/context-budget/model-context-windows.js +130 -0
- package/dist/lib/context-budget-guard.d.ts +100 -0
- package/dist/lib/context-budget-guard.d.ts.map +1 -0
- package/dist/lib/context-budget-guard.js +183 -0
- package/dist/lib/context-budget-response.d.ts +25 -0
- package/dist/lib/context-budget-response.d.ts.map +1 -0
- package/dist/lib/context-budget-response.js +57 -0
- package/dist/lib/context-overflow.d.ts.map +1 -1
- package/dist/lib/context-overflow.js +5 -60
- package/dist/lib/model-capability-matrix.d.ts.map +1 -1
- package/dist/lib/model-capability-matrix.js +9 -1
- package/dist/lib/request/error-classification.d.ts.map +1 -1
- package/dist/lib/request/error-classification.js +43 -2
- package/dist/lib/request/helpers/model-map.d.ts.map +1 -1
- package/dist/lib/request/helpers/model-map.js +181 -0
- package/dist/lib/request/rate-limit-decision.d.ts +4 -5
- package/dist/lib/request/rate-limit-decision.d.ts.map +1 -1
- package/dist/lib/request/rate-limit-decision.js +33 -4
- package/dist/lib/request/response-metadata.d.ts.map +1 -1
- package/dist/lib/request/response-metadata.js +1 -0
- package/dist/lib/request/stream-failover-runtime.d.ts +3 -1
- package/dist/lib/request/stream-failover-runtime.d.ts.map +1 -1
- package/dist/lib/request/stream-failover-runtime.js +7 -2
- package/dist/lib/runtime/context-budget-settings.d.ts +13 -0
- package/dist/lib/runtime/context-budget-settings.d.ts.map +1 -0
- package/dist/lib/runtime/context-budget-settings.js +8 -0
- package/dist/lib/runtime/loader-setup.d.ts +1 -0
- package/dist/lib/runtime/loader-setup.d.ts.map +1 -1
- package/dist/lib/runtime/loader-setup.js +1 -0
- package/dist/lib/runtime/rotation-account-selection.d.ts +23 -0
- package/dist/lib/runtime/rotation-account-selection.d.ts.map +1 -1
- package/dist/lib/runtime/rotation-account-selection.js +15 -6
- package/dist/lib/runtime/rotation-proxy-state.d.ts +2 -0
- package/dist/lib/runtime/rotation-proxy-state.d.ts.map +1 -1
- package/dist/lib/runtime/rotation-server-types.d.ts +11 -0
- package/dist/lib/runtime/rotation-server-types.d.ts.map +1 -1
- package/dist/lib/runtime-rotation-proxy.d.ts.map +1 -1
- package/dist/lib/runtime-rotation-proxy.js +251 -43
- package/dist/lib/schemas.d.ts +4 -0
- package/dist/lib/schemas.d.ts.map +1 -1
- package/dist/lib/schemas.js +10 -0
- package/dist/lib/synthetic-response.d.ts +31 -0
- package/dist/lib/synthetic-response.d.ts.map +1 -0
- package/dist/lib/synthetic-response.js +79 -0
- package/dist/lib/ui/ui-copy.d.ts +2 -1
- package/dist/lib/ui/ui-copy.d.ts.map +1 -1
- package/dist/lib/ui/ui-copy.js +2 -1
- package/dist/lib/usage/pricing.d.ts +1 -1
- package/dist/lib/usage/pricing.d.ts.map +1 -1
- package/dist/lib/usage/pricing.js +33 -0
- package/package.json +1 -1
- 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;
|
|
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;
|
|
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
|
-
|
|
765
|
-
|
|
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
|
-
|
|
1018
|
+
noteRotation();
|
|
860
1019
|
continue;
|
|
861
1020
|
}
|
|
862
|
-
|
|
863
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1081
|
+
noteRotation();
|
|
902
1082
|
continue;
|
|
903
1083
|
}
|
|
904
1084
|
const accountId = resolveAccountId(refreshed.account, refreshed.accessToken);
|
|
905
1085
|
if (!accountId) {
|
|
906
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1299
|
+
noteRotation();
|
|
1120
1300
|
continue;
|
|
1121
1301
|
}
|
|
1122
1302
|
if (upstream.status >= 500) {
|
|
1123
1303
|
await readErrorBody(upstream, state.streamStallTimeoutMs);
|
|
1124
|
-
|
|
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
|
-
|
|
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
|
|
1241
|
-
//
|
|
1242
|
-
//
|
|
1243
|
-
//
|
|
1244
|
-
//
|
|
1245
|
-
//
|
|
1246
|
-
//
|
|
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,
|
|
1259
|
-
//
|
|
1260
|
-
//
|
|
1261
|
-
//
|
|
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
|
-
//
|
|
1300
|
-
//
|
|
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
|
});
|
package/dist/lib/schemas.d.ts
CHANGED
|
@@ -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":"AAAA;;;;GAIG;AACH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAmBxB,eAAO,MAAM,kBAAkB
|
|
1
|
+
{"version":3,"file":"schemas.d.ts","sourceRoot":"","sources":["../../lib/schemas.ts"],"names":[],"mappings":"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"}
|
package/dist/lib/schemas.js
CHANGED
|
@@ -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"}
|