@oh-my-pi/pi-ai 18.8.6 → 18.8.7

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/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.8.7] - 2026-10-09
6
+
7
+ ### Added
8
+
9
+ - Added routing-session cleanup for OpenAI Responses and Codex while preserving shared provider fallbacks ([#14334](https://github.com/can1357/oh-my-pi/pull/14334) by [@iliaal](https://github.com/iliaal)).
10
+
11
+ ### Fixed
12
+
13
+ - Fixed Claude Haiku 5.5 requests silently enabling adaptive thinking when reasoning is off, on native Bedrock (main and helper calls) and the Anthropic API; conversations whose earlier effort controls rule out disabled thinking fall back to lowest-effort adaptive thinking instead of failing ([#14996](https://github.com/can1357/oh-my-pi/pull/14996) by [@bse-ai](https://github.com/bse-ai)).
14
+ - Fixed `/session pin` being ignored when every stored account is quota-blocked, which routed the next request to a different exhausted account instead of the pinned one ([#14997](https://github.com/can1357/oh-my-pi/issues/14997)).
15
+ - Fixed `minimax-code-cn` sessions staying pinned to a key whose Token Plan quota is exhausted (`用量上限` 429) instead of rotating to a sibling credential ([#15053](https://github.com/can1357/oh-my-pi/issues/15053)).
16
+
5
17
  ## [18.8.6] - 2026-10-08
6
18
 
7
19
  ### Fixed
@@ -71,9 +71,9 @@ export declare class CredentialSelector {
71
71
  * 1. strict: unblocked credentials only, usage limits respected, plan
72
72
  * filter enforced (when any account is confirmed eligible);
73
73
  * 2. plan-fitting last resort: same plan filter, but blocked/exhausted
74
- * accounts are allowed (blocked candidates rank earliest-unblocking
75
- * first) so the caller gets real usage-limit semantics from the wire
76
- * instead of a missing key;
74
+ * accounts are allowed (an explicit session pin first, then blocked
75
+ * candidates earliest-unblocking first) so the caller gets real
76
+ * usage-limit semantics from the wire instead of a missing key;
77
77
  * 3. unfiltered last resort: the plan filter matched nothing usable —
78
78
  * skip it and try every account once; the server is the final arbiter
79
79
  * of model access.
@@ -35,16 +35,19 @@ interface EffortTransition<TEffort extends string> {
35
35
  }
36
36
  /** Per-conversation effort baseline and recorded transitions. */
37
37
  export interface OpenAIEffortControlState<TEffort extends string = string> {
38
+ sessionId: string;
38
39
  baseEffort?: TEffort;
39
40
  currentEffort?: TEffort;
40
41
  transitions: EffortTransition<TEffort>[];
41
42
  }
42
- export declare function createOpenAIEffortControlState<TEffort extends string>(): OpenAIEffortControlState<TEffort>;
43
+ export declare function createOpenAIEffortControlState<TEffort extends string>(sessionId: string): OpenAIEffortControlState<TEffort>;
43
44
  /**
44
45
  * Fetch (or create) the control state for one conversation from a provider's
45
46
  * bounded per-session map, refreshing its LRU slot.
46
47
  */
47
- export declare function getOpenAIEffortControlState<TEffort extends string>(states: Map<string, OpenAIEffortControlState<TEffort>>, key: string): OpenAIEffortControlState<TEffort>;
48
+ export declare function getOpenAIEffortControlState<TEffort extends string>(states: Map<string, OpenAIEffortControlState<TEffort>>, key: string, sessionId: string): OpenAIEffortControlState<TEffort>;
49
+ /** Release one conversation's effort state from the Responses and Codex `releaseSession` implementations. */
50
+ export declare function releaseOpenAIEffortControlSession<TEffort extends string>(states: Map<string, OpenAIEffortControlState<TEffort>>, sessionId: string): void;
48
51
  interface AnchorableItem {
49
52
  type?: string | null;
50
53
  role?: string;
@@ -91,6 +91,7 @@ interface OpenAIResponsesProviderSessionState extends ProviderSessionState, Open
91
91
  /** Wire efforts a `configuration_update` can carry: every real tier, never `none`/null. */
92
92
  type ResponsesStableEffort = Exclude<ReasoningEffort, "none" | null>;
93
93
  interface OpenAIResponsesChainState {
94
+ sessionId: string;
94
95
  /**
95
96
  * Wire params of the last successful turn; never carries
96
97
  * `previous_response_id`.
@@ -191,6 +191,8 @@ export declare function parseServiceTier(value: unknown): ServiceTier | undefine
191
191
  export declare function coerceServiceTierByFamily(value: unknown): ServiceTierByFamily | undefined;
192
192
  export interface ProviderSessionState {
193
193
  close(): void;
194
+ /** Release one routing session after its final request, preserving shared provider fallbacks. */
195
+ releaseSession?(sessionId: string): void;
194
196
  }
195
197
  export interface ProviderResponseMetadata {
196
198
  status: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-ai",
3
- "version": "18.8.6",
3
+ "version": "18.8.7",
4
4
  "description": "Unified LLM API with automatic model discovery and provider configuration",
5
5
  "keywords": [
6
6
  "ai",
@@ -155,11 +155,11 @@
155
155
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
156
156
  },
157
157
  "dependencies": {
158
- "@oh-my-pi/omptype": "18.8.6",
159
- "@oh-my-pi/pi-catalog": "18.8.6",
160
- "@oh-my-pi/pi-natives": "18.8.6",
161
- "@oh-my-pi/pi-utils": "18.8.6",
162
- "@oh-my-pi/pi-wire": "18.8.6"
158
+ "@oh-my-pi/omptype": "18.8.7",
159
+ "@oh-my-pi/pi-catalog": "18.8.7",
160
+ "@oh-my-pi/pi-natives": "18.8.7",
161
+ "@oh-my-pi/pi-utils": "18.8.7",
162
+ "@oh-my-pi/pi-wire": "18.8.7"
163
163
  },
164
164
  "devDependencies": {
165
165
  "@types/bun": "^1.3.14"
@@ -505,9 +505,9 @@ export class CredentialSelector {
505
505
  * 1. strict: unblocked credentials only, usage limits respected, plan
506
506
  * filter enforced (when any account is confirmed eligible);
507
507
  * 2. plan-fitting last resort: same plan filter, but blocked/exhausted
508
- * accounts are allowed (blocked candidates rank earliest-unblocking
509
- * first) so the caller gets real usage-limit semantics from the wire
510
- * instead of a missing key;
508
+ * accounts are allowed (an explicit session pin first, then blocked
509
+ * candidates earliest-unblocking first) so the caller gets real
510
+ * usage-limit semantics from the wire instead of a missing key;
511
511
  * 3. unfiltered last resort: the plan filter matched nothing usable —
512
512
  * skip it and try every account once; the server is the final arbiter
513
513
  * of model access.
@@ -535,6 +535,9 @@ export class CredentialSelector {
535
535
  const credentials = stored.filter(entry => this.#deps.affinity.allows(provider, sessionId, entry.credential));
536
536
 
537
537
  if (credentials.length === 0) return undefined;
538
+ // Ranking and preflight both await provider work; preserve the row each
539
+ // selection started with even if a peer reorders the live pool.
540
+ const credentialIds = this.#deps.pool.entries(provider).map(entry => entry.id);
538
541
  this.#deps.policies.validateUsageCapability(provider, this.#deps.usage.canFetchOAuthUsage(provider));
539
542
 
540
543
  const providerKey = providerTypeKey(provider, "oauth");
@@ -659,6 +662,11 @@ export class CredentialSelector {
659
662
  ? { selection, usage: sessionPreferredUsage, usageChecked: true }
660
663
  : { selection, usage: null, usageChecked: false },
661
664
  );
665
+ // Keep the candidate object: preflight may rebind its positional index
666
+ // after a peer changes the credential pool.
667
+ const explicitPin = sessionPinIsExplicit
668
+ ? candidates.find(candidate => candidate.selection.index === sessionPreferredIndex)
669
+ : undefined;
662
670
  const preflightFailures = new Set<OAuthCandidate>();
663
671
  // The last retryable refresh error (network, timeout, 5xx) that removed a candidate.
664
672
  // When no candidate resolves, it is rethrown so callers retry instead of reporting
@@ -714,21 +722,24 @@ export class CredentialSelector {
714
722
  const forceRefreshIndex = options?.forceRefresh
715
723
  ? (sessionPreferredIndex ?? candidates[0]?.selection.index)
716
724
  : undefined;
717
- // Each candidate's synchronous prefix below runs back to back inside `map`,
718
- // before any await, so one provider re-list serves every initial rebind.
719
- // Resyncs after an await (refresh, disable) re-read the store.
725
+ // Candidate IDs were captured before ranking. Their synchronous rebinds
726
+ // share one provider re-list; resyncs after refresh await re-read the store.
720
727
  let preflightRows: StoredAuthCredential[] | undefined;
721
728
  await Promise.all(
722
729
  candidates.map(async candidate => {
723
730
  const force = forceRefreshIndex !== undefined && candidate.selection.index === forceRefreshIndex;
724
- const initialCredentialId = this.#deps.pool.entries(provider)[candidate.selection.index]?.id;
725
- let syncedPeerCredential = false;
726
- if (initialCredentialId !== undefined) {
727
- const beforeSync = candidate.selection.credential;
728
- preflightRows ??= this.#reloadProviderRows(provider);
729
- if (!this.#rebindOAuthSelection(preflightRows, candidate.selection, initialCredentialId)) return;
730
- syncedPeerCredential = !authCredentialEquals(beforeSync, candidate.selection.credential);
731
+ const initialCredentialId = credentialIds[candidate.selection.index];
732
+ if (initialCredentialId === undefined) {
733
+ preflightFailures.add(candidate);
734
+ return;
735
+ }
736
+ const beforeSync = candidate.selection.credential;
737
+ preflightRows ??= this.#reloadProviderRows(provider);
738
+ if (!this.#rebindOAuthSelection(preflightRows, candidate.selection, initialCredentialId)) {
739
+ preflightFailures.add(candidate);
740
+ return;
731
741
  }
742
+ const syncedPeerCredential = !authCredentialEquals(beforeSync, candidate.selection.credential);
732
743
  const hasFreshAccess = Date.now() + OAUTH_REFRESH_SKEW_MS < candidate.selection.credential.expires;
733
744
  if ((!force || syncedPeerCredential) && hasFreshAccess) return;
734
745
  const latestCredential = this.#deps.pool.credentials(provider)[candidate.selection.index];
@@ -884,9 +895,15 @@ export class CredentialSelector {
884
895
  ];
885
896
  if (enforcePlanRequirement) passes.push({ allowBlocked: true, enforcePlanRequirement: false, enforceAccounts });
886
897
  if (enforceAccounts) passes.push({ allowBlocked: true, enforcePlanRequirement: false, enforceAccounts: false });
898
+ // Blocked candidates rank earliest-unblock first, which would route a
899
+ // blocked explicit pin to an equally blocked sibling. Once the strict
900
+ // pass finds no unblocked account, the user's pin goes first.
901
+ const lastResortCandidates = explicitPin
902
+ ? [explicitPin, ...candidates.filter(candidate => candidate !== explicitPin)]
903
+ : candidates;
887
904
 
888
905
  for (const pass of passes) {
889
- for (const candidate of candidates) {
906
+ for (const candidate of pass.allowBlocked ? lastResortCandidates : candidates) {
890
907
  if (preflightFailures.has(candidate)) continue;
891
908
  const candidateAccountKey = oauthAccountKey(candidate.selection.credential);
892
909
  if (pass.enforceAccounts && (candidateAccountKey === undefined || !accountIds?.has(candidateAccountKey)))
@@ -78,15 +78,17 @@ const ACCOUNT_SCOPED_403_PATTERN =
78
78
  /\b(?:overall|account|organization|team|workspace)\b[^\n]{0,40}\b(?:message |request )?rate.?limit\b|\byour\b[^\n]{0,30}\b(?:limit )?will reset\b/i;
79
79
  // Simplified Chinese account-quota exhaustion phrasing. Zhipu Coding Plan
80
80
  // returns e.g. "429 已达到 5 小时的使用上限。您的限额将在 2026-08-06 20:06:00 重置。"
81
- // (type=1308) when the 5h window is spent; other CN providers use 额度已用完 /
81
+ // (type=1308) when the 5h window is spent; MiniMax CN returns
82
+ // "当前已达到 Token Plan 用量上限。…" (2067); other CN providers use 额度已用完 /
82
83
  // 配额已耗尽 / 余额不足. These are persistent account-local caps that must
83
84
  // rotate to a sibling credential, not transient rate limits, so they are
84
85
  // matched before the RATE_LIMIT_EXCEEDED branch. The 上限 arm is anchored on
85
- // the 使用 token: a rate/concurrency cap phrased as 每分钟请求数已达上限 /
86
- // 并发请求数已达上限 / 速率达到上限 (no 使用) must NOT match, or it would burn a
87
- // healthy sibling credential as a false quota. "速率限制" is absent for the
88
- // same reason.
89
- const CN_QUOTA_EXHAUSTED_PATTERN = /使用.{0,30}?上限|(?:额度|配额)已?(?:用|耗)(?:完|尽)|限额.{0,30}重置|余额不足/;
86
+ // the 使用 / 用量 tokens: a rate/concurrency cap phrased as 每分钟请求数已达上限 /
87
+ // 并发请求数已达上限 / 速率达到上限 (neither token) must NOT match, or it would
88
+ // burn a healthy sibling credential as a false quota. "速率限制" is absent for
89
+ // the same reason.
90
+ const CN_QUOTA_EXHAUSTED_PATTERN =
91
+ /(?:使用|用量).{0,30}?上限|(?:额度|配额)已?(?:用|耗)(?:完|尽)|限额.{0,30}重置|余额不足/;
90
92
  // Simplified Chinese rate/concurrency caps can contain both 使用 and 上限, but
91
93
  // remain transient rather than account quota exhaustion.
92
94
  const CN_TRANSIENT_CAP_PATTERN =
@@ -450,16 +450,20 @@ export const streamBedrock: StreamFunction<"bedrock-converse-stream"> = (
450
450
  let toolConfig = toolPlan.toolConfig;
451
451
  const sentinelInjected = toolPlan.sentinelInjected;
452
452
  let additionalModelRequestFields = buildAdditionalModelRequestFields(model, options);
453
- const prefixMismatchBehavior = model.thinking?.prefixBinding
454
- ? (options.anthropicPrefixMismatchBehavior ?? "drop_block")
455
- : undefined;
453
+ const thinkingDisabled =
454
+ isRecord(additionalModelRequestFields?.thinking) &&
455
+ additionalModelRequestFields.thinking.type === "disabled";
456
+ const prefixMismatchBehavior =
457
+ !thinkingDisabled && model.thinking?.prefixBinding
458
+ ? (options.anthropicPrefixMismatchBehavior ?? "drop_block")
459
+ : undefined;
456
460
 
457
461
  // Some models (Opus/Sonnet 5.5) reject forced tool use outright; keep the
458
462
  // tools offered under `auto` and leave thinking intact.
459
463
  const forcedChoice = toolConfig?.toolChoice?.any || toolConfig?.toolChoice?.tool;
460
464
  if (toolConfig && forcedChoice && !model.compat.supportsForcedToolChoice) {
461
465
  toolConfig = { ...toolConfig, toolChoice: { auto: {} } };
462
- } else if (toolConfig && forcedChoice && additionalModelRequestFields) {
466
+ } else if (toolConfig && forcedChoice && additionalModelRequestFields && !thinkingDisabled) {
463
467
  // Bedrock rejects thinking + forced tool_choice. Fable's adaptive
464
468
  // thinking cannot be disabled, so downgrade its forced choice instead.
465
469
  if (prefixMismatchBehavior) toolConfig = { ...toolConfig, toolChoice: { auto: {} } };
@@ -1335,7 +1339,13 @@ function buildAdditionalModelRequestFields(
1335
1339
  options: BedrockOptions,
1336
1340
  ): Record<string, unknown> | undefined {
1337
1341
  const reasoning = options.reasoning;
1338
- if (!reasoning || !model.reasoning) return undefined;
1342
+ if (!model.reasoning) return undefined;
1343
+ if (!reasoning) {
1344
+ if (model.compat.disabledThinking === "disabled") {
1345
+ return { thinking: { type: "disabled" }, output_config: { effort: "low" } };
1346
+ }
1347
+ return undefined;
1348
+ }
1339
1349
 
1340
1350
  const mode = model.thinking?.mode;
1341
1351
  if (mode === "anthropic-adaptive") {
@@ -4514,6 +4514,23 @@ export function resolveAnthropicCompactionEffort(
4514
4514
  );
4515
4515
  }
4516
4516
 
4517
+ /**
4518
+ * Whether effort controls earlier requests left in `messages` rule out
4519
+ * `thinking: {type: "disabled"}`: Anthropic rejects a per-message effort
4520
+ * control with thinking off, and `disabled` above `high` effort. Both stay in
4521
+ * force across a thinking toggle, so such a request falls back to the
4522
+ * adaptive-only off path (lowest effort) instead of a 400.
4523
+ */
4524
+ function effortControlsBlockDisabledThinking(
4525
+ model: Model<"anthropic-messages">,
4526
+ messages: readonly Message[],
4527
+ records: readonly AnthropicControlRecord[],
4528
+ ): boolean {
4529
+ if (model.compat.supportsPerMessageEffort !== true) return false;
4530
+ const plan = planAnthropicEffortControls(undefined, messages, records, true, undefined, false);
4531
+ return plan.inserts.length > 0 || plan.topLevel === "xhigh" || plan.topLevel === "max";
4532
+ }
4533
+
4517
4534
  /**
4518
4535
  * Splice control markers into `messages`. Inserts sharing an index come from
4519
4536
  * one request and merge into one control: tool changes first, then the effort.
@@ -4732,7 +4749,11 @@ function buildParams(
4732
4749
  let outputConfigEffort: AnthropicOutputEffort | undefined;
4733
4750
  if (model.reasoning) {
4734
4751
  const disabledThinking = model.compat.disabledThinking;
4735
- if (options?.thinkingEnabled === false && disabledThinking !== undefined) {
4752
+ if (
4753
+ options?.thinkingEnabled === false &&
4754
+ disabledThinking !== undefined &&
4755
+ !(disabledThinking === "disabled" && effortControlsBlockDisabledThinking(model, context.messages, records))
4756
+ ) {
4736
4757
  if (disabledThinking !== "omit") {
4737
4758
  thinking =
4738
4759
  disabledThinking === "adaptive"
@@ -102,6 +102,7 @@ export { setCodexAttestationProvider } from "./openai-codex-attestation";
102
102
  export type { CodexAttestationProvider } from "./openai-codex-attestation";
103
103
  import {
104
104
  getOpenAIEffortControlState,
105
+ releaseOpenAIEffortControlSession,
105
106
  type OpenAIEffortControlState,
106
107
  planStableOpenAIEffort,
107
108
  } from "./openai-configuration-update";
@@ -434,6 +435,7 @@ export interface OpenAICodexWebSocketDebugStats {
434
435
  * is historical — SSE-only sessions use it too.
435
436
  */
436
437
  type CodexWebSocketSessionState = {
438
+ sessionId: string;
437
439
  disableWebsocket: boolean;
438
440
  lastRequest?: RequestBody;
439
441
  /** Last completed response; an in-progress response cannot replace the retry baseline. */
@@ -1058,6 +1060,20 @@ function createCodexProviderSessionState(): CodexProviderSessionState {
1058
1060
  webSocketPublicToPrivate: new Map(),
1059
1061
  metadataSessions: new Map(),
1060
1062
  effortControls: new Map(),
1063
+ releaseSession: sessionId => {
1064
+ const normalizedSessionId = normalizeOpenAIPromptCacheKey(sessionId);
1065
+ if (!normalizedSessionId) return;
1066
+ for (const [key, session] of state.webSocketSessions) {
1067
+ if (session.sessionId !== normalizedSessionId) continue;
1068
+ session.connection?.close("session_disposed");
1069
+ state.webSocketSessions.delete(key);
1070
+ for (const [publicKey, privateKey] of state.webSocketPublicToPrivate) {
1071
+ if (privateKey === key) state.webSocketPublicToPrivate.delete(publicKey);
1072
+ }
1073
+ }
1074
+ state.metadataSessions.delete(normalizedSessionId);
1075
+ releaseOpenAIEffortControlSession(state.effortControls, normalizedSessionId);
1076
+ },
1061
1077
  close: () => {
1062
1078
  for (const session of state.webSocketSessions.values()) {
1063
1079
  session.connection?.close("session_disposed");
@@ -1472,14 +1488,14 @@ function createCodexRequestContext(
1472
1488
  transportProviderSessionState?.webSocketPublicToPrivate.set(publicSessionKey, sessionKey);
1473
1489
  }
1474
1490
  const sharedWebsocketState =
1475
- sessionKey && providerSessionState
1491
+ sessionKey && transportSessionId && providerSessionState
1476
1492
  ? isolatedTransportState
1477
1493
  ? providerSessionState.webSocketSessions.get(sessionKey)
1478
- : getCodexWebSocketSessionState(sessionKey, providerSessionState)
1494
+ : getCodexWebSocketSessionState(sessionKey, transportSessionId, providerSessionState)
1479
1495
  : undefined;
1480
1496
  const websocketState =
1481
- sessionKey && isolatedTransportState
1482
- ? getCodexWebSocketSessionState(sessionKey, isolatedTransportState)
1497
+ sessionKey && transportSessionId && isolatedTransportState
1498
+ ? getCodexWebSocketSessionState(sessionKey, transportSessionId, isolatedTransportState)
1483
1499
  : sharedWebsocketState;
1484
1500
  if (isolatedTransportState && websocketState && sharedWebsocketState) {
1485
1501
  websocketState.disableWebsocket = sharedWebsocketState.disableWebsocket;
@@ -1616,7 +1632,7 @@ function applyCodexStableEffort(
1616
1632
  const providerState = getCodexProviderSessionState(options?.providerSessionState);
1617
1633
  const sessionId = normalizeOpenAIPromptCacheKey(options?.sessionId);
1618
1634
  if (!providerState || !sessionId) return;
1619
- const state = getOpenAIEffortControlState(providerState.effortControls, `${model.id}\u0000${sessionId}`);
1635
+ const state = getOpenAIEffortControlState(providerState.effortControls, `${model.id}\u0000${sessionId}`, sessionId);
1620
1636
  body.reasoning = { ...body.reasoning, effort: planStableOpenAIEffort(state, body.input, effort) };
1621
1637
  }
1622
1638
 
@@ -3361,13 +3377,10 @@ export async function prewarmOpenAICodexResponses(
3361
3377
  if (publicSessionKey && sessionKey) {
3362
3378
  providerSessionState?.webSocketPublicToPrivate.set(publicSessionKey, sessionKey);
3363
3379
  }
3364
- if (!sessionKey || !providerSessionState) return;
3365
- const state = getCodexWebSocketSessionState(sessionKey, providerSessionState);
3380
+ if (!sessionKey || !transportSessionId || !providerSessionState) return;
3381
+ const state = getCodexWebSocketSessionState(sessionKey, transportSessionId, providerSessionState);
3366
3382
  if (!shouldUseCodexWebSocket(model, state, options?.preferWebsockets)) return;
3367
- const metadataSession = getOrCreateCodexMetadataSessionState(
3368
- transportSessionId ?? crypto.randomUUID(),
3369
- providerSessionState,
3370
- );
3383
+ const metadataSession = getOrCreateCodexMetadataSessionState(transportSessionId, providerSessionState);
3371
3384
  const turnState = getOrCreateCodexTurnState(metadataSession, sessionKey);
3372
3385
  const codexClientVersion = CODEX_CLIENT_VERSION;
3373
3386
  const requestIdentity = createCodexCompatibilityIdentity(metadataSession);
@@ -3418,11 +3431,13 @@ function getCodexWebSocketSessionKey(
3418
3431
 
3419
3432
  function getCodexWebSocketSessionState(
3420
3433
  sessionKey: string,
3434
+ sessionId: string,
3421
3435
  providerSessionState: CodexProviderSessionState,
3422
3436
  ): CodexWebSocketSessionState {
3423
3437
  const existing = providerSessionState.webSocketSessions.get(sessionKey);
3424
3438
  if (existing) return existing;
3425
3439
  const created: CodexWebSocketSessionState = {
3440
+ sessionId,
3426
3441
  disableWebsocket: false,
3427
3442
  canAppend: false,
3428
3443
  fallbackCount: 0,
@@ -36,13 +36,16 @@ interface EffortTransition<TEffort extends string> {
36
36
 
37
37
  /** Per-conversation effort baseline and recorded transitions. */
38
38
  export interface OpenAIEffortControlState<TEffort extends string = string> {
39
+ sessionId: string;
39
40
  baseEffort?: TEffort;
40
41
  currentEffort?: TEffort;
41
42
  transitions: EffortTransition<TEffort>[];
42
43
  }
43
44
 
44
- export function createOpenAIEffortControlState<TEffort extends string>(): OpenAIEffortControlState<TEffort> {
45
- return { transitions: [] };
45
+ export function createOpenAIEffortControlState<TEffort extends string>(
46
+ sessionId: string,
47
+ ): OpenAIEffortControlState<TEffort> {
48
+ return { sessionId, transitions: [] };
46
49
  }
47
50
 
48
51
  const MAX_EFFORT_CONTROL_STATES = 16;
@@ -54,6 +57,7 @@ const MAX_EFFORT_CONTROL_STATES = 16;
54
57
  export function getOpenAIEffortControlState<TEffort extends string>(
55
58
  states: Map<string, OpenAIEffortControlState<TEffort>>,
56
59
  key: string,
60
+ sessionId: string,
57
61
  ): OpenAIEffortControlState<TEffort> {
58
62
  const existing = states.get(key);
59
63
  if (existing) {
@@ -61,7 +65,7 @@ export function getOpenAIEffortControlState<TEffort extends string>(
61
65
  states.set(key, existing);
62
66
  return existing;
63
67
  }
64
- const created = createOpenAIEffortControlState<TEffort>();
68
+ const created = createOpenAIEffortControlState<TEffort>(sessionId);
65
69
  states.set(key, created);
66
70
  if (states.size > MAX_EFFORT_CONTROL_STATES) {
67
71
  const oldest = states.keys().next().value;
@@ -70,6 +74,16 @@ export function getOpenAIEffortControlState<TEffort extends string>(
70
74
  return created;
71
75
  }
72
76
 
77
+ /** Release one conversation's effort state from the Responses and Codex `releaseSession` implementations. */
78
+ export function releaseOpenAIEffortControlSession<TEffort extends string>(
79
+ states: Map<string, OpenAIEffortControlState<TEffort>>,
80
+ sessionId: string,
81
+ ): void {
82
+ for (const [key, state] of states) {
83
+ if (state.sessionId === sessionId) states.delete(key);
84
+ }
85
+ }
86
+
73
87
  interface AnchorableItem {
74
88
  type?: string | null;
75
89
  role?: string;
@@ -56,6 +56,7 @@ import {
56
56
  import { compactGrammarDefinition } from "./grammar";
57
57
  import {
58
58
  getOpenAIEffortControlState,
59
+ releaseOpenAIEffortControlSession,
59
60
  type OpenAIEffortControlState,
60
61
  planStableOpenAIEffort,
61
62
  } from "./openai-configuration-update";
@@ -95,6 +96,7 @@ import {
95
96
  getJuiceValue,
96
97
  getOpenAIPromptCacheKey,
97
98
  getOpenAIResponsesRoutingSessionId,
99
+ normalizeOpenAIPromptCacheKey,
98
100
  getOpenAIStrictToolsScope,
99
101
  getOpenRouterResponsesSessionId,
100
102
  isCompiledGrammarTooLargeStrictError,
@@ -404,6 +406,7 @@ interface OpenAIResponsesProviderSessionState
404
406
  type ResponsesStableEffort = Exclude<ReasoningEffort, "none" | null>;
405
407
 
406
408
  interface OpenAIResponsesChainState {
409
+ sessionId: string;
407
410
  /**
408
411
  * Wire params of the last successful turn; never carries
409
412
  * `previous_response_id`.
@@ -429,6 +432,14 @@ function createOpenAIResponsesProviderSessionState(): OpenAIResponsesProviderSes
429
432
  nativeHistoryReplayWarmed: false,
430
433
  chains: new Map(),
431
434
  effortControls: new Map(),
435
+ releaseSession: sessionId => {
436
+ const normalizedSessionId = normalizeOpenAIPromptCacheKey(sessionId);
437
+ if (!normalizedSessionId) return;
438
+ for (const [key, chain] of state.chains) {
439
+ if (chain.sessionId === normalizedSessionId) state.chains.delete(key);
440
+ }
441
+ releaseOpenAIEffortControlSession(state.effortControls, normalizedSessionId);
442
+ },
432
443
  close: () => {
433
444
  state.nativeHistoryReplayWarmed = false;
434
445
  state.chains.clear();
@@ -511,7 +522,7 @@ function getOpenAIResponsesChainState(
511
522
  const key = `${resolvedBaseUrl ?? model.baseUrl ?? ""}\u0000${model.id}\u0000${sessionId}`;
512
523
  const existing = providerSessionState.chains.get(key);
513
524
  if (existing) return existing;
514
- const created: OpenAIResponsesChainState = { canAppend: false, staleFailures: 0, disabled: false };
525
+ const created: OpenAIResponsesChainState = { sessionId, canAppend: false, staleFailures: 0, disabled: false };
515
526
  providerSessionState.chains.set(key, created);
516
527
  return created;
517
528
  }
@@ -1744,6 +1755,7 @@ function applyResponsesStableEffort(
1744
1755
  const state = getOpenAIEffortControlState(
1745
1756
  providerSessionState.effortControls,
1746
1757
  `${model.baseUrl ?? ""}\u0000${model.id}\u0000${sessionId}`,
1758
+ sessionId,
1747
1759
  );
1748
1760
  params.reasoning = { ...reasoning, effort: planStableOpenAIEffort(state, input, effort) };
1749
1761
  }
package/src/types.ts CHANGED
@@ -405,6 +405,8 @@ export function coerceServiceTierByFamily(value: unknown): ServiceTierByFamily |
405
405
 
406
406
  export interface ProviderSessionState {
407
407
  close(): void;
408
+ /** Release one routing session after its final request, preserving shared provider fallbacks. */
409
+ releaseSession?(sessionId: string): void;
408
410
  }
409
411
 
410
412
  export interface ProviderResponseMetadata {