@oh-my-pi/pi-ai 18.10.3 → 18.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/CHANGELOG.md CHANGED
@@ -2,6 +2,34 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.11.0] - 2026-10-14
6
+
7
+ ### Fixed
8
+
9
+ - Fixed parallel tool calls failing with "Tool call arguments are not valid JSON" on some OpenAI-compatible gateways, such as Gemini ([#15682](https://github.com/can1357/oh-my-pi/pull/15682) by [@Zzz141592653](https://github.com/Zzz141592653))
10
+ - Fixed Anthropic fallback-credit redemption losing its credit on errors that only mention `400` in their text, such as an overload message ([#15702](https://github.com/can1357/oh-my-pi/pull/15702) by [@H4vC](https://github.com/H4vC))
11
+ - Fixed Anthropic fallback-credit redemption sending an extra `fallback-credit-2026-06-01` beta when the refused request used a newer one ([#15702](https://github.com/can1357/oh-my-pi/pull/15702) by [@H4vC](https://github.com/H4vC))
12
+ - Fixed Anthropic fallback-credit redemptions failing when the refused turn included unsigned thinking ([#15702](https://github.com/can1357/oh-my-pi/pull/15702) by [@H4vC](https://github.com/H4vC))
13
+ - Fixed turns ending without automatic retry when a gateway forwards Go's `use of closed network connection` transport error; it now retries like other dropped connections ([#15385](https://github.com/can1357/oh-my-pi/issues/15385))
14
+ - Fixed Codex SSE streams failing with an abort instead of retrying when the first event stalls ([#15542](https://github.com/can1357/oh-my-pi/issues/15542))
15
+ - Fixed Codex (ChatGPT subscription) requests with reasoning disabled, such as judges, titles, commit messages, and thinking `off`, leaving effort to the server default; they now send the lowest supported effort, or `none` where the model accepts it ([#15514](https://github.com/can1357/oh-my-pi/issues/15514))
16
+ - Fixed a Codex account whose token OpenAI rejects (401/403) continuing to show its last quota as healthy; `omp usage` now drops the stale report and `omp auth-gateway check` reports the credential as failing ([#15439](https://github.com/can1357/oh-my-pi/issues/15439))
17
+ - Fixed usage reports being served indefinitely while their provider keeps failing; the last successful report is now shown for at most 24 hours after it was fetched ([#15439](https://github.com/can1357/oh-my-pi/issues/15439))
18
+ - Fixed Responses requests 400ing (DeepSeek: "The `reasoning_text` in the thinking mode must be passed back to the API") after a tool call whose arguments were invalid JSON: the call is now replayed with its parse diagnostic instead of being dropped and leaving a trailing `[Orphan tool result]` note ([#15568](https://github.com/can1357/oh-my-pi/issues/15568))
19
+ - Fixed DeepSeek on OpenAI-compatible gateways leaving doubled-pipe DSML markup (`</||DSML||parameter>`) in assistant text and reasoning ([#15485](https://github.com/can1357/oh-my-pi/issues/15485))
20
+ - Fixed sessions failing every request with `400 … thinking or redacted_thinking blocks in the latest assistant message cannot be modified` after a response fell back to another model mid-turn (e.g. through a gateway that opts into Anthropic server-side fallback); the turn now replays one model's reasoning, and a session already stuck on such a turn recovers by dropping that turn's reasoning and retrying ([#15654](https://github.com/can1357/oh-my-pi/pull/15654) by [@abilliontokens](https://github.com/abilliontokens))
21
+ - Fixed `omp auth-gateway`'s Anthropic `/v1/messages` stream misnumbering content blocks after a redacted reasoning block, which misrouted every later delta in index-addressed clients ([#15654](https://github.com/can1357/oh-my-pi/pull/15654) by [@abilliontokens](https://github.com/abilliontokens))
22
+
23
+ ## [18.10.4] - 2026-10-13
24
+
25
+ ### Fixed
26
+
27
+ - Fixed Codex cybersecurity policy errors incorrectly retrying without Daybreak and disabling it for subsequent requests, while preserving retries without Daybreak when access is rejected ([#15472](https://github.com/can1357/oh-my-pi/pull/15472) by [@taibenvenuti](https://github.com/taibenvenuti))
28
+
29
+ ### Removed
30
+
31
+ - Removed the `toolNamespacesInfo` stream option; Codex requests no longer send `tool_namespaces_info` turn metadata, and `code_mode_tool_names`/`tool_namespaces_info` are no longer reserved client metadata keys.
32
+
5
33
  ## [18.10.3] - 2026-10-12
6
34
 
7
35
  ### Breaking Changes
@@ -2,7 +2,7 @@ import type { AuthCredentialStore } from "./store.js";
2
2
  import type { AuthCredential, OAuthCredential } from "./types.js";
3
3
  import type { CredentialPool } from "./pool.js";
4
4
  import type { Provider } from "../types.js";
5
- import type { UsageCredential, UsageProvider } from "../usage.js";
5
+ import type { UsageCredential, UsageProvider, UsageReport } from "../usage.js";
6
6
  /** Minimum interval between non-exhausted header snapshots; used by UsageService. */
7
7
  export declare const USAGE_HEADER_INGEST_INTERVAL_MS = 60000;
8
8
  /**
@@ -56,6 +56,8 @@ export declare class UsageCache {
56
56
  failureKey(reportKey: string): string;
57
57
  get<T>(key: string): UsageCacheEntry<T> | undefined;
58
58
  getStale<T>(key: string): UsageCacheEntry<T> | undefined;
59
+ /** Last successful report under `key` while inside its retention window; the failure-path fallback. */
60
+ lastGood(key: string): UsageReport | null;
59
61
  set<T>(key: string, entry: UsageCacheEntry<T>): void;
60
62
  deletePrefix(prefix: string): boolean;
61
63
  markForceRefresh(provider?: Provider): void;
@@ -68,10 +68,22 @@ export declare function isCodexSteerRejection(message: {
68
68
  api?: Api;
69
69
  errorMessage?: string;
70
70
  }): boolean;
71
+ /**
72
+ * Transport-failure wording that marks an error transient. Covers
73
+ * diagnostics forwarded verbatim by gateways/proxies in other runtimes, e.g.
74
+ * undici's "other side closed" and Go's `net.ErrClosed` ("use of closed
75
+ * network connection", #15385).
76
+ */
71
77
  export declare const TRANSIENT_TRANSPORT_PATTERN: RegExp;
72
78
  export declare const ANTHROPIC_ACCOUNT_POLICY_PATTERN: RegExp;
73
79
  /** Whether an error message represents an Anthropic account-scoped permission/policy denial. */
74
80
  export declare function isAnthropicAccountPolicyText(text: string, provider?: string, statusArg?: number): boolean;
81
+ /**
82
+ * Anthropic's rejection of a replayed latest assistant turn whose `thinking` or
83
+ * `redacted_thinking` blocks no longer verify. Resending the same turn fails the
84
+ * same way; only a request that changes that turn's reasoning can succeed.
85
+ */
86
+ export declare function isImmutableLatestThinkingText(text: string): boolean;
75
87
  /**
76
88
  * Local llama.cpp / Ollama deterministic tool-call argument JSON parse failure.
77
89
  * The model emitted invalid JSON in a tool call and the server returned HTTP 500
@@ -18,8 +18,8 @@
18
18
  * siblings are endpoint-scoped and stay: `strictToolsDisabled`
19
19
  * (grammar-too-large 400 for the model's tool schema),
20
20
  * `replayUnsignedThinkingDisabled` / `thinkingReplayDisabled` (the endpoint
21
- * is a signing proxy), `prefixDroppedThinkingBlocks` (blocks a prefix-binding
22
- * 400 made the client strip).
21
+ * is a signing proxy), `prefixDroppedThinkingBlocks` (blocks a rejected
22
+ * replay made the client strip).
23
23
  * - **OpenAI Responses** — the `previous_response_id` chain baselines are
24
24
  * account-scoped: a stored response belongs to the account that created it.
25
25
  * Strict-tools / reasoning-effort fallbacks, replay warmup and the chaining
@@ -118,7 +118,8 @@ export interface AnthropicOptions extends StreamOptions {
118
118
  * header is auto-attached; the response parser then honors mid-stream
119
119
  * `fallback` content blocks and `usage.iterations` for served-model
120
120
  * promotion and per-attempt pricing. Opt-in ONLY — leaving this
121
- * undefined preserves the pre-fallback behavior on every code path.
121
+ * undefined sends neither and keeps both off; a `fallback` block in the
122
+ * response (a gateway opted in) is still persisted as a replay boundary.
122
123
  */
123
124
  fallbacks?: FallbackParam[];
124
125
  }
@@ -283,10 +284,11 @@ export type AnthropicMessageParam = MessageParam;
283
284
  * Serialize omp {@link Message}s to Anthropic wire messages.
284
285
  *
285
286
  * `opts.serverSideFallbackEnabled` — when the CURRENT request itself
286
- * opts into the server-side-fallback beta chain. Only then may a persisted
287
- * `fallback` content block from a prior turn be replayed on the wire;
288
- * otherwise the block is dropped to avoid a 400 on non-fallback requests
289
- * that don't send the beta.
287
+ * opts into the server-side-fallback beta chain. Only then, and only on the
288
+ * official endpoint, may a persisted `fallback` content block from a prior
289
+ * turn be replayed on the wire; otherwise the block is dropped to avoid a 400
290
+ * on requests that don't send the beta, and its turn keeps one model's
291
+ * reasoning (see {@link collapseFallbackSegments}).
290
292
  *
291
293
  * `opts.replayCompaction` — replay a user-role compaction summary that
292
294
  * carries an {@link AnthropicCompactionPayload} from this provider as a
@@ -6,6 +6,12 @@ export type { CodexAttestationProvider } from "./openai-codex-attestation.js";
6
6
  import type { ResponseInput } from "./openai-responses-wire.js";
7
7
  export interface OpenAICodexResponsesOptions extends StreamOptions {
8
8
  reasoning?: "none" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
9
+ /**
10
+ * Run with the least reasoning the model allows; takes precedence over
11
+ * `reasoning`. Sends the `none` tier where the catalog's disable mode is
12
+ * `none-effort`, else the model's lowest supported effort.
13
+ */
14
+ disableReasoning?: boolean;
9
15
  reasoningSummary?: "auto" | "concise" | "detailed" | null;
10
16
  /** Explicit `reasoning.context` replay scope. Omitted by default so Codex applies its native request policy. */
11
17
  reasoningContext?: CodexReasoningContext;
@@ -9,7 +9,7 @@ import type { ApiKey } from "./auth-retry.js";
9
9
  import type { OAuthRequestIdentity } from "./auth/types.js";
10
10
  import type { BedrockOptions } from "./providers/amazon-bedrock.js";
11
11
  import type { AnthropicOptions } from "./providers/anthropic.js";
12
- import type { FallbackParam, StopDetails } from "./providers/anthropic-wire.js";
12
+ import type { FallbackParam, MessageCreateParamsStreaming, StopDetails } from "./providers/anthropic-wire.js";
13
13
  import type { AzureOpenAIResponsesOptions } from "./providers/azure-openai-responses.js";
14
14
  import type { CursorOptions } from "./providers/cursor.js";
15
15
  import type { AppleFoundationModelsOptions } from "./providers/apple-foundation-models.js";
@@ -398,8 +398,6 @@ export interface StreamOptions {
398
398
  liveSteering?: LiveSteering;
399
399
  /** Canonical Codex compaction classification; ignored by other providers. */
400
400
  codexCompaction?: CodexCompactionRequestContext;
401
- /** Codex Code Mode tool exposure snapshot emitted as `tool_namespaces_info` turn metadata; ignored by other providers. */
402
- toolNamespacesInfo?: unknown;
403
401
  /**
404
402
  * Optional per-provider concurrent request cap for LLM stream calls. Keys are
405
403
  * provider ids (`model.provider`); positive numeric values cap in-flight
@@ -652,11 +650,12 @@ export interface RedactedThinkingContent {
652
650
  }
653
651
  /**
654
652
  * Anthropic server-side-fallback boundary marker persisted on assistant
655
- * turns whose provider request opted into
656
- * `AnthropicOptions.fallbacks`. Consumers other than the Anthropic
657
- * provider MUST ignore it — `transformMessages` strips the block on any
658
- * cross-provider hop and on non-official Anthropic replays, so downstream
659
- * converters never see it.
653
+ * turns whose response switched models mid-stream, whether the request
654
+ * opted into `AnthropicOptions.fallbacks` or a gateway opted in on its
655
+ * behalf. Consumers other than the Anthropic provider MUST ignore it —
656
+ * `transformMessages` strips the block on any non-Anthropic hop, and
657
+ * `convertAnthropicMessages` only puts it on the wire for an opted-in
658
+ * request to the official endpoint.
660
659
  */
661
660
  export interface AnthropicFallbackContent {
662
661
  type: "fallback";
@@ -1424,7 +1423,8 @@ export type AssistantMessageEvent = {
1424
1423
  export interface AnthropicFallbackCreditHandle {
1425
1424
  token: string;
1426
1425
  prefillClaim?: boolean | null;
1427
- params: unknown;
1426
+ /** The refused request's body, replayed with the credit token on the fallback model. */
1427
+ params: MessageCreateParamsStreaming;
1428
1428
  betas?: readonly string[];
1429
1429
  betaHeader?: string;
1430
1430
  expiresAt: number;
@@ -23,7 +23,9 @@ export declare function isDsmlLeakRecoveryTarget(model: Model): boolean;
23
23
  */
24
24
  export declare function stripDsmlToolMarkup(text: string): string | undefined;
25
25
  /**
26
- * Remove leaked DSML tool-call markup from `message`'s text blocks in place,
27
- * dropping blocks left empty. Returns true when anything was removed.
26
+ * Remove leaked DSML tool-call markup from an assistant message before commit,
27
+ * including reasoning blocks and turns with valid structured calls. Rewritten
28
+ * thinking with an opaque signature is discarded; OpenAI-compatible reasoning
29
+ * field names are replay markers, not signatures, and survive cleanup.
28
30
  */
29
31
  export declare function removeDsmlToolMarkupLeak(message: AssistantMessage): boolean;
@@ -5,7 +5,8 @@ export declare function parseToolCallArguments(json: string | undefined): ToolCa
5
5
  * Native Responses `function_call.arguments` to persist for replay.
6
6
  *
7
7
  * {@link parseToolCallArguments} repairs some invalid JSON (e.g. `"name": ,`) and the tool runs with the
8
- * repaired value; replay drops a call whose stored arguments do not parse, orphaning its output (#14155).
8
+ * repaired value; replay must carry what was executed (#14155), not the {@link invalidToolCallArguments}
9
+ * diagnostic it substitutes for stored arguments that do not parse (#15568).
9
10
  * Returns `raw` when it already parses or the arguments were unrepairable, else the executed arguments.
10
11
  */
11
12
  export declare function replayableToolCallArguments(raw: string, executed: ToolCall["arguments"]): string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-ai",
3
- "version": "18.10.3",
3
+ "version": "18.11.0",
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.10.3",
159
- "@oh-my-pi/pi-catalog": "18.10.3",
160
- "@oh-my-pi/pi-natives": "18.10.3",
161
- "@oh-my-pi/pi-utils": "18.10.3",
162
- "@oh-my-pi/pi-wire": "18.10.3"
158
+ "@oh-my-pi/omptype": "18.11.0",
159
+ "@oh-my-pi/pi-catalog": "18.11.0",
160
+ "@oh-my-pi/pi-natives": "18.11.0",
161
+ "@oh-my-pi/pi-utils": "18.11.0",
162
+ "@oh-my-pi/pi-wire": "18.11.0"
163
163
  },
164
164
  "devDependencies": {
165
165
  "@types/bun": "^1.3.14"
@@ -3,6 +3,7 @@ import type { AuthCredential, OAuthCredential } from "./types";
3
3
  import type { CredentialPool } from "./pool";
4
4
  import type { Provider } from "../types";
5
5
  import type { UsageCredential, UsageProvider, UsageReport } from "../usage";
6
+ import { isRecord } from "../utils";
6
7
 
7
8
  const USAGE_CACHE_PREFIX = "usage_cache:";
8
9
  const USAGE_FORCE_REFRESH_CACHE_PREFIX = "force-refresh:";
@@ -53,6 +54,17 @@ function withoutRawPayload(value: unknown): unknown {
53
54
  return rest;
54
55
  }
55
56
 
57
+ /**
58
+ * End of a cached value's durable retention. A report is retained for 24h past
59
+ * its last successful fetch, so re-storing it as a failure fallback never
60
+ * extends that window; other values are retained for 24h from now.
61
+ */
62
+ function retentionDeadline(value: unknown): number {
63
+ const fetchedAt = isRecord(value) ? value.fetchedAt : undefined;
64
+ const start = typeof fetchedAt === "number" && Number.isFinite(fetchedAt) && fetchedAt > 0 ? fetchedAt : Date.now();
65
+ return start + USAGE_LAST_GOOD_RETENTION_MS;
66
+ }
67
+
56
68
  /** Convert a stored credential for usage providers; used by usage and health services. */
57
69
  export function buildUsageCredential(credential: AuthCredential): UsageCredential {
58
70
  if (credential.type === "api_key") {
@@ -203,15 +215,19 @@ export class UsageCache {
203
215
  return parseUsageCacheEntry<T>(raw);
204
216
  }
205
217
 
218
+ /** Last successful report under `key` while inside its retention window; the failure-path fallback. */
219
+ lastGood(key: string): UsageReport | null {
220
+ const report = this.getStale<UsageReport | null>(key)?.value;
221
+ return report && Date.now() < retentionDeadline(report) ? report : null;
222
+ }
223
+
206
224
  set<T>(key: string, entry: UsageCacheEntry<T>): void {
207
225
  const value = withoutRawPayload(entry.value);
208
- const payload = JSON.stringify({
209
- value,
210
- expiresAt: entry.expiresAt,
211
- });
212
- const durableExpiresAt =
213
- value === null ? entry.expiresAt : Math.max(entry.expiresAt, Date.now() + USAGE_LAST_GOOD_RETENTION_MS);
214
- this.#store.setCache(`${USAGE_CACHE_PREFIX}${key}`, payload, Math.floor(durableExpiresAt / 1000));
226
+ const deadline = value === null ? undefined : retentionDeadline(value);
227
+ // Failure cooldowns must not make a last-good report fresh beyond retention.
228
+ const expiresAt = deadline === undefined ? entry.expiresAt : Math.min(entry.expiresAt, deadline);
229
+ const payload = JSON.stringify({ value, expiresAt });
230
+ this.#store.setCache(`${USAGE_CACHE_PREFIX}${key}`, payload, Math.floor((deadline ?? expiresAt) / 1000));
215
231
  }
216
232
 
217
233
  deletePrefix(prefix: string): boolean {
package/src/auth/usage.ts CHANGED
@@ -334,7 +334,7 @@ export class UsageService implements UsageApi {
334
334
 
335
335
  const failure = this.#deps.cache.get<null>(failureKey);
336
336
  if (failure && failure.expiresAt > now) {
337
- return this.#deps.cache.getStale<UsageReport | null>(cacheKey)?.value ?? null;
337
+ return this.#deps.cache.lastGood(cacheKey);
338
338
  }
339
339
 
340
340
  // Invalidation must not fork an already-running credential probe. Share it;
@@ -378,9 +378,7 @@ export class UsageService implements UsageApi {
378
378
  // so an expired login does not display stale quota indefinitely.
379
379
  const providerImpl = this.providerFor(request.provider);
380
380
  const retainLastGood = !invalidated && providerImpl?.retainLastGoodOnFailure !== false;
381
- const lastGood = retainLastGood
382
- ? (this.#deps.cache.getStale<UsageReport | null>(cacheKey)?.value ?? null)
383
- : null;
381
+ const lastGood = retainLastGood ? this.#deps.cache.lastGood(cacheKey) : null;
384
382
  const failureBackoffMs = providerImpl?.failureBackoffMs ?? USAGE_FAILURE_BACKOFF_MS;
385
383
  const backoffJitter = failureBackoffMs * (Math.random() * 0.5 - 0.25);
386
384
  const coolDown = Date.now() + failureBackoffMs + backoffJitter;
@@ -217,8 +217,14 @@ export function isCodexSteerRejection(message: { api?: Api; errorMessage?: strin
217
217
  );
218
218
  }
219
219
 
220
+ /**
221
+ * Transport-failure wording that marks an error transient. Covers
222
+ * diagnostics forwarded verbatim by gateways/proxies in other runtimes, e.g.
223
+ * undici's "other side closed" and Go's `net.ErrClosed` ("use of closed
224
+ * network connection", #15385).
225
+ */
220
226
  export const TRANSIENT_TRANSPORT_PATTERN =
221
- /\b(?:no[_ -]?capacity|(?:high|peak)[ _-]?demand|(?:at|over|insufficient)[ _-]?capacity|capacity[ _-]?(?:exceeded|exhausted)|peak[ _-]?load)\b|overloaded|provider.?returned.?error|rate.?limit|too many requests|auth-gateway\s+5\d{2}(?=[:\s]|$)|\b(?:429|500|502|503|504)\b|service.?unavailable|server.?error|internal.?error|retry your request|network.?error|connection.?error|connection.?refused|unable.?to.?connect\.\s*is the computer able to access the url\?|other side closed|fetch failed|upstream.?connect|upstream.?request.?failed|reset before headers|socket hang up|timed? out|timeout|terminated|retry delay|stream stall|no error details in response|HTTP2(?:StreamReset|RefusedStream|EnhanceYourCalm)|nghttp2_(?:internal_error|refused_stream)|stream closed with error code nghttp2_(?:internal_error|refused_stream)|malformed.?function.?call/i;
227
+ /\b(?:no[_ -]?capacity|(?:high|peak)[ _-]?demand|(?:at|over|insufficient)[ _-]?capacity|capacity[ _-]?(?:exceeded|exhausted)|peak[ _-]?load)\b|overloaded|provider.?returned.?error|rate.?limit|too many requests|auth-gateway\s+5\d{2}(?=[:\s]|$)|\b(?:429|500|502|503|504)\b|service.?unavailable|server.?error|internal.?error|retry your request|network.?error|connection.?error|connection.?refused|unable.?to.?connect\.\s*is the computer able to access the url\?|other side closed|use of closed network connection|fetch failed|upstream.?connect|upstream.?request.?failed|reset before headers|socket hang up|timed? out|timeout|terminated|retry delay|stream stall|no error details in response|HTTP2(?:StreamReset|RefusedStream|EnhanceYourCalm)|nghttp2_(?:internal_error|refused_stream)|stream closed with error code nghttp2_(?:internal_error|refused_stream)|malformed.?function.?call/i;
222
228
  const AUTH_FAILURE_PATTERN =
223
229
  /\b(?:401|403|unauthorized|forbidden|authentication|auth[_ ]?unavailable|no auth available|(?:invalid|no)[_ ]?api[_ ]?key)\b/i;
224
230
  const MALFORMED_FUNCTION_CALL_PATTERN = /\bmalformed.?function.?call\b/i;
@@ -237,6 +243,18 @@ export function isAnthropicAccountPolicyText(text: string, provider?: string, st
237
243
  return ANTHROPIC_ACCOUNT_POLICY_PATTERN.test(text);
238
244
  }
239
245
 
246
+ const IMMUTABLE_LATEST_THINKING_PATTERN =
247
+ /messages\.\d+\.content\.\d+.*\b(?:thinking|redacted_thinking)\b.*\blatest assistant message cannot be modified\b/is;
248
+
249
+ /**
250
+ * Anthropic's rejection of a replayed latest assistant turn whose `thinking` or
251
+ * `redacted_thinking` blocks no longer verify. Resending the same turn fails the
252
+ * same way; only a request that changes that turn's reasoning can succeed.
253
+ */
254
+ export function isImmutableLatestThinkingText(text: string): boolean {
255
+ return IMMUTABLE_LATEST_THINKING_PATTERN.test(text);
256
+ }
257
+
240
258
  const CODEX_CHATGPT_ACCOUNT_MODEL_POLICY_PATTERN =
241
259
  /\bThe ['"]([^'"\r\n]+)['"] model is not supported when using Codex with a ChatGPT account\./i;
242
260
  const CODEX_CHATGPT_ACCOUNT_MODEL_MAX_LENGTH = 256;
@@ -18,8 +18,8 @@
18
18
  * siblings are endpoint-scoped and stay: `strictToolsDisabled`
19
19
  * (grammar-too-large 400 for the model's tool schema),
20
20
  * `replayUnsignedThinkingDisabled` / `thinkingReplayDisabled` (the endpoint
21
- * is a signing proxy), `prefixDroppedThinkingBlocks` (blocks a prefix-binding
22
- * 400 made the client strip).
21
+ * is a signing proxy), `prefixDroppedThinkingBlocks` (blocks a rejected
22
+ * replay made the client strip).
23
23
  * - **OpenAI Responses** — the `previous_response_id` chain baselines are
24
24
  * account-scoped: a stored response belongs to the account that created it.
25
25
  * Strict-tools / reasoning-effort fallbacks, replay warmup and the chaining
@@ -538,9 +538,53 @@ function mapStopReasonOut(reason: StopReason, hasToolUse: boolean): "end_turn" |
538
538
  }
539
539
  }
540
540
 
541
+ /**
542
+ * Collapse a server-side-fallback turn for a bridge client that never opted
543
+ * into the beta: drop the `fallback` markers and keep reasoning from one
544
+ * model only. The downstream client never accepted a `fallbacks` request
545
+ * param and the bridge does not forward one, so the marker cannot go on the
546
+ * wire; without it, two models' signed reasoning replayed back-to-back reads
547
+ * as one modified chain and is the shape Anthropic rejects.
548
+ *
549
+ * The segment kept is the first that has any reasoning, not the last. The
550
+ * streaming encoder has already sent the first segment's blocks when the
551
+ * boundary arrives and cannot retract them, so first-segment is the only
552
+ * rule it can decide online; this path uses the same rule so streamed and
553
+ * non-streamed turns present the same reasoning. The offline converter
554
+ * (`collapseFallbackSegments` in `anthropic.ts`) can see the whole turn and
555
+ * keeps the last segment instead — both guarantee a single chain survives.
556
+ */
557
+ function collapseFallbackSegmentsKeepFirst(content: AssistantMessage["content"]): AssistantMessage["content"] {
558
+ let firstReasoningSegment = -1;
559
+ let segment = 0;
560
+ for (const block of content) {
561
+ if (block.type === "fallback") segment++;
562
+ else if (block.type === "thinking" || block.type === "redactedThinking") {
563
+ firstReasoningSegment = segment;
564
+ break;
565
+ }
566
+ }
567
+ if (firstReasoningSegment === -1) {
568
+ return content.filter(block => block.type !== "fallback");
569
+ }
570
+ const collapsed: AssistantMessage["content"] = [];
571
+ segment = 0;
572
+ for (const block of content) {
573
+ if (block.type === "fallback") {
574
+ segment++;
575
+ continue;
576
+ }
577
+ if ((block.type === "thinking" || block.type === "redactedThinking") && segment !== firstReasoningSegment) {
578
+ continue;
579
+ }
580
+ collapsed.push(block);
581
+ }
582
+ return collapsed;
583
+ }
584
+
541
585
  function encodeContentBlocks(message: AssistantMessage): Record<string, unknown>[] {
542
586
  const blocks: Record<string, unknown>[] = [];
543
- for (const c of message.content) {
587
+ for (const c of collapseFallbackSegmentsKeepFirst(message.content)) {
544
588
  switch (c.type) {
545
589
  case "text":
546
590
  blocks.push({ type: "text", text: c.text });
@@ -668,6 +712,20 @@ export function encodeStream(
668
712
  // preceding text/thinking block before it opens, so no block that is
669
713
  // still open is ever renumbered.
670
714
  const suppressed = new Set<number>();
715
+ // A persisted `fallback` marker occupies a content index but is
716
+ // never emitted — the bridge client never opted into the beta, so
717
+ // the marker cannot go on the wire. The walk below records each
718
+ // marker (and every other block this encoder cannot emit) in
719
+ // `suppressed` so `wireIndex` stays dense. Once a boundary has been
720
+ // passed with reasoning already sent, every later segment's
721
+ // reasoning is a second signed chain the client must not receive;
722
+ // the first chain is already on the wire and cannot be retracted,
723
+ // so it is the one that survives (see
724
+ // `collapseFallbackSegmentsKeepFirst`). `reasoningSeen` tracks what
725
+ // the client actually received, which is why redacted reasoning —
726
+ // which this encoder has no event for — does not set it.
727
+ let reasoningSeen = false;
728
+ let suppressLaterReasoning = false;
671
729
  const wireIndex = (contentIndex: number): number => {
672
730
  let shift = 0;
673
731
  for (const index of suppressed) {
@@ -707,6 +765,20 @@ export function encodeStream(
707
765
  while (nextContentIndexToInspect < limit) {
708
766
  const contentIndex = nextContentIndexToInspect++;
709
767
  const content = message.content[contentIndex];
768
+ if (content?.type === "fallback") {
769
+ // Never on the wire, so keep the numbering dense. Latch the
770
+ // collapse only when the client already has this turn's first
771
+ // chain: what follows would be a second one.
772
+ suppressed.add(contentIndex);
773
+ if (reasoningSeen) suppressLaterReasoning = true;
774
+ continue;
775
+ }
776
+ if (content?.type === "redactedThinking") {
777
+ // Redacted reasoning has no stream event, so it never reaches
778
+ // the client; account for its index like any other hole.
779
+ suppressed.add(contentIndex);
780
+ continue;
781
+ }
710
782
  if (content?.type !== "anthropicServerTool") continue;
711
783
  ensureStart(message);
712
784
  const index = wireIndex(contentIndex);
@@ -781,6 +853,15 @@ export function encodeStream(
781
853
  case "thinking_start": {
782
854
  emitServerToolBlocksBefore(ev.partial, ev.contentIndex);
783
855
  ensureStart(ev.partial);
856
+ if (suppressLaterReasoning) {
857
+ // A later segment's reasoning is a second signed
858
+ // chain; drop the whole block — start, deltas,
859
+ // signature and stop — and account for its index
860
+ // so later blocks stay densely numbered.
861
+ suppressed.add(ev.contentIndex);
862
+ break;
863
+ }
864
+ reasoningSeen = true;
784
865
  const index = wireIndex(ev.contentIndex);
785
866
  open.set(ev.contentIndex, { index, kind: "thinking" });
786
867
  controller.enqueue(
@@ -793,6 +874,7 @@ export function encodeStream(
793
874
  break;
794
875
  }
795
876
  case "thinking_delta":
877
+ if (suppressed.has(ev.contentIndex)) break;
796
878
  controller.enqueue(
797
879
  sseFrame("content_block_delta", {
798
880
  type: "content_block_delta",
@@ -802,6 +884,7 @@ export function encodeStream(
802
884
  );
803
885
  break;
804
886
  case "thinking_end": {
887
+ if (suppressed.has(ev.contentIndex)) break;
805
888
  const c = ev.partial.content[ev.contentIndex];
806
889
  if (c?.type === "thinking" && c.thinkingSignature) {
807
890
  controller.enqueue(