@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.
- package/CHANGELOG.md +26 -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-responses.d.ts +2 -3
- package/dist/types/providers/openai-shared.d.ts +5 -3
- 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 +2 -1
- package/src/providers/openai-responses.ts +12 -40
- package/src/providers/openai-shared.ts +65 -46
- 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
|
@@ -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
|
-
|
|
1475
|
-
|
|
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
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
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
|
-
|
|
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
|
|
1503
|
-
}
|
|
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 (
|
|
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
|
-
):
|
|
2422
|
+
): void {
|
|
2386
2423
|
const reasoning = policy.reasoning;
|
|
2387
|
-
if (!reasoning.modelSupported) return
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
):
|
|
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
|
|
9
|
-
"
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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
|