@oh-my-pi/pi-ai 16.2.13 → 16.3.2

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.
@@ -17,6 +17,7 @@ import {
17
17
  COREWEAVE_PROJECT_HEADER,
18
18
  coreWeaveProjectHeaders,
19
19
  hasCoreWeaveProjectHeader,
20
+ removeBlankCoreWeaveProjectHeaders,
20
21
  } from "@oh-my-pi/pi-catalog/wire/coreweave";
21
22
  import { parseGitHubCopilotApiKey } from "@oh-my-pi/pi-catalog/wire/github-copilot";
22
23
  import {
@@ -160,6 +161,7 @@ function resolveSakanaRequestBaseUrl(): string | undefined {
160
161
  }
161
162
 
162
163
  function applyCoreWeaveProjectHeader(headers: Record<string, string>): void {
164
+ removeBlankCoreWeaveProjectHeaders(headers);
163
165
  if (hasCoreWeaveProjectHeader(headers)) {
164
166
  return;
165
167
  }
@@ -1344,6 +1346,8 @@ export interface BuildResponsesInputOptions<TApi extends Api> {
1344
1346
  includeThinkingSignatures?: boolean;
1345
1347
  developerStringContent?: boolean;
1346
1348
  repairOrphanOutputs?: boolean;
1349
+ /** Preserve assistant message item IDs from text signatures during fallback replay. */
1350
+ preserveAssistantMessageIds?: boolean;
1347
1351
  }
1348
1352
 
1349
1353
  export function buildResponsesInput<TApi extends Api>(options: BuildResponsesInputOptions<TApi>): ResponseInput {
@@ -1432,6 +1436,7 @@ export function buildResponsesInput<TApi extends Api>(options: BuildResponsesInp
1432
1436
  knownCallIds,
1433
1437
  includeThinkingSignatures,
1434
1438
  customCallIds,
1439
+ options.preserveAssistantMessageIds,
1435
1440
  );
1436
1441
  if (outputItems.length === 0) continue;
1437
1442
  messages.push(...outputItems);
@@ -1453,6 +1458,21 @@ export function buildResponsesInput<TApi extends Api>(options: BuildResponsesInp
1453
1458
  return repairOrphanResponsesToolCalls(withRepairedOutputs);
1454
1459
  }
1455
1460
 
1461
+ type ResponsesReplayAssistantMessage = Omit<ResponseOutputMessage, "id"> & { id?: string };
1462
+
1463
+ function parseResponseReasoningReplayItem(signature: string | undefined): ResponseReasoningItem | undefined {
1464
+ if (!signature) return undefined;
1465
+ try {
1466
+ const parsed = JSON.parse(signature) as unknown;
1467
+ if (!parsed || typeof parsed !== "object") return undefined;
1468
+ if (!("type" in parsed) || parsed.type !== "reasoning") return undefined;
1469
+ if (!("id" in parsed) || typeof parsed.id !== "string") return undefined;
1470
+ return parsed as ResponseReasoningItem;
1471
+ } catch {
1472
+ return undefined;
1473
+ }
1474
+ }
1475
+
1456
1476
  export function convertResponsesAssistantMessage<TApi extends Api>(
1457
1477
  assistantMsg: AssistantMessage,
1458
1478
  model: Model<TApi>,
@@ -1460,9 +1480,16 @@ export function convertResponsesAssistantMessage<TApi extends Api>(
1460
1480
  knownCallIds: Set<string>,
1461
1481
  includeThinkingSignatures = true,
1462
1482
  customCallIds?: Set<string>,
1483
+ preserveMessageIds = false,
1463
1484
  ): ResponseInput {
1464
1485
  const outputItems: ResponseInput = [];
1465
1486
  let unsignedTextBlocks = 0;
1487
+ const hasReplayableReasoningItem =
1488
+ includeThinkingSignatures &&
1489
+ assistantMsg.stopReason !== "error" &&
1490
+ assistantMsg.content.some(
1491
+ block => block.type === "thinking" && parseResponseReasoningReplayItem(block.thinkingSignature) !== undefined,
1492
+ );
1466
1493
  const isDifferentModel =
1467
1494
  assistantMsg.model !== model.id && assistantMsg.provider === model.provider && assistantMsg.api === model.api;
1468
1495
 
@@ -1471,14 +1498,8 @@ export function convertResponsesAssistantMessage<TApi extends Api>(
1471
1498
  if (!includeThinkingSignatures) {
1472
1499
  continue;
1473
1500
  }
1474
- if (block.thinkingSignature) {
1475
- try {
1476
- outputItems.push(JSON.parse(block.thinkingSignature) as ResponseReasoningItem);
1477
- } catch {
1478
- // Legacy/corrupt persisted signature — skip the reasoning item
1479
- // rather than failing the whole request build.
1480
- }
1481
- }
1501
+ const reasoningItem = parseResponseReasoningReplayItem(block.thinkingSignature);
1502
+ if (reasoningItem) outputItems.push(reasoningItem);
1482
1503
  continue;
1483
1504
  }
1484
1505
 
@@ -1486,21 +1507,30 @@ export function convertResponsesAssistantMessage<TApi extends Api>(
1486
1507
  const parsedSignature = parseTextSignature(block.textSignature);
1487
1508
  let msgId = parsedSignature?.id;
1488
1509
  if (!msgId) {
1489
- // Distinct ids per unsigned block: several text blocks in one message
1490
- // (cross-provider replay downgrades thinking text) must not share an id.
1491
- msgId = unsignedTextBlocks === 0 ? `msg_${msgIndex}` : `msg_${msgIndex}_${unsignedTextBlocks}`;
1492
- unsignedTextBlocks += 1;
1510
+ if (hasReplayableReasoningItem) {
1511
+ // Distinct ids per unsigned block: several text blocks in one message
1512
+ // (cross-provider replay downgrades thinking text) must not share an id.
1513
+ msgId = unsignedTextBlocks === 0 ? `msg_${msgIndex}` : `msg_${msgIndex}_${unsignedTextBlocks}`;
1514
+ unsignedTextBlocks += 1;
1515
+ }
1516
+ } else if (!preserveMessageIds && !hasReplayableReasoningItem) {
1517
+ // Without the matching reasoning item the server rejects replayed
1518
+ // item ids (#4173) — drop them regardless of shape, including
1519
+ // legacy plain-string signatures that would otherwise fall into
1520
+ // the >64-char hash branch and fabricate a bogus msg_ id.
1521
+ msgId = undefined;
1493
1522
  } else if (msgId.length > 64) {
1494
1523
  msgId = `msg_${Bun.hash(msgId).toString(36)}`;
1495
1524
  }
1496
- outputItems.push({
1525
+ const messageItem: ResponsesReplayAssistantMessage = {
1497
1526
  type: "message",
1498
1527
  role: "assistant",
1499
1528
  content: [{ type: "output_text", text: block.text.toWellFormed(), annotations: [] }],
1500
1529
  status: "completed",
1501
- id: msgId,
1502
- phase: parsedSignature?.phase,
1503
- } satisfies ResponseOutputMessage);
1530
+ ...(msgId ? { id: msgId } : {}),
1531
+ ...(parsedSignature?.phase ? { phase: parsedSignature.phase } : {}),
1532
+ };
1533
+ outputItems.push(messageItem as ResponseInput[number]);
1504
1534
  continue;
1505
1535
  }
1506
1536
 
@@ -1510,7 +1540,15 @@ export function convertResponsesAssistantMessage<TApi extends Api>(
1510
1540
 
1511
1541
  const normalized = normalizeResponsesToolCallId(block.id, block.customWireName ? "ctc" : "fc");
1512
1542
  let itemId: string | undefined = normalized.itemId;
1513
- if (isDifferentModel && (itemId?.startsWith("fc_") || itemId?.startsWith("fcr_") || itemId?.startsWith("ctc_"))) {
1543
+ if (
1544
+ !hasReplayableReasoningItem &&
1545
+ (itemId?.startsWith("fc_") || itemId?.startsWith("fcr_") || itemId?.startsWith("ctc_"))
1546
+ ) {
1547
+ itemId = undefined;
1548
+ } else if (
1549
+ isDifferentModel &&
1550
+ (itemId?.startsWith("fc_") || itemId?.startsWith("fcr_") || itemId?.startsWith("ctc_"))
1551
+ ) {
1514
1552
  itemId = undefined;
1515
1553
  }
1516
1554
  knownCallIds.add(normalized.callId);
@@ -1519,7 +1557,7 @@ export function convertResponsesAssistantMessage<TApi extends Api>(
1519
1557
  customCallIds?.add(normalized.callId);
1520
1558
  outputItems.push({
1521
1559
  type: "custom_tool_call",
1522
- id: itemId,
1560
+ ...(itemId ? { id: itemId } : {}),
1523
1561
  call_id: normalized.callId,
1524
1562
  name: block.customWireName,
1525
1563
  input: rawInput,
@@ -1528,7 +1566,7 @@ export function convertResponsesAssistantMessage<TApi extends Api>(
1528
1566
  }
1529
1567
  outputItems.push({
1530
1568
  type: "function_call",
1531
- id: itemId,
1569
+ ...(itemId ? { id: itemId } : {}),
1532
1570
  call_id: normalized.callId,
1533
1571
  name: block.name,
1534
1572
  arguments: JSON.stringify(block.arguments),
@@ -2379,12 +2417,11 @@ export interface ApplyResponsesCompatPolicyOptions {
2379
2417
 
2380
2418
  export function applyResponsesCompatPolicy<P extends ResponseCreateParamsStreaming>(
2381
2419
  params: P,
2382
- messages: ResponseInput,
2383
2420
  policy: OpenAICompatPolicy,
2384
2421
  options: ApplyResponsesCompatPolicyOptions | undefined,
2385
- ): number {
2422
+ ): void {
2386
2423
  const reasoning = policy.reasoning;
2387
- if (!reasoning.modelSupported) return 0;
2424
+ if (!reasoning.modelSupported) return;
2388
2425
  if (reasoning.includeEncryptedReasoning) {
2389
2426
  const include = params.include ?? [];
2390
2427
  if (!include.includes("reasoning.encrypted_content")) include.push("reasoning.encrypted_content");
@@ -2394,7 +2431,7 @@ export function applyResponsesCompatPolicy<P extends ResponseCreateParamsStreami
2394
2431
  if (reasoning.disabled) {
2395
2432
  if (reasoning.disableMode === "openrouter-enabled-false") {
2396
2433
  params.reasoning = { enabled: false } as P["reasoning"];
2397
- return 0;
2434
+ return;
2398
2435
  }
2399
2436
  if (
2400
2437
  reasoning.disableMode === "lowest-effort" &&
@@ -2404,16 +2441,9 @@ export function applyResponsesCompatPolicy<P extends ResponseCreateParamsStreami
2404
2441
  type ReasoningParam = NonNullable<ResponseCreateParamsStreaming["reasoning"]>;
2405
2442
  params.reasoning = { effort: reasoning.wireEffort as ReasoningParam["effort"] } as P["reasoning"] &
2406
2443
  ReasoningParam;
2407
- return 0;
2408
- }
2409
- if (policy.compat.requiresJuiceZeroHack && reasoning.requestedEffort === undefined) {
2410
- messages.push({
2411
- role: "developer",
2412
- content: [{ type: "input_text", text: "# Juice: 0 !important" }],
2413
- });
2414
- return 1;
2444
+ return;
2415
2445
  }
2416
- return 0;
2446
+ return;
2417
2447
  }
2418
2448
 
2419
2449
  if (reasoning.requestedEffort !== undefined || options?.reasoningSummary !== undefined) {
@@ -2422,7 +2452,7 @@ export function applyResponsesCompatPolicy<P extends ResponseCreateParamsStreami
2422
2452
  type ReasoningParam = NonNullable<ResponseCreateParamsStreaming["reasoning"]>;
2423
2453
  params.reasoning = { summary: options.reasoningSummary || "auto" } as P["reasoning"] & ReasoningParam;
2424
2454
  }
2425
- return 0;
2455
+ return;
2426
2456
  }
2427
2457
 
2428
2458
  const requested = reasoning.requestedEffort ?? "medium";
@@ -2435,17 +2465,8 @@ export function applyResponsesCompatPolicy<P extends ResponseCreateParamsStreami
2435
2465
  reasoningParams.summary = options?.reasoningSummary || "auto";
2436
2466
  }
2437
2467
  params.reasoning = reasoningParams as P["reasoning"];
2438
- return 0;
2439
- }
2440
-
2441
- if (policy.compat.requiresJuiceZeroHack) {
2442
- messages.push({
2443
- role: "developer",
2444
- content: [{ type: "input_text", text: "# Juice: 0 !important" }],
2445
- });
2446
- return 1;
2468
+ return;
2447
2469
  }
2448
- return 0;
2449
2470
  }
2450
2471
 
2451
2472
  /**
@@ -2456,14 +2477,12 @@ export function applyResponsesReasoningParams<P extends ResponseCreateParamsStre
2456
2477
  params: P,
2457
2478
  model: Model<"openai-responses" | "azure-openai-responses" | "openai-codex-responses">,
2458
2479
  options: ReasoningOptions | undefined,
2459
- messages: ResponseInput,
2460
2480
  mapEffort?: (effort: string) => string,
2461
2481
  includeEncryptedReasoning?: boolean,
2462
2482
  omitReasoningEffort?: boolean,
2463
- ): number {
2483
+ ): void {
2464
2484
  return applyResponsesCompatPolicy(
2465
2485
  params,
2466
- messages,
2467
2486
  resolveOpenAICompatPolicy(model, {
2468
2487
  endpoint: "responses",
2469
2488
  reasoning: options?.reasoning,
@@ -431,6 +431,16 @@ export function transformMessages<TApi extends Api>(
431
431
  if (!sanitized.thinkingSignature && (!sanitized.thinking || sanitized.thinking.trim() === "")) {
432
432
  return [];
433
433
  }
434
+ // Same-model Anthropic replay to a signature-enforcing endpoint
435
+ // cannot natively replay thinking blocks whose source explicitly
436
+ // recorded an empty signature, but this is not a dialect
437
+ // transition. Do not demote that sentinel into the target model's
438
+ // textual thinking dialect; keep demotion for signatures stripped
439
+ // by the untrustworthy-turn recovery above and for literal thinking
440
+ // envelopes that never carried a signature field.
441
+ if (isSameModel && isOfficialAnthropicTarget && sanitized.thinkingSignature?.trim() === "") {
442
+ return [];
443
+ }
434
444
  return sanitized;
435
445
  }
436
446
  // Cross-API target: same-model replay keeps signatures untouched
@@ -481,6 +491,22 @@ export function transformMessages<TApi extends Api>(
481
491
  return [];
482
492
  }
483
493
 
494
+ if (block.type === "fallback") {
495
+ // Server-side-fallback boundary marker (Anthropic beta
496
+ // `server-side-fallback-2026-06-01`). Only the official
497
+ // Anthropic endpoint accepts this block on replay: every
498
+ // other target either rejects unknown content blocks with a
499
+ // 400 (anthropic-compatible endpoints like Umans/Z.AI/MiniMax,
500
+ // and older omp gateways whose schema pre-dates this feature)
501
+ // or throws in its converter (Bedrock). Even the official
502
+ // replay path only accepts the block when the current request
503
+ // itself opts into the beta — but we don't know that here, so
504
+ // keep it and let `convertAnthropicMessages` re-check the
505
+ // per-request opt-in before serializing.
506
+ if (isAnthropicTarget && model.compat.officialEndpoint) return block;
507
+ return [];
508
+ }
509
+
484
510
  if (block.type === "text") {
485
511
  if (isSameModel) return block;
486
512
  return {
@@ -5,14 +5,15 @@ import { createApiKeyLogin } from "./api-key-login";
5
5
  import type { OAuthLoginCallbacks } from "./oauth/types";
6
6
  import type { ProviderDefinition } from "./types";
7
7
 
8
- const PROJECT_SETUP_INSTRUCTIONS =
9
- "Create or select a CoreWeave Serverless Inference project, set COREWEAVE_PROJECT=<team>/<project> for the OpenAI-Project header, then copy your API key from account settings";
8
+ const PROJECT_PERSIST_INSTRUCTIONS =
9
+ "add export COREWEAVE_PROJECT=<team>/<project> to your shell startup file (for example ~/.zshrc, ~/.bashrc, or your shell's profile/rc file)";
10
+ const PROJECT_SETUP_INSTRUCTIONS = `Create or select a CoreWeave Serverless Inference project, ${PROJECT_PERSIST_INSTRUCTIONS} for the OpenAI-Project header, then copy your API key from account settings`;
10
11
 
11
12
  function requireCoreWeaveProjectHeaders(): Record<string, string> {
12
13
  const headers = coreWeaveProjectHeaders($env);
13
14
  if (!headers) {
14
15
  throw new AIError.ConfigurationError(
15
- "CoreWeave Serverless Inference requires OpenAI-Project. Set COREWEAVE_PROJECT=<team>/<project> before running /login coreweave.",
16
+ `CoreWeave Serverless Inference requires OpenAI-Project. Set COREWEAVE_PROJECT=<team>/<project> before running /login coreweave. To persist it, ${PROJECT_PERSIST_INSTRUCTIONS}.`,
16
17
  );
17
18
  }
18
19
  return headers;
@@ -18,7 +18,7 @@ const STANDARD_AUTH_URL = "https://platform.xiaomimimo.com/#/console/api-keys";
18
18
  const TOKEN_PLAN_AUTH_URL = "https://platform.xiaomimimo.com/console/plan-manage";
19
19
  const STANDARD_API_BASE_URL = "https://api.xiaomimimo.com/v1";
20
20
  const TOKEN_PLAN_KEY_PREFIX = "tp-";
21
- const STANDARD_VALIDATION_MODEL = "mimo-v2-flash";
21
+ const STANDARD_VALIDATION_MODEL = "mimo-v2.5";
22
22
  const TOKEN_PLAN_VALIDATION_MODEL = "mimo-v2.5";
23
23
  const TOKEN_PLAN_SGP_API_BASE_URL = "https://token-plan-sgp.xiaomimimo.com/v1";
24
24
  const TOKEN_PLAN_AMS_API_BASE_URL = "https://token-plan-ams.xiaomimimo.com/v1";
package/src/stream.ts CHANGED
@@ -3,6 +3,7 @@ import * as fsSync from "node:fs";
3
3
  import * as fs from "node:fs/promises";
4
4
  import * as path from "node:path";
5
5
  import { scheduler } from "node:timers/promises";
6
+ import { isOfficialAnthropicApiUrl } from "@oh-my-pi/pi-catalog/compat/anthropic";
6
7
  import type { Effort } from "@oh-my-pi/pi-catalog/effort";
7
8
  import { isVertexExpressOpenAIUrl, isVertexRawPredictUrl } from "@oh-my-pi/pi-catalog/hosts";
8
9
  import {
@@ -13,7 +14,8 @@ import {
13
14
  resolveWireModelId,
14
15
  } from "@oh-my-pi/pi-catalog/model-thinking";
15
16
  import { CATALOG_PROVIDERS, type ProviderCatalogEntry } from "@oh-my-pi/pi-catalog/provider-models";
16
- import { $env, $pickenv, getConfigRootDir, isEnoent, logger } from "@oh-my-pi/pi-utils";
17
+ import { CODEX_BASE_URL } from "@oh-my-pi/pi-catalog/wire/codex";
18
+ import { $env, $pickenv, getConfigRootDir, isEnoent, logger, withExtraCaFetch } from "@oh-my-pi/pi-utils";
17
19
  import { getCustomApi } from "./api-registry";
18
20
  import { AUTH_RETRY_STEPS, isApiKeyResolver, resolveRetryKey } from "./auth-retry";
19
21
  import * as AIError from "./error";
@@ -71,6 +73,7 @@ import type {
71
73
  ToolChoice,
72
74
  } from "./types";
73
75
  import { AssistantMessageEventStream } from "./utils/event-stream";
76
+ import { isFoundryEnabled } from "./utils/foundry";
74
77
  import { wrapLeakedThinkingStream } from "./utils/leaked-thinking-stream";
75
78
  import { wrapFetchForProxy } from "./utils/proxy";
76
79
  import { withRequestDebugFetch } from "./utils/request-debug";
@@ -84,6 +87,62 @@ function isGoogleVertexAuthenticatedModel(model: Model<Api>): boolean {
84
87
  );
85
88
  }
86
89
 
90
+ /**
91
+ * Whether {@link model} is an official first-party endpoint whose stream needs
92
+ * no leaked-thinking healing — the official Anthropic API and the official
93
+ * OpenAI / OpenAI-Codex endpoints return structured thinking blocks and never
94
+ * leak reasoning idioms into the visible text channel.
95
+ *
96
+ * The gate is provider id **and** official endpoint URL: pointing
97
+ * `provider: "anthropic"` (or `openai`) at a custom proxy via `models.yml`
98
+ * still routes through {@link wrapLeakedThinkingStream}, since a third-party
99
+ * gateway may well leak. URL checks are strict (exact origin / path boundary
100
+ * or parsed hostname) — a substring match would accept lookalikes like
101
+ * `https://api.openai.com.evil/`. Anthropic Foundry (`CLAUDE_CODE_USE_FOUNDRY`)
102
+ * redirects an empty `baseUrl` to `FOUNDRY_BASE_URL`, so the check runs against
103
+ * that effective endpoint — exempt only when it resolves to the official host.
104
+ */
105
+ function isLeakedThinkingHealExempt(model: Model<Api>): boolean {
106
+ switch (model.provider) {
107
+ case "anthropic":
108
+ // Mirror resolveAnthropicBaseUrl: Foundry redirects an empty baseUrl to
109
+ // FOUNDRY_BASE_URL, so exempt only when the effective endpoint is official.
110
+ return isOfficialAnthropicApiUrl((isFoundryEnabled() && $env.FOUNDRY_BASE_URL?.trim()) || model.baseUrl);
111
+ case "openai":
112
+ return isOfficialOpenAIApiUrl(model.baseUrl);
113
+ case "openai-codex":
114
+ return isOfficialCodexApiUrl(model.baseUrl);
115
+ default:
116
+ return false;
117
+ }
118
+ }
119
+
120
+ /** Strict official-OpenAI endpoint check; missing baseUrl defaults to `api.openai.com`. */
121
+ function isOfficialOpenAIApiUrl(baseUrl: string | undefined): boolean {
122
+ if (!baseUrl) return true;
123
+ try {
124
+ return new URL(baseUrl).hostname === "api.openai.com";
125
+ } catch {
126
+ return false;
127
+ }
128
+ }
129
+
130
+ /** Strict official-Codex endpoint check; exact origin or a path boundary after {@link CODEX_BASE_URL}. */
131
+ function isOfficialCodexApiUrl(baseUrl: string | undefined): boolean {
132
+ if (!baseUrl) return true;
133
+ const lower = baseUrl.toLowerCase().replace(/\/+$/, "");
134
+ return lower === CODEX_BASE_URL || lower.startsWith(`${CODEX_BASE_URL}/`);
135
+ }
136
+
137
+ /**
138
+ * Apply live leaked-thinking healing unless {@link model} is an official
139
+ * first-party endpoint ({@link isLeakedThinkingHealExempt}), which emits
140
+ * structured thinking and needs no healing.
141
+ */
142
+ function healLeakedThinking(model: Model<Api>, inner: AssistantMessageEventStream): AssistantMessageEventStream {
143
+ return isLeakedThinkingHealExempt(model) ? inner : wrapLeakedThinkingStream(inner);
144
+ }
145
+
87
146
  type ProviderInFlightLease = {
88
147
  path: string;
89
148
  heartbeat: NodeJS.Timeout;
@@ -502,9 +561,10 @@ function withProviderInFlightLimit<TOptions extends Pick<StreamOptions, "signal"
502
561
  ): AssistantMessageEventStream {
503
562
  // Leaked-thinking healing folds in here — the one shared provider-dispatch
504
563
  // chokepoint — so the loop guard (which wraps this) sees healed events and all
505
- // six provider exits are covered by one wrap. Healing is idempotent.
564
+ // provider exits are covered by one wrap. Official first-party providers are
565
+ // exempt (see `healLeakedThinking`); healing is otherwise idempotent.
506
566
  const limit = resolveProviderInFlightLimit(model.provider, options);
507
- if (limit === undefined) return wrapLeakedThinkingStream(dispatch());
567
+ if (limit === undefined) return healLeakedThinking(model, dispatch());
508
568
 
509
569
  const outer = new AssistantMessageEventStream();
510
570
  void (async () => {
@@ -524,7 +584,7 @@ function withProviderInFlightLimit<TOptions extends Pick<StreamOptions, "signal"
524
584
  if (options?.signal?.aborted) {
525
585
  throw options.signal.reason ?? new AIError.AbortError("Provider request aborted before dispatch");
526
586
  }
527
- const inner = wrapLeakedThinkingStream(dispatch());
587
+ const inner = healLeakedThinking(model, dispatch());
528
588
  try {
529
589
  for await (const event of inner) {
530
590
  outer.push(event);
@@ -709,7 +769,7 @@ function streamDispatch<TApi extends Api>(
709
769
  options?: OptionsForApi<TApi>,
710
770
  ): AssistantMessageEventStream {
711
771
  const baseOptions = (options || {}) as StreamOptions;
712
- const debugOptions = withRequestDebugFetch(baseOptions);
772
+ const debugOptions = withExtraCaFetch(withRequestDebugFetch(baseOptions));
713
773
  const requestOptions = {
714
774
  ...debugOptions,
715
775
  fetch: wrapFetchForProxy(debugOptions.fetch ?? (globalThis.fetch as FetchImpl), model.provider),
@@ -943,7 +1003,7 @@ export function streamSimple<TApi extends Api>(
943
1003
  options?: SimpleStreamOptions,
944
1004
  ): AssistantMessageEventStream {
945
1005
  const baseOptions = (options || {}) as SimpleStreamOptions;
946
- const debugOptions = withRequestDebugFetch(baseOptions);
1006
+ const debugOptions = withExtraCaFetch(withRequestDebugFetch(baseOptions));
947
1007
  const requestOptions = {
948
1008
  ...debugOptions,
949
1009
  fetch: wrapFetchForProxy(debugOptions.fetch ?? (globalThis.fetch as FetchImpl), model.provider),
@@ -1110,7 +1170,8 @@ export function streamSimple<TApi extends Api>(
1110
1170
  // GitLab Duo Workflow - IDE workflow protocol + WebSocket action bridge
1111
1171
  if (model.api === "gitlab-duo-agent") {
1112
1172
  // Does not route through withProviderInFlightLimit, so heal explicitly.
1113
- return wrapLeakedThinkingStream(
1173
+ return healLeakedThinking(
1174
+ model,
1114
1175
  streamGitLabDuoWorkflow(model as Model<"gitlab-duo-agent">, context, {
1115
1176
  ...requestOptions,
1116
1177
  apiKey,
@@ -1371,6 +1432,7 @@ function mapOptionsForApi<TApi extends Api>(
1371
1432
  onSseEvent: options?.onSseEvent,
1372
1433
  execHandlers: options?.execHandlers,
1373
1434
  fetch: options?.fetch,
1435
+ fallbacks: options?.fallbacks,
1374
1436
  };
1375
1437
 
1376
1438
  switch (model.api) {
package/src/types.ts CHANGED
@@ -25,7 +25,7 @@ import type { ZodType, z } from "zod/v4";
25
25
  import type { ApiKey } from "./auth-retry";
26
26
  import type { BedrockOptions } from "./providers/amazon-bedrock";
27
27
  import type { AnthropicOptions } from "./providers/anthropic";
28
- import type { StopDetails } from "./providers/anthropic-wire";
28
+ import type { FallbackParam, StopDetails } from "./providers/anthropic-wire";
29
29
  import type { AzureOpenAIResponsesOptions } from "./providers/azure-openai-responses";
30
30
  import type { CursorOptions } from "./providers/cursor";
31
31
  import type { DevinOptions } from "./providers/devin";
@@ -523,6 +523,13 @@ export interface SimpleStreamOptions extends Omit<StreamOptions, "apiKey"> {
523
523
  openrouterVariant?: string;
524
524
  /** Antigravity endpoint routing mode: "auto" (default with failover), "production", "sandbox". */
525
525
  antigravityEndpointMode?: "auto" | "production" | "sandbox";
526
+ /**
527
+ * Anthropic `server-side-fallback-2026-06-01` fallback chain (top-level
528
+ * `fallbacks` request field). Opt-in ONLY — leaving this undefined is
529
+ * the default and preserves the pre-fallback behavior on every
530
+ * provider. Non-Anthropic providers ignore the field.
531
+ */
532
+ fallbacks?: FallbackParam[];
526
533
  }
527
534
 
528
535
  // Generic StreamFunction with typed options
@@ -556,6 +563,20 @@ export interface RedactedThinkingContent {
556
563
  data: string;
557
564
  }
558
565
 
566
+ /**
567
+ * Anthropic server-side-fallback boundary marker persisted on assistant
568
+ * turns whose provider request opted into
569
+ * `AnthropicOptions.fallbacks`. Consumers other than the Anthropic
570
+ * provider MUST ignore it — `transformMessages` strips the block on any
571
+ * cross-provider hop and on non-official Anthropic replays, so downstream
572
+ * converters never see it.
573
+ */
574
+ export interface AnthropicFallbackContent {
575
+ type: "fallback";
576
+ from: { model: string };
577
+ to: { model: string };
578
+ }
579
+
559
580
  export interface ImageContent {
560
581
  type: "image";
561
582
  data: string; // base64 encoded image data
@@ -634,7 +655,7 @@ export interface ContextSnapshot {
634
655
 
635
656
  export interface AssistantMessage {
636
657
  role: "assistant";
637
- content: (TextContent | ThinkingContent | RedactedThinkingContent | ToolCall)[];
658
+ content: (TextContent | ThinkingContent | RedactedThinkingContent | AnthropicFallbackContent | ToolCall)[];
638
659
  api: Api;
639
660
  provider: Provider;
640
661
  model: string;
@@ -3,11 +3,15 @@
3
3
  *
4
4
  * Some providers emit their canonical reasoning idioms (` ```thinking `,
5
5
  * `<think>`, Gemma/Harmony channels, …) into the *visible* text stream instead
6
- * of a structured thinking part. {@link wrapLeakedThinkingStream} re-projects any
6
+ * of a structured thinking part. {@link wrapLeakedThinkingStream} re-projects a
7
7
  * provider stream into a fresh {@link AssistantMessageEventStream}, splitting the
8
- * leaked fences out into proper `thinking` blocks *live* as deltas arrive — so
9
- * every provider gets the same healing, not just the three with provider-local
10
- * {@link StreamMarkupHealing} loops.
8
+ * leaked fences out into proper `thinking` blocks *live* as deltas arrive.
9
+ *
10
+ * Applied to every provider stream *except* official first-party endpoints
11
+ * (the official Anthropic API and the official OpenAI / OpenAI-Codex endpoints),
12
+ * which return structured thinking and never leak — `healLeakedThinking` in
13
+ * `../stream.ts` gates the wrap so the healer cannot misfire on legitimate
14
+ * fenced content those models emit as visible text.
11
15
  *
12
16
  * The healing is idempotent: a second pass over already-clean text finds no
13
17
  * fences, so wrapping a provider that already heals (or wrapping twice) is a