@oh-my-pi/pi-ai 18.9.0 → 18.9.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 +31 -0
- package/dist/types/auth-gateway/dispatch.d.ts +5 -3
- package/dist/types/judgment/index.d.ts +1 -1
- package/dist/types/judgment/native.d.ts +80 -0
- package/dist/types/judgment/openai-decisions.d.ts +3 -0
- package/dist/types/judgment/systemone.d.ts +7 -0
- package/dist/types/judgment/types.d.ts +2 -2
- package/dist/types/judgment/wire.d.ts +36 -0
- package/dist/types/providers/transform-messages.d.ts +14 -0
- package/dist/types/registry/oauth/cloudflare-workers-ai.d.ts +3 -0
- package/dist/types/utils/retry-wait.d.ts +9 -0
- package/package.json +6 -6
- package/src/auth/sqlite-credential-store.ts +4 -4
- package/src/auth-gateway/dispatch.ts +7 -5
- package/src/auth-gateway/routes/systemone.ts +16 -12
- package/src/judgment/index.ts +1 -1
- package/src/judgment/native.ts +333 -0
- package/src/judgment/openai-decisions-predicate.md +9 -0
- package/src/judgment/openai-decisions.ts +129 -0
- package/src/judgment/systemone.ts +65 -0
- package/src/judgment/types.ts +2 -2
- package/src/judgment/wire.ts +42 -0
- package/src/providers/amazon-bedrock.ts +6 -5
- package/src/providers/anthropic.ts +82 -70
- package/src/providers/cursor.ts +2 -6
- package/src/providers/devin.ts +20 -2
- package/src/providers/openai-responses.ts +7 -8
- package/src/providers/systemone-server.ts +1 -1
- package/src/providers/transform-messages.ts +146 -68
- package/src/registry/cloudflare-ai-gateway.ts +2 -2
- package/src/registry/hooks/api-key.ts +1 -0
- package/src/registry/oauth/cloudflare-ai-gateway.ts +2 -2
- package/src/registry/oauth/cloudflare-workers-ai.ts +32 -0
- package/src/usage/claude.ts +2 -6
- package/src/utils/empty-completion-retry.ts +2 -3
- package/src/utils/retry-wait.ts +19 -0
- package/dist/types/judgment/typesafe.d.ts +0 -49
- package/src/judgment/typesafe.ts +0 -183
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
|
-
import { scheduler } from "node:timers/promises";
|
|
3
2
|
import * as tls from "node:tls";
|
|
4
3
|
import { isAnthropicSigningProxyUrl, isOfficialAnthropicApiUrl } from "@oh-my-pi/pi-catalog/compat/anthropic";
|
|
5
4
|
import { hostMatchesUrl, isVertexRawPredictUrl } from "@oh-my-pi/pi-catalog/hosts";
|
|
@@ -20,6 +19,7 @@ import { NO_AUTH_SENTINEL } from "../auth-retry";
|
|
|
20
19
|
import { renderDemotedThinking } from "../dialect/demotion";
|
|
21
20
|
import * as AIError from "../error";
|
|
22
21
|
import { parseToolCallArguments } from "../utils/tool-call-arguments";
|
|
22
|
+
import { waitForRetry } from "../utils/retry-wait";
|
|
23
23
|
import { getEnvApiKey } from "../env-api-key";
|
|
24
24
|
import { OUTPUT_FALLBACK_BUFFER } from "../stream";
|
|
25
25
|
import type {
|
|
@@ -3582,11 +3582,7 @@ const streamAnthropicOnce = (
|
|
|
3582
3582
|
attempt: fallbackCreditTransientRetries,
|
|
3583
3583
|
},
|
|
3584
3584
|
);
|
|
3585
|
-
|
|
3586
|
-
await options.providerRetryWait(500, options.signal);
|
|
3587
|
-
} else {
|
|
3588
|
-
await scheduler.wait(500, { signal: options?.signal });
|
|
3589
|
-
}
|
|
3585
|
+
await waitForRetry(options?.providerRetryWait, 500, options?.signal);
|
|
3590
3586
|
resetStreamOutputState();
|
|
3591
3587
|
continue;
|
|
3592
3588
|
}
|
|
@@ -3853,11 +3849,7 @@ const streamAnthropicOnce = (
|
|
|
3853
3849
|
attempt: slowWaitAttempts,
|
|
3854
3850
|
});
|
|
3855
3851
|
if (slowRetry.delayMs > 0) {
|
|
3856
|
-
|
|
3857
|
-
await options.providerRetryWait(slowRetry.delayMs, options.signal);
|
|
3858
|
-
} else {
|
|
3859
|
-
await scheduler.wait(slowRetry.delayMs, { signal: options?.signal });
|
|
3860
|
-
}
|
|
3852
|
+
await waitForRetry(options?.providerRetryWait, slowRetry.delayMs, options?.signal);
|
|
3861
3853
|
}
|
|
3862
3854
|
resetStreamOutputState();
|
|
3863
3855
|
continue;
|
|
@@ -3923,11 +3915,7 @@ const streamAnthropicOnce = (
|
|
|
3923
3915
|
throw streamFailure;
|
|
3924
3916
|
}
|
|
3925
3917
|
const delayMs = headerDelayMs !== undefined ? Math.max(headerDelayMs, backoffDelayMs) : backoffDelayMs;
|
|
3926
|
-
|
|
3927
|
-
await options.providerRetryWait(delayMs, options.signal);
|
|
3928
|
-
} else {
|
|
3929
|
-
await scheduler.wait(delayMs, { signal: options?.signal });
|
|
3930
|
-
}
|
|
3918
|
+
await waitForRetry(options?.providerRetryWait, delayMs, options?.signal);
|
|
3931
3919
|
output.content.length = 0;
|
|
3932
3920
|
output.model = model.id;
|
|
3933
3921
|
output.responseId = undefined;
|
|
@@ -4421,6 +4409,17 @@ function applyCacheControlToLastBlock(blocks: ContentBlockParam[], cacheControl:
|
|
|
4421
4409
|
return false;
|
|
4422
4410
|
}
|
|
4423
4411
|
|
|
4412
|
+
/** True for a mid-conversation tool-control message (`role: "system"`, tool-change blocks only). */
|
|
4413
|
+
function isToolControlMessage(message: MessageParam): boolean {
|
|
4414
|
+
return (
|
|
4415
|
+
message.role === "system" &&
|
|
4416
|
+
typeof message.content !== "string" &&
|
|
4417
|
+
Array.isArray(message.content) &&
|
|
4418
|
+
message.content.length > 0 &&
|
|
4419
|
+
message.content.every(block => block.type === "tool_addition" || block.type === "tool_removal")
|
|
4420
|
+
);
|
|
4421
|
+
}
|
|
4422
|
+
|
|
4424
4423
|
const ANTHROPIC_MAX_BREAKPOINTS = 4;
|
|
4425
4424
|
const ANTHROPIC_DECIMATION_INTERVAL = 15;
|
|
4426
4425
|
|
|
@@ -4482,85 +4481,98 @@ function applyPromptCaching(params: MessageCreateParamsStreaming, cacheControl?:
|
|
|
4482
4481
|
// the suffix after it. Turn-scoped `clear_at` messages are absent next
|
|
4483
4482
|
// request, so they still truncate the decimation range (ordinals would
|
|
4484
4483
|
// shift), but per-call marks no longer freeze the tail.
|
|
4485
|
-
|
|
4486
|
-
|
|
4487
|
-
|
|
4488
|
-
|
|
4489
|
-
|
|
4490
|
-
|
|
4491
|
-
|
|
4492
|
-
|
|
4493
|
-
|
|
4494
|
-
//
|
|
4495
|
-
|
|
4496
|
-
|
|
4497
|
-
// interior `Continue.` pad, none of which advance the user turn ordinal.
|
|
4498
|
-
const userIndices: number[] = [];
|
|
4499
|
-
for (let index = 0; index <= stableMessageEnd; index++) {
|
|
4500
|
-
const message = params.messages[index];
|
|
4501
|
-
if (message && isConversationalUser(message)) {
|
|
4502
|
-
userIndices.push(index);
|
|
4503
|
-
}
|
|
4504
|
-
}
|
|
4505
|
-
|
|
4506
|
-
// Stable historical decimation checkpoint every 15 user turns (15th, 30th, 45th...)
|
|
4507
|
-
const decimationIndices = userIndices.filter((_, ordinal) => (ordinal + 1) % ANTHROPIC_DECIMATION_INTERVAL === 0);
|
|
4508
|
-
|
|
4484
|
+
//
|
|
4485
|
+
// One backward walk over the message tail resolves everything the anchor
|
|
4486
|
+
// selection needs at once: the trailing candidates, the stable prefix end,
|
|
4487
|
+
// and the conversational-turn count under it. The first per-call/turn-scoped
|
|
4488
|
+
// message from the start is the last mark this walk sees (so `markIndex` ends
|
|
4489
|
+
// up as that boundary), and the turn counter — reset on every mark — ends up
|
|
4490
|
+
// counting exactly the conversational turns before the first mark (the range
|
|
4491
|
+
// the old forward scan covered). Nothing is materialized: the checkpoint
|
|
4492
|
+
// search below walks the same history backwards and stops as soon as the
|
|
4493
|
+
// breakpoint budget is spent.
|
|
4494
|
+
let markIndex = -1;
|
|
4495
|
+
let stableUserTurns = 0;
|
|
4509
4496
|
// Collect up to 2 trailing candidates from the message tail, skipping
|
|
4510
4497
|
// per-call messages, turn-scoped messages, and mid-conversation
|
|
4511
4498
|
// tool-control messages. A per-call tail candidate is rebuilt next request
|
|
4512
4499
|
// (fresh timestamps on appended probes, fresh redaction bytes), so a
|
|
4513
4500
|
// breakpoint on it cannot match — it would spend the tail anchor on bytes
|
|
4514
|
-
// that never repeat while the persisted history behind it
|
|
4501
|
+
// that never repeat while the persisted history behind it gets uncached.
|
|
4515
4502
|
// Turn-scoped messages are absent next request for the same reason, and
|
|
4516
4503
|
// tool controls reject cache_control outright. The walk starts at the
|
|
4517
4504
|
// message tail (not the truncated prefix end) so the anchor advances every
|
|
4518
4505
|
// turn; the sub-prefix candidate below covers the reusable region behind
|
|
4519
4506
|
// a mark.
|
|
4520
4507
|
const trailingCandidates: number[] = [];
|
|
4521
|
-
for (let index = messageEnd; index >= 0
|
|
4508
|
+
for (let index = messageEnd; index >= 0; index--) {
|
|
4522
4509
|
const message = params.messages[index];
|
|
4523
|
-
if (!message
|
|
4524
|
-
if (
|
|
4525
|
-
|
|
4526
|
-
|
|
4527
|
-
Array.isArray(message.content) &&
|
|
4528
|
-
message.content.length > 0 &&
|
|
4529
|
-
message.content.every(block => block.type === "tool_addition" || block.type === "tool_removal")
|
|
4530
|
-
) {
|
|
4510
|
+
if (!message) continue;
|
|
4511
|
+
if (message.clear_at === "next_user_message" || isPerCallContextMessage(message)) {
|
|
4512
|
+
markIndex = index;
|
|
4513
|
+
stableUserTurns = 0;
|
|
4531
4514
|
continue;
|
|
4532
4515
|
}
|
|
4533
|
-
trailingCandidates.
|
|
4534
|
-
|
|
4535
|
-
|
|
4516
|
+
if (trailingCandidates.length < 2 && !isToolControlMessage(message)) {
|
|
4517
|
+
trailingCandidates.push(index);
|
|
4518
|
+
}
|
|
4519
|
+
// Decimation counts conversational turns, so it reads the provenance marker
|
|
4520
|
+
// `convertAnthropicMessages` records rather than the wire role. A wire `user`
|
|
4521
|
+
// can also be a serialized `developer` message, a tool_result run, or an
|
|
4522
|
+
// interior `Continue.` pad, none of which advance the user turn ordinal.
|
|
4523
|
+
if (isConversationalUser(message)) stableUserTurns++;
|
|
4524
|
+
}
|
|
4525
|
+
const stableMessageEnd = markIndex === -1 ? messageEnd : markIndex - 1;
|
|
4526
|
+
|
|
4527
|
+
// Decimation checkpoints are generated newest-first on demand instead of
|
|
4528
|
+
// being materialized as index arrays: the newest sits `stableUserTurns %
|
|
4529
|
+
// ANTHROPIC_DECIMATION_INTERVAL` conversational turns back from the stable
|
|
4530
|
+
// prefix end and every 15th turn follows it, so the walk stops as soon as the
|
|
4531
|
+
// breakpoint budget is spent. Candidates repeat when a trailing or sub-prefix
|
|
4532
|
+
// anchor is already a checkpoint; re-decorating the same message is a no-op
|
|
4533
|
+
// (its block already carries cache_control), so the budget accounting is
|
|
4534
|
+
// unchanged.
|
|
4535
|
+
//
|
|
4536
|
+
// Priority order:
|
|
4536
4537
|
// 1. Most recent trailing message
|
|
4537
4538
|
// 2. Latest decimation checkpoints (newest first) to maintain stable long-context anchors
|
|
4538
4539
|
// 3. Newest message at or before the first per-call/turn-scoped mark, so a
|
|
4539
4540
|
// volatile interior message costs only its own re-billed bytes instead
|
|
4540
4541
|
// of invalidating the whole reusable prefix behind it
|
|
4541
4542
|
// 4. Second trailing message
|
|
4542
|
-
|
|
4543
|
-
|
|
4544
|
-
|
|
4545
|
-
|
|
4546
|
-
|
|
4547
|
-
|
|
4548
|
-
|
|
4543
|
+
//
|
|
4544
|
+
// Count only successful block decorations toward the message budget so an uncacheable
|
|
4545
|
+
// block (such as a thinking-only assistant) does not silently consume a breakpoint.
|
|
4546
|
+
let appliedCount = 0;
|
|
4547
|
+
if (trailingCandidates.length > 0 && appliedCount < messageBudget) {
|
|
4548
|
+
const message = params.messages[trailingCandidates[0]];
|
|
4549
|
+
if (message && applyCacheControlToMessage(message, cacheControl)) {
|
|
4550
|
+
appliedCount++;
|
|
4549
4551
|
}
|
|
4550
4552
|
}
|
|
4551
|
-
|
|
4552
|
-
|
|
4553
|
+
// Stable historical decimation checkpoint every 15 user turns (15th, 30th, 45th...)
|
|
4554
|
+
if (stableUserTurns >= ANTHROPIC_DECIMATION_INTERVAL) {
|
|
4555
|
+
let turnsSinceCheckpoint = stableUserTurns % ANTHROPIC_DECIMATION_INTERVAL;
|
|
4556
|
+
for (let index = stableMessageEnd; index >= 0 && appliedCount < messageBudget; index--) {
|
|
4557
|
+
const message = params.messages[index];
|
|
4558
|
+
if (!message || !isConversationalUser(message)) continue;
|
|
4559
|
+
if (turnsSinceCheckpoint > 0) {
|
|
4560
|
+
turnsSinceCheckpoint--;
|
|
4561
|
+
continue;
|
|
4562
|
+
}
|
|
4563
|
+
turnsSinceCheckpoint = ANTHROPIC_DECIMATION_INTERVAL - 1;
|
|
4564
|
+
if (applyCacheControlToMessage(message, cacheControl)) {
|
|
4565
|
+
appliedCount++;
|
|
4566
|
+
}
|
|
4567
|
+
}
|
|
4553
4568
|
}
|
|
4554
|
-
|
|
4555
|
-
|
|
4556
|
-
|
|
4569
|
+
if (stableMessageEnd < messageEnd && stableMessageEnd >= 0 && appliedCount < messageBudget) {
|
|
4570
|
+
const message = params.messages[stableMessageEnd];
|
|
4571
|
+
if (message && applyCacheControlToMessage(message, cacheControl)) {
|
|
4572
|
+
appliedCount++;
|
|
4557
4573
|
}
|
|
4558
4574
|
}
|
|
4559
|
-
|
|
4560
|
-
// Count only successful block decorations toward the message budget so an uncacheable
|
|
4561
|
-
// block (such as a thinking-only assistant) does not silently consume a breakpoint.
|
|
4562
|
-
let appliedCount = 0;
|
|
4563
|
-
for (const index of candidateIndices) {
|
|
4575
|
+
for (const index of trailingCandidates) {
|
|
4564
4576
|
if (appliedCount >= messageBudget) break;
|
|
4565
4577
|
const message = params.messages[index];
|
|
4566
4578
|
if (message && applyCacheControlToMessage(message, cacheControl)) {
|
package/src/providers/cursor.ts
CHANGED
|
@@ -2,7 +2,6 @@ import * as fs from "node:fs/promises";
|
|
|
2
2
|
import http2 from "node:http2";
|
|
3
3
|
import { cursorModelParameters } from "@oh-my-pi/pi-catalog/compat/behavior";
|
|
4
4
|
import { isCursorMaxModeWireId } from "@oh-my-pi/pi-catalog/compat/collapse";
|
|
5
|
-
import { scheduler } from "node:timers/promises";
|
|
6
5
|
import { classifyModel, collapseVariantId } from "@oh-my-pi/pi-catalog/compat/taxonomy";
|
|
7
6
|
import type {
|
|
8
7
|
ConversationStep,
|
|
@@ -227,6 +226,7 @@ import { deterministicUuid } from "../utils/deterministic-id";
|
|
|
227
226
|
import { AssistantMessageEventStream } from "../utils/event-stream";
|
|
228
227
|
import { connectProxiedSocket, getProxyForUrl, wrapFetchForProxy } from "../utils/proxy";
|
|
229
228
|
import { createRequestDebugSession, isRequestDebugEnabled, type RequestDebugResponseLog } from "../utils/request-debug";
|
|
229
|
+
import { waitForRetry } from "../utils/retry-wait";
|
|
230
230
|
import { sanitizeSchemaForCursor, toolWireSchema } from "../utils/schema";
|
|
231
231
|
import { formatConnectEndStreamError, hasRetryableCursorErrorDetail } from "./connect-error-detail";
|
|
232
232
|
import { CONNECT_END_STREAM_FLAG, ConnectFrameDecoder, frameConnectMessage } from "./connect-frame";
|
|
@@ -1759,11 +1759,7 @@ function streamCursorWithWireMode(
|
|
|
1759
1759
|
});
|
|
1760
1760
|
let retryDelayCompleted = false;
|
|
1761
1761
|
try {
|
|
1762
|
-
|
|
1763
|
-
await options.providerRetryWait(delayMs, options.signal);
|
|
1764
|
-
} else {
|
|
1765
|
-
await scheduler.wait(delayMs, { signal: options?.signal });
|
|
1766
|
-
}
|
|
1762
|
+
await waitForRetry(options?.providerRetryWait, delayMs, options?.signal);
|
|
1767
1763
|
retryDelayCompleted = !options?.signal?.aborted;
|
|
1768
1764
|
} catch (waitError) {
|
|
1769
1765
|
error = waitError;
|
package/src/providers/devin.ts
CHANGED
|
@@ -61,7 +61,7 @@ import {
|
|
|
61
61
|
ConnectFrameDecoder,
|
|
62
62
|
frameConnectMessage,
|
|
63
63
|
} from "./connect-frame";
|
|
64
|
-
import { transformMessages } from "./transform-messages";
|
|
64
|
+
import { isResponsesFamilyApi, responsesCallComponent, transformMessages } from "./transform-messages";
|
|
65
65
|
|
|
66
66
|
/** Base host for Codeium/Windsurf's Cascade chat API (Connect protocol over HTTP/1.1). */
|
|
67
67
|
export const DEVIN_API_URL = DEVIN_DEFAULT_BASE_URL;
|
|
@@ -79,6 +79,13 @@ const CHAT_MESSAGE_PATH = "/exa.api_server_pb.ApiServerService/GetChatMessage";
|
|
|
79
79
|
const DEVIN_ASSIGN_MODEL_PATH = "/exa.api_server_pb.ApiServerService/AssignModel";
|
|
80
80
|
const DEVIN_AUTH_PATH = "/exa.auth_pb.AuthService/GetUserJwt";
|
|
81
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Cascade forwards replayed tool-call ids to the backend serving the chat model;
|
|
84
|
+
* OpenAI-routed models reject ids past OpenAI's 40-char cap or outside
|
|
85
|
+
* `[a-zA-Z0-9_-]` with an opaque `invalid_argument` (#15234).
|
|
86
|
+
*/
|
|
87
|
+
const DEVIN_TOOL_CALL_ID_MAX_LENGTH = 40;
|
|
88
|
+
|
|
82
89
|
/**
|
|
83
90
|
* Hard upper bound on a single Connect frame payload. The 4-byte length prefix
|
|
84
91
|
* is otherwise attacker-controlled (up to `2**32 - 1`), so a malicious or buggy
|
|
@@ -230,7 +237,18 @@ export const streamDevin: StreamFunction<"devin-agent"> = (
|
|
|
230
237
|
apiKey: auth.apiKey,
|
|
231
238
|
userJwt: auth.userJwt,
|
|
232
239
|
cascadeId: options?.conversationId ?? options?.sessionId ?? crypto.randomUUID(),
|
|
233
|
-
|
|
240
|
+
// Foreign ids (cross-model calls and their results alike): Responses
|
|
241
|
+
// composites (`call_id|item_id`) keep their wire `call_id`; opaque ids
|
|
242
|
+
// from other APIs keep every segment. Both are then sanitized and capped.
|
|
243
|
+
messages: transformMessages(
|
|
244
|
+
context.messages,
|
|
245
|
+
model,
|
|
246
|
+
(id, _target, source) =>
|
|
247
|
+
(isResponsesFamilyApi(source.api) ? responsesCallComponent(id) : id)
|
|
248
|
+
.replace(/[^a-zA-Z0-9_-]/g, "_")
|
|
249
|
+
.slice(0, DEVIN_TOOL_CALL_ID_MAX_LENGTH),
|
|
250
|
+
DEVIN_TOOL_CALL_ID_MAX_LENGTH,
|
|
251
|
+
),
|
|
234
252
|
};
|
|
235
253
|
// Router models (`adaptive`) are not valid chat model uids: the server
|
|
236
254
|
// resolves them through AssignModel and expects the returned uid plus
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { scheduler } from "node:timers/promises";
|
|
2
1
|
import type { Effort } from "@oh-my-pi/pi-catalog/effort";
|
|
3
2
|
import {
|
|
4
3
|
$flag,
|
|
@@ -40,6 +39,7 @@ import {
|
|
|
40
39
|
} from "../utils/idle-iterator";
|
|
41
40
|
import { OpenAIHttpError, postOpenAIStream } from "../utils/openai-http";
|
|
42
41
|
import { notifyProviderResponse } from "../utils/provider-response";
|
|
42
|
+
import { waitForRetry } from "../utils/retry-wait";
|
|
43
43
|
import {
|
|
44
44
|
adaptSchemaForStrict,
|
|
45
45
|
findStrictToolSchemaViolation,
|
|
@@ -249,8 +249,7 @@ export async function pollOpenAIResponsesResultForCompletion(args: {
|
|
|
249
249
|
if (attempt > 0) {
|
|
250
250
|
if (Date.now() + OPENAI_RESPONSES_RESUME_POLL_INTERVAL_MS >= deadline) return undefined;
|
|
251
251
|
try {
|
|
252
|
-
|
|
253
|
-
else await scheduler.wait(OPENAI_RESPONSES_RESUME_POLL_INTERVAL_MS, { signal: args.signal });
|
|
252
|
+
await waitForRetry(args.wait, OPENAI_RESPONSES_RESUME_POLL_INTERVAL_MS, args.signal);
|
|
254
253
|
} catch {
|
|
255
254
|
return undefined;
|
|
256
255
|
}
|
|
@@ -1268,11 +1267,11 @@ const streamOpenAIResponsesOnce = (
|
|
|
1268
1267
|
firstTokenTime = undefined;
|
|
1269
1268
|
nativeOutputItems.length = 0;
|
|
1270
1269
|
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1270
|
+
await waitForRetry(
|
|
1271
|
+
options?.providerRetryWait,
|
|
1272
|
+
OPENAI_RESPONSES_TRANSIENT_STREAM_RETRY_DELAY_MS,
|
|
1273
|
+
options?.signal,
|
|
1274
|
+
);
|
|
1276
1275
|
if (abortTracker.wasCallerAbort()) throw new AIError.AbortError();
|
|
1277
1276
|
openaiStream = await openResponsesStreamWithFallbacks();
|
|
1278
1277
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Server side of TypeSafe's System One wire format (`POST /v1/systemone`),
|
|
3
|
-
* served by the auth-gateway so TypeSafe SDKs and omp's own `
|
|
3
|
+
* served by the auth-gateway so TypeSafe SDKs and omp's own `NativeJudge`
|
|
4
4
|
* can point `TYPESAFE_BASE_URL` at the gateway and never hold the real key.
|
|
5
5
|
*
|
|
6
6
|
* Requests are validated only as far as routing needs — the `model` id and
|
|
@@ -43,7 +43,7 @@ export const MAX_TOOL_CALL_ID_LENGTH = 64;
|
|
|
43
43
|
* ids verbatim as provider correlation tokens), so ONLY these origins may be
|
|
44
44
|
* canonicalized to their `call_` component for pairing.
|
|
45
45
|
*/
|
|
46
|
-
function isResponsesFamilyApi(api: Api | undefined): boolean {
|
|
46
|
+
export function isResponsesFamilyApi(api: Api | undefined): boolean {
|
|
47
47
|
return api === "openai-responses" || api === "openai-codex-responses" || api === "azure-openai-responses";
|
|
48
48
|
}
|
|
49
49
|
|
|
@@ -53,7 +53,7 @@ function isResponsesFamilyApi(api: Api | undefined): boolean {
|
|
|
53
53
|
* index 0) keeps its full id so unrelated empty-half ids never collapse onto
|
|
54
54
|
* one empty-string bucket.
|
|
55
55
|
*/
|
|
56
|
-
function responsesCallComponent(id: string): string {
|
|
56
|
+
export function responsesCallComponent(id: string): string {
|
|
57
57
|
const pipe = id.indexOf("|");
|
|
58
58
|
return pipe <= 0 ? id : id.slice(0, pipe);
|
|
59
59
|
}
|
|
@@ -82,19 +82,27 @@ interface ToolCallOriginScope {
|
|
|
82
82
|
opaqueCompositeCallIds: ReadonlySet<string>;
|
|
83
83
|
}
|
|
84
84
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
85
|
+
/**
|
|
86
|
+
* Record one assistant turn's tool-call ids into the origin-scope sets.
|
|
87
|
+
*
|
|
88
|
+
* Called from the normalization map (`transformMessages`) in document order, on
|
|
89
|
+
* the SAME post-transform content that message emits, so the two sets cover
|
|
90
|
+
* every assistant turn exactly once and hold its post-normalization, pre-dedup
|
|
91
|
+
* ids (`_dupN` replacements minted later by dedup are never scope members).
|
|
92
|
+
* Both sets are only read via `.has`, so ordering is inert.
|
|
93
|
+
*/
|
|
94
|
+
function recordToolCallOriginScope(
|
|
95
|
+
msg: AssistantMessage,
|
|
96
|
+
content: Readonly<AssistantMessage["content"]>,
|
|
97
|
+
responsesComponents: Set<string>,
|
|
98
|
+
opaqueCompositeCallIds: Set<string>,
|
|
99
|
+
): void {
|
|
100
|
+
const responsesOrigin = isResponsesFamilyApi(msg.api);
|
|
101
|
+
for (const block of content) {
|
|
102
|
+
if (block.type !== "toolCall") continue;
|
|
103
|
+
if (responsesOrigin) responsesComponents.add(responsesCallComponent(block.id));
|
|
104
|
+
else if (block.id.includes("|")) opaqueCompositeCallIds.add(block.id);
|
|
96
105
|
}
|
|
97
|
-
return { responsesComponents, opaqueCompositeCallIds };
|
|
98
106
|
}
|
|
99
107
|
|
|
100
108
|
/**
|
|
@@ -158,16 +166,70 @@ function appendSegmentDuplicateSuffix(segment: string, suffix: string, maxLength
|
|
|
158
166
|
|
|
159
167
|
type PendingToolResultRewrite = { replacementId: string } | undefined;
|
|
160
168
|
|
|
169
|
+
/** A real (non-synthetic) tool result filed at its position in the transformed transcript. */
|
|
170
|
+
interface IndexedToolResult {
|
|
171
|
+
index: number;
|
|
172
|
+
msg: ToolResultMessage;
|
|
173
|
+
consumed: boolean;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* File a SURVIVING (post-dedup) tool result into the request-wide index.
|
|
178
|
+
*
|
|
179
|
+
* Called from the dedup pass, once per tool result message, at the position the
|
|
180
|
+
* message occupies in that pass's output array — so the entries and their order
|
|
181
|
+
* describe the transcript the request will actually carry.
|
|
182
|
+
* The caller passes the pairing key for the id that survived the pass (a `_dup`
|
|
183
|
+
* rewrite changes it), which is what the consumers downstream must pair against.
|
|
184
|
+
*/
|
|
185
|
+
function indexToolResult(
|
|
186
|
+
msg: ToolResultMessage,
|
|
187
|
+
index: number,
|
|
188
|
+
key: string,
|
|
189
|
+
realToolResultsById: Map<string, IndexedToolResult[]>,
|
|
190
|
+
): void {
|
|
191
|
+
const entry: IndexedToolResult = { index, msg, consumed: false };
|
|
192
|
+
const entries = realToolResultsById.get(key);
|
|
193
|
+
if (entries) entries.push(entry);
|
|
194
|
+
else realToolResultsById.set(key, [entry]);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Outputs of {@link deduplicateToolCallIds}.
|
|
199
|
+
*
|
|
200
|
+
* Both indexes describe the pass's OWN output — a `_dup` rewrite changes ids — so
|
|
201
|
+
* callers read them only after the pass completes, and no caller can hand in a
|
|
202
|
+
* pre-filled container.
|
|
203
|
+
*/
|
|
204
|
+
interface DeduplicatedToolCallIds {
|
|
205
|
+
messages: Message[];
|
|
206
|
+
/** Every real tool result filed at its position, keyed by pairing key. */
|
|
207
|
+
realToolResultsById: Map<string, IndexedToolResult[]>;
|
|
208
|
+
/** Pairing key of every `tool_use` id the pass left standing. */
|
|
209
|
+
validToolUseIds: Set<string>;
|
|
210
|
+
}
|
|
211
|
+
|
|
161
212
|
function deduplicateToolCallIds(
|
|
162
213
|
messages: Message[],
|
|
163
214
|
originScope: ToolCallOriginScope,
|
|
164
|
-
maxToolCallIdLength
|
|
165
|
-
duplicateSuffixPrefix
|
|
166
|
-
):
|
|
215
|
+
maxToolCallIdLength: number,
|
|
216
|
+
duplicateSuffixPrefix: string,
|
|
217
|
+
): DeduplicatedToolCallIds {
|
|
167
218
|
const seenToolCallIds = new Map<string, number>();
|
|
168
219
|
const pendingToolResultRewrites = new Map<string, PendingToolResultRewrite[]>();
|
|
220
|
+
const realToolResultsById = new Map<string, IndexedToolResult[]>();
|
|
221
|
+
const validToolUseIds = new Set<string>();
|
|
169
222
|
|
|
170
|
-
|
|
223
|
+
const enqueueToolResultRewrite = (id: string, rewrite: PendingToolResultRewrite): void => {
|
|
224
|
+
const rewrites = pendingToolResultRewrites.get(id);
|
|
225
|
+
if (rewrites) {
|
|
226
|
+
rewrites.push(rewrite);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
pendingToolResultRewrites.set(id, [rewrite]);
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const transformed = messages.map((msg, index) => {
|
|
171
233
|
if (msg.role === "toolResult") {
|
|
172
234
|
// Pair on the call_ component: a composite result id
|
|
173
235
|
// (`call_X|fc_Y`) must find the rewrite enqueued under its assistant
|
|
@@ -176,29 +238,34 @@ function deduplicateToolCallIds(
|
|
|
176
238
|
// result is dropped downstream.
|
|
177
239
|
const key = toolCallPairingKey(msg.toolCallId, originScope);
|
|
178
240
|
const rewrites = pendingToolResultRewrites.get(key);
|
|
179
|
-
if (!rewrites || rewrites.length === 0)
|
|
241
|
+
if (!rewrites || rewrites.length === 0) {
|
|
242
|
+
indexToolResult(msg, index, key, realToolResultsById);
|
|
243
|
+
return msg;
|
|
244
|
+
}
|
|
180
245
|
|
|
181
246
|
const rewrite = rewrites.shift();
|
|
182
247
|
if (rewrites.length === 0) pendingToolResultRewrites.delete(key);
|
|
183
|
-
if (rewrite)
|
|
248
|
+
if (rewrite) {
|
|
249
|
+
const rewritten = { ...msg, toolCallId: rewrite.replacementId };
|
|
250
|
+
indexToolResult(
|
|
251
|
+
rewritten,
|
|
252
|
+
index,
|
|
253
|
+
toolCallPairingKey(rewritten.toolCallId, originScope),
|
|
254
|
+
realToolResultsById,
|
|
255
|
+
);
|
|
256
|
+
return rewritten;
|
|
257
|
+
}
|
|
258
|
+
indexToolResult(msg, index, key, realToolResultsById);
|
|
184
259
|
return msg;
|
|
185
260
|
}
|
|
186
261
|
|
|
187
262
|
if (msg.role !== "assistant") return msg;
|
|
188
263
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
}
|
|
195
|
-
pendingToolResultRewrites.set(id, [rewrite]);
|
|
196
|
-
};
|
|
197
|
-
|
|
198
|
-
// Ids this turn has already touched; used to scope the "drop carried-over
|
|
199
|
-
// pending rewrites" semantics to the FIRST occurrence per turn so multiple
|
|
200
|
-
// blocks of the same id within one turn still accumulate as duplicates.
|
|
201
|
-
const idsTouchedInTurn = new Set<string>();
|
|
264
|
+
// Ids this turn has already touched; scopes the "drop carried-over pending
|
|
265
|
+
// rewrites" step to an id's FIRST occurrence per turn, so multiple blocks
|
|
266
|
+
// of the same id within one turn still accumulate as duplicates. Empty
|
|
267
|
+
// sets always miss `.has`, so turns with no tool call allocate nothing.
|
|
268
|
+
let idsTouchedInTurn: Set<string> | undefined;
|
|
202
269
|
let contentChanged = false;
|
|
203
270
|
const content = msg.content.map(block => {
|
|
204
271
|
if (block.type !== "toolCall") return block;
|
|
@@ -217,9 +284,9 @@ function deduplicateToolCallIds(
|
|
|
217
284
|
// "No result provided" for it, and the upcoming real result(id) must
|
|
218
285
|
// route to one of THIS turn's calls. Without this guard the older
|
|
219
286
|
// `_dup` id would steal the next result.
|
|
220
|
-
if (!idsTouchedInTurn
|
|
287
|
+
if (!idsTouchedInTurn?.has(blockKey)) {
|
|
288
|
+
(idsTouchedInTurn ??= new Set()).add(blockKey);
|
|
221
289
|
pendingToolResultRewrites.delete(blockKey);
|
|
222
|
-
idsTouchedInTurn.add(blockKey);
|
|
223
290
|
}
|
|
224
291
|
|
|
225
292
|
const previousCount = seenToolCallIds.get(blockKey) ?? 0;
|
|
@@ -250,9 +317,20 @@ function deduplicateToolCallIds(
|
|
|
250
317
|
return { ...block, id: replacementId };
|
|
251
318
|
});
|
|
252
319
|
|
|
320
|
+
// Surviving `tool_use` pairing keys, read off the SAME post-dedup content the
|
|
321
|
+
// pass emits — a rewrite changes the id, so the keys must come from here and
|
|
322
|
+
// not from the input message. Recorded before the early-out because
|
|
323
|
+
// `!contentChanged` still returns element-identical content.
|
|
324
|
+
for (const block of content) {
|
|
325
|
+
if (block.type !== "toolCall") continue;
|
|
326
|
+
validToolUseIds.add(toolCallPairingKey(block.id, originScope));
|
|
327
|
+
}
|
|
328
|
+
|
|
253
329
|
if (!contentChanged) return msg;
|
|
254
330
|
return { ...msg, content };
|
|
255
331
|
});
|
|
332
|
+
|
|
333
|
+
return { messages: transformed, realToolResultsById, validToolUseIds };
|
|
256
334
|
}
|
|
257
335
|
|
|
258
336
|
const MAX_TOOL_CALL_NAME_LENGTH = 128;
|
|
@@ -728,6 +806,11 @@ export function transformMessages<TApi extends Api>(
|
|
|
728
806
|
}
|
|
729
807
|
}
|
|
730
808
|
const targetHasReasoningSlot = targetReplaysPlaintextReasoning(model, targetCompat);
|
|
809
|
+
// Origin-scope sets, accumulated by the normalization map itself (below) so
|
|
810
|
+
// the scope needs no second transcript walk. Only read via `.has`, so
|
|
811
|
+
// iteration order is inert.
|
|
812
|
+
const responsesComponents = new Set<string>();
|
|
813
|
+
const opaqueCompositeCallIds = new Set<string>();
|
|
731
814
|
// First pass: transform messages (thinking blocks, tool call ID normalization)
|
|
732
815
|
const normalizedMessages = messages.map((msg, index) => {
|
|
733
816
|
// User and developer messages pass through unchanged
|
|
@@ -1123,6 +1206,13 @@ export function transformMessages<TApi extends Api>(
|
|
|
1123
1206
|
transformedContent[transformedContent.length - 1] = { ...finalBlock, text: finalBlock.text.trimEnd() };
|
|
1124
1207
|
}
|
|
1125
1208
|
|
|
1209
|
+
// Record this turn's tool-call ids into the origin scope. Origin scope
|
|
1210
|
+
// must be complete before dedup reads it: filled here off the SAME
|
|
1211
|
+
// post-transform content this map emits, in document order, and never
|
|
1212
|
+
// read inside the map — so by the time the dedup pass consults it the
|
|
1213
|
+
// sets hold every assistant id, never a partial build.
|
|
1214
|
+
recordToolCallOriginScope(assistantMsg, transformedContent, responsesComponents, opaqueCompositeCallIds);
|
|
1215
|
+
|
|
1126
1216
|
return {
|
|
1127
1217
|
...assistantMsg,
|
|
1128
1218
|
content: transformedContent,
|
|
@@ -1133,31 +1223,32 @@ export function transformMessages<TApi extends Api>(
|
|
|
1133
1223
|
// Per-concrete-id origin classification — the only scope `toolCallPairingKey`
|
|
1134
1224
|
// consults to decide whether a pipe-bearing id is a canonicalizable Responses
|
|
1135
1225
|
// composite or an opaque Chat Completions token that pairs by raw equality.
|
|
1136
|
-
|
|
1137
|
-
const
|
|
1226
|
+
// The normalization map above fills both; naming the pair is all this does.
|
|
1227
|
+
const originScope: ToolCallOriginScope = { responsesComponents, opaqueCompositeCallIds };
|
|
1228
|
+
// All real tool results, keyed by pairing key, in document order. One id can
|
|
1229
|
+
// map to more than one result: compaction can fold an assistant `tool_use`
|
|
1230
|
+
// into a summary string while its `tool_result` survives, and a later turn may
|
|
1231
|
+
// reuse the id. The dedup pass files them at each result's position in its own
|
|
1232
|
+
// output array, so `takeRealToolResult` below pulls the earliest unconsumed
|
|
1233
|
+
// result positioned AFTER the call's assistant turn (an orphaned earlier
|
|
1234
|
+
// result is never pulled forward onto a later call).
|
|
1235
|
+
//
|
|
1236
|
+
// The set carries the `tool_use` pairing keys the dedup pass left standing.
|
|
1237
|
+
// Anthropic rejects `tool_result` blocks whose `tool_use_id` does not appear in
|
|
1238
|
+
// a prior `tool_use` block. After handoff/compaction folds an assistant turn
|
|
1239
|
+
// into a summary string, the user-side `toolResult` for that turn can survive
|
|
1240
|
+
// while the originating `tool_use` disappears — leaving an orphan that
|
|
1241
|
+
// triggers HTTP 400, which the second pass drops as unreplayable.
|
|
1242
|
+
const {
|
|
1243
|
+
messages: transformed,
|
|
1244
|
+
realToolResultsById,
|
|
1245
|
+
validToolUseIds,
|
|
1246
|
+
} = deduplicateToolCallIds(
|
|
1138
1247
|
normalizedMessages,
|
|
1139
1248
|
originScope,
|
|
1140
1249
|
maxNormalizedToolCallIdLength,
|
|
1141
1250
|
duplicateToolCallIdSuffixPrefix,
|
|
1142
1251
|
);
|
|
1143
|
-
// All real tool results, keyed by id, in document order. One id can map to
|
|
1144
|
-
// more than one result: compaction can fold an assistant `tool_use` into a
|
|
1145
|
-
// summary string while its `tool_result` survives, and a later turn may reuse
|
|
1146
|
-
// the id. `takeRealToolResult` pulls the earliest unconsumed result positioned
|
|
1147
|
-
// AFTER the call's assistant turn, so an orphaned earlier result is never
|
|
1148
|
-
// pulled forward onto a later call (which would surface a prior turn's output).
|
|
1149
|
-
type IndexedToolResult = { index: number; msg: ToolResultMessage; consumed: boolean };
|
|
1150
|
-
const realToolResultsById = new Map<string, IndexedToolResult[]>();
|
|
1151
|
-
for (let index = 0; index < transformed.length; index++) {
|
|
1152
|
-
const msg = transformed[index];
|
|
1153
|
-
if (msg.role === "toolResult") {
|
|
1154
|
-
const entry: IndexedToolResult = { index, msg, consumed: false };
|
|
1155
|
-
const key = toolCallPairingKey(msg.toolCallId, originScope);
|
|
1156
|
-
const entries = realToolResultsById.get(key);
|
|
1157
|
-
if (entries) entries.push(entry);
|
|
1158
|
-
else realToolResultsById.set(key, [entry]);
|
|
1159
|
-
}
|
|
1160
|
-
}
|
|
1161
1252
|
const takeRealToolResult = (id: string, afterIndex: number): ToolResultMessage | undefined => {
|
|
1162
1253
|
const entries = realToolResultsById.get(toolCallPairingKey(id, originScope));
|
|
1163
1254
|
if (!entries) return undefined;
|
|
@@ -1169,19 +1260,6 @@ export function transformMessages<TApi extends Api>(
|
|
|
1169
1260
|
return undefined;
|
|
1170
1261
|
};
|
|
1171
1262
|
|
|
1172
|
-
// Anthropic rejects `tool_result` blocks whose `tool_use_id` does not appear in a prior
|
|
1173
|
-
// `tool_use` block. After handoff/compaction folds an assistant turn into a summary
|
|
1174
|
-
// string, the user-side `toolResult` for that turn can survive while the originating
|
|
1175
|
-
// `tool_use` disappears — leaving an orphan that triggers HTTP 400. Track the set of
|
|
1176
|
-
// `tool_use` ids that survive transformation so the second pass can drop orphans cleanly.
|
|
1177
|
-
const validToolUseIds = new Set<string>();
|
|
1178
|
-
for (const msg of transformed) {
|
|
1179
|
-
if (msg.role !== "assistant") continue;
|
|
1180
|
-
for (const block of msg.content) {
|
|
1181
|
-
if (block.type === "toolCall") validToolUseIds.add(toolCallPairingKey(block.id, originScope));
|
|
1182
|
-
}
|
|
1183
|
-
}
|
|
1184
|
-
|
|
1185
1263
|
// Second pass: ensure each surviving assistant tool call is immediately
|
|
1186
1264
|
// followed by exactly one corresponding tool result.
|
|
1187
1265
|
const result: Message[] = [];
|