@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/CHANGELOG.md +20 -0
- package/README.md +8 -1
- package/dist/types/dialect/demotion.d.ts +8 -7
- package/dist/types/providers/anthropic-wire.d.ts +57 -1
- package/dist/types/providers/anthropic.d.ts +22 -2
- package/dist/types/providers/openai-shared.d.ts +3 -1
- package/dist/types/types.d.ts +26 -2
- package/dist/types/utils/leaked-thinking-stream.d.ts +8 -4
- package/package.json +4 -4
- package/src/auth-broker/remote-store.ts +11 -1
- package/src/dialect/demotion.ts +13 -8
- package/src/providers/anthropic-wire.ts +51 -2
- package/src/providers/anthropic.ts +222 -5
- package/src/providers/azure-openai-responses.ts +1 -0
- package/src/providers/openai-responses-reasoning-suppression.md +1 -0
- package/src/providers/openai-shared.ts +64 -23
- package/src/providers/transform-messages.ts +26 -0
- package/src/registry/coreweave.ts +4 -3
- package/src/registry/oauth/xiaomi.ts +1 -1
- package/src/stream.ts +69 -7
- package/src/types.ts +23 -2
- package/src/utils/leaked-thinking-stream.ts +8 -4
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 {
|
|
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
|
-
//
|
|
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
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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
|