@oh-my-pi/pi-ai 16.2.13 → 16.3.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/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