@f5-sales-demo/pi-ai 21.13.0 → 21.13.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 +15 -0
- package/package.json +2 -2
- package/src/auth-storage.ts +12 -11
- package/src/providers/cursor.ts +30 -23
- package/src/utils/retry-after.ts +25 -0
- package/src/utils.ts +31 -1
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,21 @@
|
|
|
10
10
|
|
|
11
11
|
### Fixed
|
|
12
12
|
|
|
13
|
+
- Removed output-only lifecycle statuses before replaying persisted OpenAI Responses items,
|
|
14
|
+
including remote compaction and compaction summaries. Semantically ported from
|
|
15
|
+
[can1357/oh-my-pi#7743](https://github.com/can1357/oh-my-pi/pull/7743) and
|
|
16
|
+
[#10415](https://github.com/can1357/oh-my-pi/pull/10415)
|
|
17
|
+
([#3721](https://github.com/f5-sales-demo/xcsh/issues/3721)).
|
|
18
|
+
- Extended rate-limit handling to keep API-key credentials blocked until their live usage reset
|
|
19
|
+
and to understand absolute English and Chinese reset timestamps. Semantically ported from
|
|
20
|
+
[can1357/oh-my-pi#10325](https://github.com/can1357/oh-my-pi/pull/10325)
|
|
21
|
+
([#3721](https://github.com/f5-sales-demo/xcsh/issues/3721)).
|
|
22
|
+
- Prevented case-insensitive Windows environment lookup from replacing literal stored credentials,
|
|
23
|
+
and delayed Cursor success until the HTTP/2/CONNECT protocol terminates cleanly so late stream
|
|
24
|
+
errors remain visible. Semantically ported from
|
|
25
|
+
[can1357/oh-my-pi#7362](https://github.com/can1357/oh-my-pi/pull/7362) and
|
|
26
|
+
[#5636](https://github.com/can1357/oh-my-pi/pull/5636)
|
|
27
|
+
([#3711](https://github.com/f5-sales-demo/xcsh/issues/3711)).
|
|
13
28
|
- Exported the canonical provider credential-shape matcher so xcsh can protect otherwise
|
|
14
29
|
unconfigured tokens reversibly before provider submission, preserving exact-match local edits.
|
|
15
30
|
Semantically ported from
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@f5-sales-demo/pi-ai",
|
|
4
|
-
"version": "21.13.
|
|
4
|
+
"version": "21.13.2",
|
|
5
5
|
"description": "Unified LLM API with automatic model discovery and provider configuration",
|
|
6
6
|
"homepage": "https://github.com/f5-sales-demo/xcsh",
|
|
7
7
|
"author": "Can Boluk",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"@anthropic-ai/sdk": "^0.115",
|
|
47
47
|
"@aws-sdk/client-bedrock-runtime": "^3",
|
|
48
48
|
"@bufbuild/protobuf": "^2.11",
|
|
49
|
-
"@f5-sales-demo/pi-utils": "21.13.
|
|
49
|
+
"@f5-sales-demo/pi-utils": "21.13.2",
|
|
50
50
|
"@google/genai": "^2.15",
|
|
51
51
|
"@sinclair/typebox": "^0.34",
|
|
52
52
|
"@smithy/node-http-handler": "^4.4",
|
package/src/auth-storage.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
import { Database, type Statement } from "bun:sqlite";
|
|
11
11
|
import * as fs from "node:fs/promises";
|
|
12
12
|
import * as path from "node:path";
|
|
13
|
-
import { getAgentDbPath, logger } from "@f5-sales-demo/pi-utils";
|
|
13
|
+
import { $envExact, getAgentDbPath, logger } from "@f5-sales-demo/pi-utils";
|
|
14
14
|
import { getEnvApiKey } from "./stream";
|
|
15
15
|
import type { Provider } from "./types";
|
|
16
16
|
import type {
|
|
@@ -149,7 +149,7 @@ export type AuthStorageOptions = {
|
|
|
149
149
|
* Does NOT support "!command" syntax (that requires pi-natives).
|
|
150
150
|
*/
|
|
151
151
|
async function defaultConfigValueResolver(config: string): Promise<string | undefined> {
|
|
152
|
-
const envValue =
|
|
152
|
+
const envValue = $envExact(config);
|
|
153
153
|
return envValue || config;
|
|
154
154
|
}
|
|
155
155
|
|
|
@@ -1015,7 +1015,10 @@ export class AuthStorage {
|
|
|
1015
1015
|
// Queries provider usage endpoints to detect rate limits before they occur.
|
|
1016
1016
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
1017
1017
|
|
|
1018
|
-
#buildUsageCredential(credential:
|
|
1018
|
+
#buildUsageCredential(credential: AuthCredential): UsageCredential {
|
|
1019
|
+
if (credential.type === "api_key") {
|
|
1020
|
+
return { type: "api_key", apiKey: credential.key };
|
|
1021
|
+
}
|
|
1019
1022
|
return {
|
|
1020
1023
|
type: "oauth",
|
|
1021
1024
|
accessToken: credential.access,
|
|
@@ -1417,11 +1420,11 @@ export class AuthStorage {
|
|
|
1417
1420
|
|
|
1418
1421
|
async #getUsageReport(
|
|
1419
1422
|
provider: Provider,
|
|
1420
|
-
credential:
|
|
1423
|
+
credential: AuthCredential,
|
|
1421
1424
|
options?: { baseUrl?: string; timeoutMs?: number },
|
|
1422
1425
|
): Promise<UsageReport | null> {
|
|
1423
1426
|
return this.#fetchUsageCached(
|
|
1424
|
-
this.#
|
|
1427
|
+
this.#buildUsageRequest(provider, this.#buildUsageCredential(credential), options?.baseUrl),
|
|
1425
1428
|
options?.timeoutMs ?? this.#usageRequestTimeoutMs,
|
|
1426
1429
|
);
|
|
1427
1430
|
}
|
|
@@ -1510,15 +1513,13 @@ export class AuthStorage {
|
|
|
1510
1513
|
const now = Date.now();
|
|
1511
1514
|
let blockedUntil = now + (options?.retryAfterMs ?? AuthStorage.#defaultBackoffMs);
|
|
1512
1515
|
|
|
1513
|
-
if (
|
|
1516
|
+
if (this.#rankingStrategyResolver?.(provider)) {
|
|
1514
1517
|
const credential = this.#getCredentialsForProvider(provider)[sessionCredential.index];
|
|
1515
|
-
if (credential?.type ===
|
|
1518
|
+
if (credential?.type === sessionCredential.type) {
|
|
1516
1519
|
const report = await this.#getUsageReport(provider, credential, options);
|
|
1517
1520
|
if (report && this.#isUsageLimitReached(report)) {
|
|
1518
|
-
const resetAtMs = this.#getUsageResetAtMs(report,
|
|
1519
|
-
if (resetAtMs && resetAtMs > blockedUntil)
|
|
1520
|
-
blockedUntil = resetAtMs;
|
|
1521
|
-
}
|
|
1521
|
+
const resetAtMs = this.#getUsageResetAtMs(report, now);
|
|
1522
|
+
if (resetAtMs && resetAtMs > blockedUntil) blockedUntil = resetAtMs;
|
|
1522
1523
|
}
|
|
1523
1524
|
}
|
|
1524
1525
|
}
|
package/src/providers/cursor.ts
CHANGED
|
@@ -325,6 +325,27 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
|
|
|
325
325
|
let h2Client: http2.ClientHttp2Session | null = null;
|
|
326
326
|
let h2Request: http2.ClientHttp2Stream | null = null;
|
|
327
327
|
let heartbeatTimer: NodeJS.Timeout | null = null;
|
|
328
|
+
const h2Completion = Promise.withResolvers<void>();
|
|
329
|
+
let h2Settled = false;
|
|
330
|
+
let sawTurnEnded = false;
|
|
331
|
+
let endStreamError: Error | null = null;
|
|
332
|
+
const settleH2 = (error?: unknown): void => {
|
|
333
|
+
if (h2Settled) return;
|
|
334
|
+
h2Settled = true;
|
|
335
|
+
if (error !== undefined) {
|
|
336
|
+
h2Completion.reject(error);
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
if (endStreamError) {
|
|
340
|
+
h2Completion.reject(endStreamError);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
if (!sawTurnEnded) {
|
|
344
|
+
h2Completion.reject(new Error("Cursor stream ended before turnEnded"));
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
h2Completion.resolve();
|
|
348
|
+
};
|
|
328
349
|
|
|
329
350
|
try {
|
|
330
351
|
const apiKey = options?.apiKey;
|
|
@@ -346,6 +367,7 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
|
|
|
346
367
|
|
|
347
368
|
const baseUrl = model.baseUrl || CURSOR_API_URL;
|
|
348
369
|
h2Client = http2.connect(baseUrl);
|
|
370
|
+
h2Client.on("error", error => settleH2(error));
|
|
349
371
|
|
|
350
372
|
h2Request = h2Client.request({
|
|
351
373
|
":method": "POST",
|
|
@@ -363,7 +385,6 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
|
|
|
363
385
|
stream.push({ type: "start", partial: output });
|
|
364
386
|
|
|
365
387
|
let pendingBuffer = Buffer.alloc(0);
|
|
366
|
-
let endStreamError: Error | null = null;
|
|
367
388
|
let currentTextBlock: (TextContent & { index: number }) | null = null;
|
|
368
389
|
let currentThinkingBlock: (ThinkingContent & { index: number }) | null = null;
|
|
369
390
|
let currentToolCall: ToolCallState | null = null;
|
|
@@ -400,8 +421,6 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
|
|
|
400
421
|
conversationStateCache.set(conversationId, checkpoint);
|
|
401
422
|
};
|
|
402
423
|
|
|
403
|
-
let resolveH2: (() => void) | undefined;
|
|
404
|
-
|
|
405
424
|
h2Request.on("data", (chunk: Buffer) => {
|
|
406
425
|
pendingBuffer = Buffer.concat([pendingBuffer, chunk]);
|
|
407
426
|
|
|
@@ -417,6 +436,7 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
|
|
|
417
436
|
const endError = parseConnectEndStream(messageBytes);
|
|
418
437
|
if (endError) {
|
|
419
438
|
endStreamError = endError;
|
|
439
|
+
settleH2(endError);
|
|
420
440
|
h2Request?.close();
|
|
421
441
|
}
|
|
422
442
|
continue;
|
|
@@ -443,13 +463,8 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
|
|
|
443
463
|
log("error", "handleServerMessage", { error: String(error) });
|
|
444
464
|
});
|
|
445
465
|
|
|
446
|
-
//
|
|
447
|
-
|
|
448
|
-
if (isTurnEnded && resolveH2) {
|
|
449
|
-
const r = resolveH2;
|
|
450
|
-
resolveH2 = undefined;
|
|
451
|
-
r();
|
|
452
|
-
}
|
|
466
|
+
// Application completion is not protocol success; wait for a clean HTTP/2 end.
|
|
467
|
+
if (isTurnEnded) sawTurnEnded = true;
|
|
453
468
|
} catch (e) {
|
|
454
469
|
log("error", "parseServerMessage", { error: String(e) });
|
|
455
470
|
}
|
|
@@ -471,32 +486,24 @@ export const streamCursor: StreamFunction<"cursor-agent"> = (
|
|
|
471
486
|
|
|
472
487
|
heartbeatTimer = setInterval(sendHeartbeat, 5000);
|
|
473
488
|
|
|
474
|
-
const h2Completion = Promise.withResolvers<void>();
|
|
475
|
-
resolveH2 = h2Completion.resolve;
|
|
476
|
-
|
|
477
489
|
h2Request!.on("trailers", trailers => {
|
|
478
490
|
const status = trailers["grpc-status"];
|
|
479
491
|
const msg = trailers["grpc-message"];
|
|
480
|
-
if (status && status !== "0") {
|
|
481
|
-
|
|
492
|
+
if (status && status !== "0" && !endStreamError) {
|
|
493
|
+
endStreamError = new Error(`gRPC error ${status}: ${decodeURIComponent(String(msg || ""))}`);
|
|
482
494
|
}
|
|
483
495
|
});
|
|
484
496
|
|
|
485
497
|
h2Request!.on("end", () => {
|
|
486
|
-
|
|
487
|
-
if (endStreamError) {
|
|
488
|
-
h2Completion.reject(endStreamError);
|
|
489
|
-
return;
|
|
490
|
-
}
|
|
491
|
-
h2Completion.resolve();
|
|
498
|
+
settleH2();
|
|
492
499
|
});
|
|
493
500
|
|
|
494
|
-
h2Request!.on("error",
|
|
501
|
+
h2Request!.on("error", error => settleH2(error));
|
|
495
502
|
|
|
496
503
|
if (options?.signal) {
|
|
497
504
|
options.signal.addEventListener("abort", () => {
|
|
498
505
|
h2Request?.close();
|
|
499
|
-
|
|
506
|
+
settleH2(new Error("Request was aborted"));
|
|
500
507
|
});
|
|
501
508
|
}
|
|
502
509
|
await h2Completion.promise;
|
package/src/utils/retry-after.ts
CHANGED
|
@@ -1,6 +1,31 @@
|
|
|
1
1
|
export type HeadersLike = Headers | Record<string, string | undefined> | undefined | null;
|
|
2
2
|
|
|
3
3
|
const RETRY_AFTER_HINT = "retry-after-ms=";
|
|
4
|
+
const RETRY_AFTER_MS_BODY_PATTERN = /\bretry-after-ms\s*[:=]\s*([0-9]+)\b/i;
|
|
5
|
+
const WILL_RESET_AT_PATTERN =
|
|
6
|
+
/(?:will\s+)?reset at\s+([0-9]{4}-[0-9]{2}-[0-9]{2}[ T][0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]+)?(?:Z|[+-][0-9]{2}:?[0-9]{2})?)/i;
|
|
7
|
+
const CN_RESET_AT_PATTERN = /将在\s*([0-9]{4}-[0-9]{2}-[0-9]{2}\s+[0-9]{2}:[0-9]{2}:[0-9]{2})\s*重置/;
|
|
8
|
+
|
|
9
|
+
/** Parse body-level retry hints that are not represented by HTTP headers. */
|
|
10
|
+
export function getRetryAfterMsFromErrorText(message: string): number | undefined {
|
|
11
|
+
const retryAfterMsMatch = RETRY_AFTER_MS_BODY_PATTERN.exec(message);
|
|
12
|
+
if (retryAfterMsMatch?.[1]) {
|
|
13
|
+
const milliseconds = Number(retryAfterMsMatch[1]);
|
|
14
|
+
if (Number.isFinite(milliseconds) && milliseconds > 0) return milliseconds;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
for (const pattern of [WILL_RESET_AT_PATTERN, CN_RESET_AT_PATTERN]) {
|
|
18
|
+
const match = pattern.exec(message);
|
|
19
|
+
if (!match?.[1]) continue;
|
|
20
|
+
const normalized = match[1].replace(" ", "T");
|
|
21
|
+
const timestamp = /(?:Z|[+-][0-9]{2}:?[0-9]{2})$/i.test(normalized) ? normalized : `${normalized}Z`;
|
|
22
|
+
const resetAt = Date.parse(timestamp);
|
|
23
|
+
const delay = resetAt - Date.now();
|
|
24
|
+
if (!Number.isNaN(resetAt) && delay > 0) return delay;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return undefined;
|
|
28
|
+
}
|
|
4
29
|
|
|
5
30
|
export function formatErrorMessageWithRetryAfter(error: unknown, headers?: HeadersLike): string {
|
|
6
31
|
const message = error instanceof Error ? error.message : JSON.stringify(error);
|
package/src/utils.ts
CHANGED
|
@@ -65,12 +65,42 @@ export function truncateResponseItemId(id: string, prefix: string): string {
|
|
|
65
65
|
return `${prefix}_${Bun.hash(id).toString(36)}`;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Remove response-only lifecycle state from item types that reject it when
|
|
70
|
+
* replayed as OpenAI Responses input. The original array and untouched item
|
|
71
|
+
* identities are preserved when no change is needed.
|
|
72
|
+
*/
|
|
73
|
+
export function stripOpenAIResponsesOutputOnlyStatusesForReplay<TItem extends { type?: unknown; status?: unknown }>(
|
|
74
|
+
items: TItem[],
|
|
75
|
+
): TItem[] {
|
|
76
|
+
let sanitized: TItem[] | undefined;
|
|
77
|
+
for (let index = 0; index < items.length; index++) {
|
|
78
|
+
const item = items[index]!;
|
|
79
|
+
const rejectsOutputStatus =
|
|
80
|
+
item.type === "message" ||
|
|
81
|
+
item.type === "function_call" ||
|
|
82
|
+
item.type === "custom_tool_call" ||
|
|
83
|
+
item.type === "compaction" ||
|
|
84
|
+
item.type === "compaction_summary";
|
|
85
|
+
if (!rejectsOutputStatus || !Object.hasOwn(item, "status")) {
|
|
86
|
+
sanitized?.push(item);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
if (!sanitized) sanitized = items.slice(0, index);
|
|
90
|
+
const withoutStatus = { ...item };
|
|
91
|
+
delete withoutStatus.status;
|
|
92
|
+
sanitized.push(withoutStatus);
|
|
93
|
+
}
|
|
94
|
+
return sanitized ?? items;
|
|
95
|
+
}
|
|
96
|
+
|
|
68
97
|
export function sanitizeOpenAIResponsesHistoryItemsForReplay(items: Array<Record<string, unknown>>): ResponseInput {
|
|
69
98
|
const normalizedCallIds = new Map<string, string>();
|
|
70
|
-
|
|
99
|
+
const sanitized = items.flatMap(item => {
|
|
71
100
|
const sanitized = sanitizeOpenAIResponsesHistoryItemForReplay(item, normalizedCallIds);
|
|
72
101
|
return sanitized ? [sanitized] : [];
|
|
73
102
|
});
|
|
103
|
+
return stripOpenAIResponsesOutputOnlyStatusesForReplay(sanitized);
|
|
74
104
|
}
|
|
75
105
|
|
|
76
106
|
function sanitizeOpenAIResponsesHistoryItemForReplay(
|